From 2c2de7faf0dbc75858584a2b066cdd1348a2b56c Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 13:26:26 +1000 Subject: [PATCH 01/36] Add Pi bridge integration --- .../build-pi-villani-runtime-release.yml | 51 + integrations/pi-villani/README.md | 6 + integrations/pi-villani/docs/release.md | 5 + integrations/pi-villani/package-lock.json | 3163 +++++++++++++++++ integrations/pi-villani/package.json | 1 + integrations/pi-villani/src/index.ts | 6 + integrations/pi-villani/src/modelProxy.ts | 4 + integrations/pi-villani/src/process.ts | 9 + integrations/pi-villani/src/render.ts | 3 + integrations/pi-villani/src/runtime.ts | 6 + integrations/pi-villani/src/runtimeConfig.ts | 7 + integrations/pi-villani/src/smoke.test.ts | 9 + integrations/pi-villani/tsconfig.json | 19 + packaging/smoke_test_runtime.py | 8 + packaging/villani_runtime_entry.py | 3 + villani_code/cli.py | 33 +- villani_code/integrations/__init__.py | 0 villani_code/integrations/pi_bridge.py | 152 + .../integrations/pi_bridge_protocol.py | 41 + villani_code/runner_factory.py | 34 + villani_code/state.py | 2 + villani_code/state_runtime.py | 2 +- villani_code/state_tooling.py | 8 +- 23 files changed, 3548 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/build-pi-villani-runtime-release.yml create mode 100644 integrations/pi-villani/README.md create mode 100644 integrations/pi-villani/docs/release.md create mode 100644 integrations/pi-villani/package-lock.json create mode 100644 integrations/pi-villani/package.json create mode 100644 integrations/pi-villani/src/index.ts create mode 100644 integrations/pi-villani/src/modelProxy.ts create mode 100644 integrations/pi-villani/src/process.ts create mode 100644 integrations/pi-villani/src/render.ts create mode 100644 integrations/pi-villani/src/runtime.ts create mode 100644 integrations/pi-villani/src/runtimeConfig.ts create mode 100644 integrations/pi-villani/src/smoke.test.ts create mode 100644 integrations/pi-villani/tsconfig.json create mode 100644 packaging/smoke_test_runtime.py create mode 100644 packaging/villani_runtime_entry.py create mode 100644 villani_code/integrations/__init__.py create mode 100644 villani_code/integrations/pi_bridge.py create mode 100644 villani_code/integrations/pi_bridge_protocol.py create mode 100644 villani_code/runner_factory.py diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml new file mode 100644 index 00000000..5ad41e74 --- /dev/null +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -0,0 +1,51 @@ +name: Build Pi Villani Runtime Release +on: + workflow_dispatch: + push: + tags: ["pi-villani-runtime-v*"] +jobs: + build: + strategy: + matrix: + include: + - key: win32-x64 + os: windows-latest + ext: zip + - key: darwin-arm64 + os: macos-14 + ext: tar.gz + - key: darwin-x64 + os: macos-13 + ext: tar.gz + - key: linux-x64 + os: ubuntu-latest + ext: tar.gz + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: {python-version: '3.11'} + - run: python -m pip install -e . pyinstaller + - run: pyinstaller packaging/villani_runtime.spec --distpath dist-runtime/villani-code + - run: python packaging/smoke_test_runtime.py dist-runtime/villani-code/villani-code${{ runner.os == 'Windows' && '.exe' || '' }} + - shell: bash + run: | + name="villani-runtime-v0.1.0-${{ matrix.key }}.${{ matrix.ext }}" + cd dist-runtime + if [[ "${{ matrix.ext }}" == "zip" ]]; then 7z a ../$name villani-code; else tar -czf ../$name villani-code; fi + cd .. + shasum -a 256 "$name" > "${name}.sha256" + - uses: actions/upload-artifact@v4 + with: {name: runtime-${{ matrix.key }}, path: "villani-runtime-v0.1.0-${{ matrix.key }}.${{ matrix.ext }}*"} + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: {path: artifacts} + - run: find artifacts -name '*.sha256' -exec cat {} \; > checksums.txt + - uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/**/villani-runtime-v0.1.0-* + checksums.txt diff --git a/integrations/pi-villani/README.md b/integrations/pi-villani/README.md new file mode 100644 index 00000000..dfbbcdc8 --- /dev/null +++ b/integrations/pi-villani/README.md @@ -0,0 +1,6 @@ +# @mmprotest/pi-villani +Install with: +```bash +pi install npm:@mmprotest/pi-villani +``` +Provides `/villani `, `/villani-abort`, and `/villani-confirm-test`. diff --git a/integrations/pi-villani/docs/release.md b/integrations/pi-villani/docs/release.md new file mode 100644 index 00000000..e92d93c9 --- /dev/null +++ b/integrations/pi-villani/docs/release.md @@ -0,0 +1,5 @@ +# Release checklist +1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.0`. +2. Verify assets and checksums. +3. Publish `@mmprotest/pi-villani`. +Install: `pi install npm:@mmprotest/pi-villani`. diff --git a/integrations/pi-villani/package-lock.json b/integrations/pi-villani/package-lock.json new file mode 100644 index 00000000..50729ed7 --- /dev/null +++ b/integrations/pi-villani/package-lock.json @@ -0,0 +1,3163 @@ +{ + "name": "@mmprotest/pi-villani", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mmprotest/pi-villani", + "version": "0.1.0", + "dependencies": { + "@earendil-works/pi-ai": "latest", + "@earendil-works/pi-coding-agent": "latest", + "adm-zip": "latest", + "tar": "latest" + }, + "devDependencies": { + "@types/adm-zip": "latest", + "@types/node": "latest", + "typescript": "latest" + }, + "engines": { + "node": ">=22.19.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==", + "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/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "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/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==", + "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==", + "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==", + "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==", + "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==", + "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.974.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", + "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.31", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz", + "integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz", + "integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz", + "integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz", + "integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/credential-provider-env": "^3.972.49", + "@aws-sdk/credential-provider-http": "^3.972.51", + "@aws-sdk/credential-provider-login": "^3.972.55", + "@aws-sdk/credential-provider-process": "^3.972.49", + "@aws-sdk/credential-provider-sso": "^3.972.55", + "@aws-sdk/credential-provider-web-identity": "^3.972.55", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz", + "integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz", + "integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.49", + "@aws-sdk/credential-provider-http": "^3.972.51", + "@aws-sdk/credential-provider-ini": "^3.972.56", + "@aws-sdk/credential-provider-process": "^3.972.49", + "@aws-sdk/credential-provider-sso": "^3.972.55", + "@aws-sdk/credential-provider-web-identity": "^3.972.55", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz", + "integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz", + "integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/token-providers": "3.1074.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1074.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz", + "integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz", + "integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.22.tgz", + "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.18.tgz", + "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.31.tgz", + "integrity": "sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz", + "integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz", + "integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "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==", + "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.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", + "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.2.tgz", + "integrity": "sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ==", + "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/@earendil-works/pi-coding-agent": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.2.tgz", + "integrity": "sha512-m9v7OUit0s9LklWfh61ca/XY5INjUzjtYtNZwy3cNvyjOLk3IpBgghP8aAp0iH35rLaiRwuuWiJ8t88ODMWY+A==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.80.2", + "@earendil-works/pi-ai": "^0.80.2", + "@earendil-works/pi-tui": "^0.80.2", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.1.38", + "undici": "8.5.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "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/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "license": "Apache-2.0", + "dependencies": { + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "license": "Apache-2.0", + "dependencies": { + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "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/signature-v4-multi-region": "^3.996.27", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.2.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.80.2", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.2.tgz", + "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/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.2.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "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/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "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/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "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/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "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/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "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/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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", + "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/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "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/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/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==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "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/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "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/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "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==", + "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/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "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==", + "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/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", + "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz", + "integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.2.tgz", + "integrity": "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.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==", + "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==", + "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.5.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz", + "integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "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==", + "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==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/adm-zip": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.0.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", + "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "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==", + "license": "MIT" + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "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==", + "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==", + "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==", + "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==", + "license": "BSD-3-Clause" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "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==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "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==", + "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==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "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==", + "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/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "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==", + "license": "Apache-2.0" + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "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==", + "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", + "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==", + "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/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "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==", + "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==", + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "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/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "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==", + "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/tar": { + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "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==", + "license": "0BSD" + }, + "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==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "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==", + "license": "MIT" + }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "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==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/integrations/pi-villani/package.json b/integrations/pi-villani/package.json new file mode 100644 index 00000000..aa873016 --- /dev/null +++ b/integrations/pi-villani/package.json @@ -0,0 +1 @@ +{"name":"@mmprotest/pi-villani","version":"0.1.0","description":"Pi extension for Villani Code","main":"dist/index.js","types":"dist/index.d.ts","type":"module","engines":{"node":">=22.19.0"},"pi":{"extensions":["./dist/index.js"]},"files":["dist","!dist/*.test.js","!dist/*.test.d.ts","README.md","docs"],"scripts":{"build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit","test":"npm run build && node --test dist/*.test.js"},"dependencies":{"@earendil-works/pi-ai":"latest","@earendil-works/pi-coding-agent":"latest","adm-zip":"latest","tar":"latest"},"devDependencies":{"typescript":"latest","@types/node":"latest","@types/adm-zip":"latest"}} diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts new file mode 100644 index 00000000..79efb53d --- /dev/null +++ b/integrations/pi-villani/src/index.ts @@ -0,0 +1,6 @@ +import { randomUUID } from 'node:crypto'; import { resolveVillaniRuntime } from './runtime.js'; import { VillaniBridgeProcess } from './process.js'; import { startModelProxy } from './modelProxy.js'; import { finalMessage, renderBridgeEvent } from './render.js'; +type VillaniMode='runner'|'villani'; type RunCommand={type:'run';id:string;task:string;repo:string;mode:VillaniMode;config:any}; let active:{bridge?:VillaniBridgeProcess, abort:AbortController}|null=null; +function buildRunConfig(proxyUrl?:string, model?:any){ if(proxyUrl) return {provider:'openai',model:model?.id??'pi-current-model',base_url:proxyUrl,pi_model_proxy:true}; return {provider:process.env.VILLANI_PROVIDER,model:process.env.VILLANI_MODEL,base_url:process.env.VILLANI_BASE_URL,api_key:process.env.VILLANI_API_KEY};} +export async function runVillani(task:string, ctx:any={}){ if(!task?.trim()) throw new Error('/villani requires a task argument'); if(active) throw new Error('A Villani run is already active'); const abort=new AbortController(); active={abort}; let proxy:any; try{const repo=ctx.cwd||process.cwd(); const useProxy=process.env.VILLANI_USE_PI_MODEL!=='false'; let proxyUrl:string|undefined; let model=ctx.model; if(useProxy){proxy=await startModelProxy(ctx.pi||{complete:async()=>''},model,abort.signal); proxyUrl=proxy.url;} const exe=await resolveVillaniRuntime({signal:abort.signal}); const bridge=new VillaniBridgeProcess(exe,{proxyMode:useProxy,explicitConfigMode:!useProxy}); active.bridge=bridge; await bridge.waitUntilReady(); const id=randomUUID(); const command:RunCommand={type:'run',id,task,repo,mode:(process.env.VILLANI_MODE as VillaniMode|undefined)||'runner',config:buildRunConfig(proxyUrl,model)}; bridge.send(command); return await new Promise((resolve,reject)=>{bridge.on('event',async(e:any)=>{if(e.type==='approval_required'){let ok=false; try{ok=ctx.confirm?await ctx.confirm(e.title||'Approve Villani action?'):false;}catch{} bridge.respondToApproval(id,e.request_id,ok);} else if(['run_completed','run_failed','run_aborted'].includes(e.type)){resolve(finalMessage(e));} else renderBridgeEvent(e,{log:ctx.log});}); bridge.on('error',reject);});} finally{active?.bridge?.kill(); proxy?.close?.(); active=null;}} +export function abortVillani(){ if(!active) return false; active.abort.abort(); active.bridge?.kill(); active=null; return true; } +export default function activate(ctx:any){ctx?.registerCommand?.('/villani',runVillani); ctx?.registerCommand?.('/villani-abort',abortVillani); ctx?.registerCommand?.('/villani-confirm-test',()=>true);} diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts new file mode 100644 index 00000000..c0d1af55 --- /dev/null +++ b/integrations/pi-villani/src/modelProxy.ts @@ -0,0 +1,4 @@ +import http from 'node:http'; +export function zeroUsage(){return {prompt_tokens:0,completion_tokens:0,total_tokens:0};} +function sanitize(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]');} +export async function startModelProxy(pi:{complete:(model:any,context:any,options:any)=>Promise}, model:any, signal?:AbortSignal){const server=http.createServer(async(req,res)=>{if(req.method!=='POST'||req.url!=='/v1/chat/completions'){res.writeHead(404).end();return;} let body=''; req.on('data',d=>body+=d); req.on('end',async()=>{try{const j=JSON.parse(body||'{}'); const out=await pi.complete(model,{messages:j.messages||[],tools:j.tools},{signal}); const text=typeof out==='string'?out:(out.text||out.content||''); const msg:any={role:'assistant',content:text}; if(out.tool_calls) msg.tool_calls=out.tool_calls; const chunk={id:'pi-villani',object:'chat.completion',created:Math.floor(Date.now()/1000),model:j.model||'pi-current-model',choices:[{index:0,message:msg,finish_reason:'stop'}],usage:zeroUsage()}; if(j.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...chunk,object:'chat.completion.chunk'})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify(chunk));}}catch(e){res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitize(e)}}));}});}); await new Promise(r=>server.listen(0,'127.0.0.1',r)); signal?.addEventListener('abort',()=>server.close()); const addr=server.address(); if(!addr||typeof addr==='string')throw new Error('Proxy bind failed'); return {url:`http://127.0.0.1:${addr.port}`,close:()=>server.close()};} diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts new file mode 100644 index 00000000..3a8c5bac --- /dev/null +++ b/integrations/pi-villani/src/process.ts @@ -0,0 +1,9 @@ +import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; import { EventEmitter } from 'node:events'; +export interface BridgeEvent{type:string;[k:string]:unknown} export interface LaunchOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; timeoutMs?:number} +export function sanitizedEnv(opts:LaunchOptions={}):NodeJS.ProcessEnv{const e={...(opts.env||process.env)}; if(opts.proxyMode&&!opts.explicitConfigMode){for(const k of Object.keys(e)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|AUTH|TOKEN|BEARER)$/i.test(k)) delete e[k];}} return e;} +export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; + constructor(executable:string, opts:LaunchOptions={}){super(); this.proc=spawn(executable,['bridge','--stdio'],{shell:false,env:sanitizedEnv(opts)}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString()));} + onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; this.emit('event',e);}catch(err){this.kill(); this.emit('error',new Error('Malformed JSONL from bridge'));}}} + send(c:unknown){this.proc.stdin.write(JSON.stringify(c)+'\n');} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} + waitUntilReady(timeoutMs=10000){return new Promise((res,rej)=>{if(this.ready)return res(); const t=setTimeout(()=>rej(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); this.on('event',(e:BridgeEvent)=>{if(e.type==='ready'){clearTimeout(t);res();}}); this.proc.once('exit',()=>{clearTimeout(t);rej(new Error(`Bridge exited before ready. stderr: ${this.stderr}`));});});} + waitForExit(){return new Promise(r=>this.proc.once('exit',r));}} diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts new file mode 100644 index 00000000..7ad2e024 --- /dev/null +++ b/integrations/pi-villani/src/render.ts @@ -0,0 +1,3 @@ +export function visibleChangedFiles(files:string[]=[]){return files.filter(f=>!/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/ .test(f));} +export function renderBridgeEvent(event:any, ui:{log?:(s:string)=>void}={}){const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; if(['phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','bridge_diagnostic','error'].includes(event.type)) ui.log?.(`[Villani] ${event.type}`);} +export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; return [head,event.summary,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:''].filter(Boolean).join('\n\n');} diff --git a/integrations/pi-villani/src/runtime.ts b/integrations/pi-villani/src/runtime.ts new file mode 100644 index 00000000..33b916d3 --- /dev/null +++ b/integrations/pi-villani/src/runtime.ts @@ -0,0 +1,6 @@ +import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { assetName, executableRelativePath, platformKey, VILLANI_RUNTIME_REPOSITORY, VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; +export function parseChecksums(text:string): Map{const m=new Map(); for(const l of text.split(/\r?\n/)){const p=l.trim().split(/\s+/); if(p.length>=2)m.set(p[p.length-1],p[0]);} return m;} +export function cacheRoot(){return process.platform==='win32'?join(process.env.LOCALAPPDATA||homedir(),'pi-villani','runtime'):join(homedir(),'.cache','pi-villani','runtime');} +async function download(url:string, signal?:AbortSignal):Promise{const r=await fetch(url,{signal}); if(!r.ok) throw new Error(`Download failed ${r.status}: ${url}`); return Buffer.from(await r.arrayBuffer());} +export async function resolveVillaniRuntime(opts:{signal?:AbortSignal, platform?:string, arch?:string}={}):Promise{ if(process.env.VILLANI_COMMAND) return process.env.VILLANI_COMMAND; const key=platformKey(opts.platform as any, opts.arch as any); const asset=assetName(key); const root=cacheRoot(); const exe=join(root,VILLANI_RUNTIME_VERSION,executableRelativePath(key)); const marker=join(root,VILLANI_RUNTIME_VERSION,'.verified.json'); const base=`https://github.com/${VILLANI_RUNTIME_REPOSITORY}/releases/download/${VILLANI_RUNTIME_TAG}`; const checks=parseChecksums((await download(`${base}/checksums.txt`,opts.signal)).toString('utf8')); const sum=checks.get(asset); if(!sum) throw new Error(`Missing checksum for ${asset}`); if(existsSync(exe)&&existsSync(marker)){try{const j=JSON.parse(readFileSync(marker,'utf8')); if(j.version===VILLANI_RUNTIME_VERSION&&j.asset===asset&&j.sha256===sum)return exe;}catch{}} + const buf=await download(`${base}/${asset}`,opts.signal); const got=createHash('sha256').update(buf).digest('hex'); if(got!==sum) throw new Error(`Checksum mismatch for ${asset}`); mkdirSync(join(root,VILLANI_RUNTIME_VERSION),{recursive:true}); const archive=join(root,VILLANI_RUNTIME_VERSION,asset); writeFileSync(archive,buf); const child_process=await import('node:child_process'); const cmd=asset.endsWith('.zip')?`python -m zipfile -e ${JSON.stringify(archive)} ${JSON.stringify(join(root,VILLANI_RUNTIME_VERSION))}`:`tar -xzf ${JSON.stringify(archive)} -C ${JSON.stringify(join(root,VILLANI_RUNTIME_VERSION))}`; child_process.execSync(cmd); if(!existsSync(exe)) throw new Error(`Runtime executable missing after extraction: ${exe}`); writeFileSync(marker,JSON.stringify({version:VILLANI_RUNTIME_VERSION,asset,sha256:sum})); return exe; } diff --git a/integrations/pi-villani/src/runtimeConfig.ts b/integrations/pi-villani/src/runtimeConfig.ts new file mode 100644 index 00000000..170afabe --- /dev/null +++ b/integrations/pi-villani/src/runtimeConfig.ts @@ -0,0 +1,7 @@ +export const VILLANI_RUNTIME_VERSION = "0.1.0"; +export const VILLANI_RUNTIME_REPOSITORY = "mmprotest/villani-code"; +export const VILLANI_RUNTIME_TAG = `pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`; +export type PlatformKey = "win32-x64"|"darwin-arm64"|"darwin-x64"|"linux-x64"; +export function platformKey(platform=process.platform, arch=process.arch): PlatformKey { const k=`${platform}-${arch}`; if(["win32-x64","darwin-arm64","darwin-x64","linux-x64"].includes(k)) return k as PlatformKey; throw new Error(`Unsupported platform: ${k}`); } +export function assetName(key: PlatformKey): string { return `villani-runtime-v${VILLANI_RUNTIME_VERSION}-${key}.${key.startsWith('win32')?'zip':'tar.gz'}`; } +export function executableRelativePath(key: PlatformKey): string { return key.startsWith('win32')?'villani-code/villani-code.exe':'villani-code/villani-code'; } diff --git a/integrations/pi-villani/src/smoke.test.ts b/integrations/pi-villani/src/smoke.test.ts new file mode 100644 index 00000000..ffea1fe1 --- /dev/null +++ b/integrations/pi-villani/src/smoke.test.ts @@ -0,0 +1,9 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; +import { assetName, executableRelativePath, platformKey } from './runtimeConfig.js'; +import { parseChecksums } from './runtime.js'; +import { sanitizedEnv } from './process.js'; +import { zeroUsage } from './modelProxy.js'; +import { visibleChangedFiles } from './render.js'; +test('runtime asset helpers',()=>{assert.equal(assetName(platformKey('linux','x64')),'villani-runtime-v0.1.0-linux-x64.tar.gz'); assert.equal(executableRelativePath('win32-x64'),'villani-code/villani-code.exe');}); +test('checksum parsing and env sanitization',()=>{assert.equal(parseChecksums('abc file.tgz').get('file.tgz'),'abc'); const e=sanitizedEnv({proxyMode:true,env:{OPENAI_API_KEY:'x',PATH:'p'}}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.PATH,'p');}); +test('usage and render filters',()=>{assert.deepEqual(zeroUsage(),{prompt_tokens:0,completion_tokens:0,total_tokens:0}); assert.deepEqual(visibleChangedFiles(['a.py','.villani/x','x.pyc']),['a.py']);}); diff --git a/integrations/pi-villani/tsconfig.json b/integrations/pi-villani/tsconfig.json new file mode 100644 index 00000000..6a9515ea --- /dev/null +++ b/integrations/pi-villani/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/packaging/smoke_test_runtime.py b/packaging/smoke_test_runtime.py new file mode 100644 index 00000000..b1aaf71f --- /dev/null +++ b/packaging/smoke_test_runtime.py @@ -0,0 +1,8 @@ +import json, subprocess, sys +exe=sys.argv[1] +p=subprocess.Popen([exe,'bridge','--stdio'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True) +assert p.stdout and p.stdin +ready=json.loads(p.stdout.readline()); assert ready=={"type":"ready","protocol_version":1} +p.stdin.write('{"type":"ping","id":"release-smoke"}\n'); p.stdin.flush() +pong=json.loads(p.stdout.readline()); assert pong=={"type":"pong","id":"release-smoke"} +p.kill() diff --git a/packaging/villani_runtime_entry.py b/packaging/villani_runtime_entry.py new file mode 100644 index 00000000..b82ea3c0 --- /dev/null +++ b/packaging/villani_runtime_entry.py @@ -0,0 +1,3 @@ +from villani_code.cli import app +if __name__ == "__main__": + app() diff --git a/villani_code/cli.py b/villani_code/cli.py index eb28cb42..95a6d362 100644 --- a/villani_code/cli.py +++ b/villani_code/cli.py @@ -20,6 +20,7 @@ from villani_code.debug_bundle import create_debug_bundle from villani_code.debug_mode import DebugMode, build_debug_config from villani_code.trace_summary import write_summary_from_events, write_tool_calls_from_events +from villani_code.runner_factory import build_runner app = typer.Typer(help="Villani: constrained-inference coding agent with visible context governance") mcp_app = typer.Typer(help="Manage MCP servers") @@ -95,28 +96,7 @@ def _resolve_villani_flag(repo: Path, cli_value: bool | None) -> bool: def _build_runner(base_url: str, model: str, repo: Path, max_tokens: int, stream: bool, thinking: Optional[str], unsafe: bool, verbose: bool, extra_json: Optional[str], redact: bool, dangerously_skip_permissions: bool, auto_accept_edits: bool, auto_approve: bool, plan_mode: Literal["off", "auto", "strict"], max_repair_attempts: int, small_model: bool, provider: Literal["anthropic", "openai"], api_key: Optional[str], villani_mode: bool = False, villani_objective: str | None = None, benchmark_runtime_json: str | None = None, debug_mode: DebugMode = DebugMode.OFF, debug_dir: Optional[Path] = None, memory_enabled: bool = False, memory_update_interval_tool_calls: int = 5) -> Runner: - resolved_repo = repo.resolve() - try: - ensure_runtime_dependencies_not_shadowed(resolved_repo) - except RuntimeError as exc: - raise typer.BadParameter(str(exc)) from exc - - client: Any - if provider == "openai": - resolved_api_key = api_key or os.environ.get("OPENAI_API_KEY") - client = OpenAIClient(base_url=base_url, api_key=resolved_api_key) - else: - _ = api_key or os.environ.get("ANTHROPIC_API_KEY") - client = AnthropicClient(base_url=base_url) - thinking_obj = None - if thinking: - try: - thinking_obj = json.loads(thinking) - except json.JSONDecodeError: - thinking_obj = thinking - benchmark_config = BenchmarkRuntimeConfig.model_validate_json(benchmark_runtime_json) if benchmark_runtime_json else None - debug_config = build_debug_config(debug_mode.value if isinstance(debug_mode, DebugMode) else str(debug_mode), debug_dir=debug_dir) - return Runner(client=client, repo=resolved_repo, model=model, max_tokens=max_tokens, stream=stream, thinking=thinking_obj, unsafe=unsafe, verbose=verbose, extra_json=extra_json, redact=redact, bypass_permissions=dangerously_skip_permissions, auto_accept_edits=auto_accept_edits, auto_approve=auto_approve, plan_mode=plan_mode, max_repair_attempts=max_repair_attempts, small_model=small_model, villani_mode=villani_mode, villani_objective=villani_objective, benchmark_config=benchmark_config, debug_config=debug_config, provider=provider, memory_enabled=memory_enabled, memory_update_interval_tool_calls=memory_update_interval_tool_calls) + return build_runner(base_url=base_url, model=model, repo=repo, max_tokens=max_tokens, stream=stream, thinking=thinking, unsafe=unsafe, verbose=verbose, extra_json=extra_json, redact=redact, dangerously_skip_permissions=dangerously_skip_permissions, auto_accept_edits=auto_accept_edits, auto_approve=auto_approve, plan_mode=plan_mode, max_repair_attempts=max_repair_attempts, small_model=small_model, provider=provider, api_key=api_key, villani_mode=villani_mode, villani_objective=villani_objective, benchmark_runtime_json=benchmark_runtime_json, debug_mode=debug_mode, debug_dir=debug_dir, memory_enabled=memory_enabled, memory_update_interval_tool_calls=memory_update_interval_tool_calls) def _run_interactive(base_url: str, model: str, repo: Path, max_tokens: int, small_model: bool, provider: Literal["anthropic", "openai"], api_key: Optional[str], villani_mode: bool = False, villani_objective: str | None = None, auto_approve: bool = False, debug_mode: DebugMode = DebugMode.OFF, debug_dir: Optional[Path] = None) -> None: @@ -175,6 +155,15 @@ def main( _run_interactive(base_url, model, repo, max_tokens, small_model, provider, api_key, villani_mode=resolved_villani, auto_approve=auto_approve) +@app.command() +def bridge( + stdio: bool = typer.Option(False, "--stdio", help="Run the JSONL stdio bridge for external integrations."), +) -> None: + if not stdio: + raise typer.BadParameter("Only --stdio is supported for the bridge command") + from villani_code.integrations.pi_bridge import main_stdio + main_stdio() + @app.command() def run( instruction: str = typer.Argument(..., help="User instruction"), diff --git a/villani_code/integrations/__init__.py b/villani_code/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py new file mode 100644 index 00000000..b2553dd3 --- /dev/null +++ b/villani_code/integrations/pi_bridge.py @@ -0,0 +1,152 @@ +from __future__ import annotations +import hashlib,json,os,subprocess,sys,threading,traceback +from dataclasses import dataclass,field +from pathlib import Path +from typing import Any,Callable,TextIO +from villani_code.execution import ExecutionBudget, VILLANI_TASK_BUDGET +from villani_code.integrations.pi_bridge_protocol import * + +HIDDEN={'.villani','.villani_code','__pycache__'} +def _visible(p:str)->bool: + parts=p.replace('\\','/').split('/'); return not (any(x in HIDDEN for x in parts) or p.endswith('.pyc')) +def _cap(v:Any,n:int=2000)->str: return str(v)[:n] +def summarize_approval_request(tool_name:str, tool_input:dict[str,Any])->tuple[str,dict[str,Any]]: + if tool_name=='Write': + path=str(tool_input.get('path') or tool_input.get('file_path') or '') + return f'Write file: {path}', {'path':path,'content_chars':len(str(tool_input.get('content','')))} + if tool_name=='Patch': + path=str(tool_input.get('path') or tool_input.get('file_path') or '') + return f'Patch file: {path}', {'path':path,'operation':_cap(tool_input.get('operation') or 'patch',100)} + if tool_name=='Bash': return 'Run command', {'command':_cap(tool_input.get('command',''))} + out={k:_cap(tool_input[k]) for k in ('path','file_path','command') if k in tool_input} + return tool_name,out + +def git_changed_files(repo:Path)->list[str]: + try: + r=subprocess.run(['git','status','--porcelain=v1','--untracked-files=all'],cwd=repo,text=True,capture_output=True,timeout=10) + if r.returncode: return [] + files=[] + for line in r.stdout.splitlines(): + name=line[3:] if len(line)>3 else '' + if ' -> ' in name: name=name.split(' -> ',1)[1] + if name and _visible(name): files.append(name) + return sorted(set(files)) + except Exception: return [] +def hash_file(path:Path)->str|None: + try: + if not path.is_file(): return None + h=hashlib.sha256(); h.update(path.read_bytes()); return h.hexdigest() + except Exception: return None +def hash_files(repo:Path, files:list[str])->dict[str,str|None]: return {f:hash_file(repo/f) for f in files} +def attributed_changed_files(repo:Path,before_dirty:list[str],before_dirty_hashes:dict[str,str|None],touched_files:set[str]|list[str])->tuple[list[str],list[str]]: + after=git_changed_files(repo); before=set(before_dirty); touched=set(touched_files); changed=[]; pre=[] + for f in after: + h=hash_file(repo/f) + if f not in before or before_dirty_hashes.get(f)!=h or (f in touched and before_dirty_hashes.get(f)!=h): changed.append(f) + else: pre.append(f) + return sorted(set(filter(_visible,changed))), sorted(set(filter(_visible,pre))) + +def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: + t=event.get('type'); out=[] + phase={'diagnosis_attempted','diagnosis_generated','planning_started','repair_attempt_started'} + if t in phase: out.append({'type':'phase','id':run_id,'phase':t}) + elif t=='model_request_started': out += [{'type':'phase','id':run_id,'phase':t},{'type':'bridge_diagnostic','id':run_id,'message':'model request started'}] + elif t in {'model_request_completed','model_request_failed'}: out.append({'type':'bridge_diagnostic','id':run_id,'message':t}) + elif t=='tool_started': out += [{'type':'tool_started','id':run_id,'tool':event.get('name')},{'type':'bridge_diagnostic','id':run_id,'message':'tool started'}] + elif t=='tool_finished': + out.append({'type':'tool_finished','id':run_id,'tool':event.get('name'),'is_error':event.get('is_error')}) + if event.get('name') in {'Write','Patch','Edit'}: out.append({'type':'workspace_changed','id':run_id}) + elif t=='validation_step_started': out.append({'type':'verification_started','id':run_id,'name':event.get('name')}) + elif t in {'validation_step_finished','validation_completed'}: out.append({'type':'verification_finished','id':run_id,'passed':event.get('passed')}) + elif t in {'command_wandering_detected','progress_governor_redirected','governor_redirect'}: out.append({'type':'governor_redirect','id':run_id,'reason':event.get('reason')}) + return out + +def extract_summary(r:dict[str,Any])->str|None: + for k in ('summary','response'): + v=r.get(k) + if isinstance(v,str): return v[:4000] + return None + +def run_existing_runner(runner:Any, command:RunCommand)->dict[str,Any]: + budget=None + if command.limits.max_turns is not None: + budget=ExecutionBudget(max_turns=command.limits.max_turns,max_tool_calls=VILLANI_TASK_BUDGET.max_tool_calls,max_seconds=VILLANI_TASK_BUDGET.max_seconds,max_no_edit_turns=VILLANI_TASK_BUDGET.max_no_edit_turns,max_reconsecutive_recon_turns=VILLANI_TASK_BUDGET.max_reconsecutive_recon_turns) + result=runner.run_villani_mode() if command.mode=='villani' else (runner.run(command.task,execution_budget=budget) if budget is not None else runner.run(command.task)) + return result if isinstance(result,dict) else {'response':result} + +def build_default_runner(command:RunCommand,event_callback,approval_callback): + from villani_code.runner_factory import build_runner + provider=command.config.provider or os.environ.get('VILLANI_PROVIDER') or 'anthropic'; model=command.config.model or os.environ.get('VILLANI_MODEL'); base_url=command.config.base_url or os.environ.get('VILLANI_BASE_URL') + if provider not in {'anthropic','openai'}: raise ValueError("provider must be 'anthropic' or 'openai'") + if not model or not base_url: raise ValueError('run config requires model and base_url, or VILLANI_MODEL and VILLANI_BASE_URL') + runner=build_runner(base_url=base_url,model=model,repo=Path(command.repo),provider=provider,api_key=command.config.api_key or os.environ.get('VILLANI_API_KEY'),villani_mode=command.mode=='villani',villani_objective=command.task if command.mode=='villani' else None,event_callback=event_callback,approval_callback=approval_callback,external_approval_mode=True) + runner.print_stream=False; return runner +@dataclass(slots=True) +class PendingApproval: + run_id:str; request_id:str; tool:str; ready:threading.Event=field(default_factory=threading.Event); approved:bool|None=None +@dataclass(slots=True) +class ActiveRun: + command:RunCommand; abort_requested:threading.Event=field(default_factory=threading.Event); thread:threading.Thread|None=None; pending_approvals:dict[str,PendingApproval]=field(default_factory=dict); touched_files:set[str]=field(default_factory=set) +class PiBridge: + def __init__(self,*,stdin:TextIO|None=None,stdout:TextIO|None=None,stderr:TextIO|None=None,runner_factory:Callable[...,Any]|None=None)->None: + self.stdin=stdin or sys.stdin; self.stdout=stdout or sys.stdout; self.stderr=stderr or sys.stderr; self.runner_factory=runner_factory or build_default_runner; self.runs={}; self.lock=threading.Lock() + def emit(self,e): + self.stdout.write(to_json_line(e)); self.stdout.flush() + def run_forever(self): + self.emit(ready_event()) + for line in self.stdin: + if not line.strip(): continue + try: self.handle(parse_json_line(line)) + except Exception as exc: self.emit({'type':'error','error':str(exc)}) + def handle(self,p): + t=p.get('type') + if t=='ping': self.emit({'type':'pong','id':p.get('id')}); return + if t=='run': self.start_run(parse_run_command(p)); return + if t=='abort': self.abort(str(p.get('id',''))); return + if t=='approval_response': self.approval(parse_approval_response_command(p)); return + self.emit({'type':'error','error':f'Unknown bridge command type: {t}'}) + def start_run(self,cmd): + with self.lock: + if cmd.id in self.runs: self.emit({'type':'error','id':cmd.id,'error':'Duplicate active run id'}); return + ar=ActiveRun(cmd); self.runs[cmd.id]=ar + th=threading.Thread(target=self._worker,args=(ar,),daemon=True); ar.thread=th; th.start() + def abort(self,run_id): + ar=self.runs.get(run_id) + if not ar: self.emit({'type':'error','id':run_id,'error':'Unknown run id'}); return + ar.abort_requested.set(); + for req,p in list(ar.pending_approvals.items()): + ar.pending_approvals.pop(req,None); p.approved=False; p.ready.set() + self.emit({'type':'abort_requested','id':run_id}) + def approval(self,cmd): + ar=self.runs.get(cmd.id) + if not ar: self.emit({'type':'error','id':cmd.id,'error':'Unknown run id'}); return + p=ar.pending_approvals.pop(cmd.request_id,None) + if not p: self.emit({'type':'error','id':cmd.id,'error':'Unknown approval request id'}); return + p.approved=cmd.approved; p.ready.set() + def _worker(self,ar): + cmd=ar.command; repo=Path(cmd.repo); before=git_changed_files(repo); before_hash=hash_files(repo,before) + self.emit({'type':'run_started','id':cmd.id}) + def ev(e): + if e.get('type')=='approval_required': return + for m in map_runner_event(cmd.id,e): self.emit(m) + def appr(tool,inp): + if ar.abort_requested.is_set(): return False + title,summary=summarize_approval_request(tool,inp); req=f'{cmd.id}:{len(ar.pending_approvals)+1}' + p=PendingApproval(cmd.id,req,tool); ar.pending_approvals[req]=p; self.emit({'type':'approval_required','id':cmd.id,'request_id':req,'tool':tool,'title':title,'summary':summary}) + p.ready.wait(); self.emit({'type':'approval_resolved','id':cmd.id,'request_id':req,'approved':bool(p.approved)}); return bool(p.approved) and not ar.abort_requested.is_set() + try: + if ar.abort_requested.is_set(): self.emit({'type':'run_aborted','id':cmd.id}); return + runner=self.runner_factory(cmd,ev,appr); result=run_existing_runner(runner,cmd) + changed,pre=attributed_changed_files(repo,before,before_hash,ar.touched_files) + if ar.abort_requested.is_set(): self.emit({'type':'run_aborted','id':cmd.id}); return + ex=result.get('execution') if isinstance(result.get('execution'),dict) else {}; reason=ex.get('terminated_reason') or ex.get('reason') + base={'id':cmd.id,'changed_files':changed,'preexisting_dirty_files':pre,'summary':extract_summary(result),'transcript_path':result.get('transcript_path'),'verification_passed':result.get('verification_passed'),'terminated_reason':reason} + if ex.get('completed') is False: self.emit({'type':'run_failed','success':False,'error':str(reason or 'Runner stopped before completion.'),**base}) + else: self.emit({'type':'run_completed','success':True,**base}) + except Exception as exc: + print(traceback.format_exc(),file=self.stderr); self.emit({'type':'run_failed','id':cmd.id,'success':False,'error':str(exc),'summary':str(exc)}) + finally: + for req,p in list(ar.pending_approvals.items()): ar.pending_approvals.pop(req,None); p.approved=False; p.ready.set() + self.runs.pop(cmd.id,None) +def main_stdio(): PiBridge().run_forever() +if __name__=='__main__': main_stdio() diff --git a/villani_code/integrations/pi_bridge_protocol.py b/villani_code/integrations/pi_bridge_protocol.py new file mode 100644 index 00000000..2846e518 --- /dev/null +++ b/villani_code/integrations/pi_bridge_protocol.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any, Literal +import json +PROTOCOL_VERSION=1 +BridgeMode=Literal['runner','villani'] +@dataclass(slots=True) +class BridgeConfig: + provider:str|None=None; model:str|None=None; base_url:str|None=None; api_key:str|None=None; pi_model_proxy:bool=False +@dataclass(slots=True) +class BridgeLimits: max_turns:int|None=None +@dataclass(slots=True) +class RunCommand: + id:str; task:str; repo:str; mode:BridgeMode='runner'; config:BridgeConfig=field(default_factory=BridgeConfig); limits:BridgeLimits=field(default_factory=BridgeLimits) +@dataclass(slots=True) +class ApprovalResponseCommand: + id:str; request_id:str; approved:bool + +def to_json_line(event:dict[str,Any])->str: return json.dumps(event,separators=(',',':'))+'\n' +def parse_json_line(line:str)->dict[str,Any]: + v=json.loads(line) + if not isinstance(v,dict): raise ValueError('JSON command must be an object') + return v +def _nonempty(p,k): + v=p.get(k) + if not isinstance(v,str) or not v.strip(): raise ValueError(f'{k} is required') + return v +def parse_run_command(payload:dict[str,Any])->RunCommand: + rid=_nonempty(payload,'id'); task=_nonempty(payload,'task'); repo=_nonempty(payload,'repo') + mode=payload.get('mode','runner') + if mode not in {'runner','villani'}: raise ValueError("mode must be 'runner' or 'villani'") + c=payload.get('config') or {}; l=payload.get('limits') or {} + if not isinstance(c,dict) or not isinstance(l,dict): raise ValueError('config and limits must be objects') + max_turns=l.get('max_turns') + if max_turns is not None and not isinstance(max_turns,int): raise ValueError('max_turns must be an integer') + return RunCommand(rid,task,repo,mode,BridgeConfig(provider=c.get('provider'),model=c.get('model'),base_url=c.get('base_url'),api_key=c.get('api_key'),pi_model_proxy=bool(c.get('pi_model_proxy',False))),BridgeLimits(max_turns=max_turns)) +def parse_approval_response_command(payload:dict[str,Any])->ApprovalResponseCommand: + rid=_nonempty(payload,'id'); req=_nonempty(payload,'request_id') + if not isinstance(payload.get('approved'),bool): raise ValueError('approved must be boolean') + return ApprovalResponseCommand(rid,req,payload['approved']) +def ready_event()->dict[str,Any]: return {'type':'ready','protocol_version':PROTOCOL_VERSION} diff --git a/villani_code/runner_factory.py b/villani_code/runner_factory.py new file mode 100644 index 00000000..259289d4 --- /dev/null +++ b/villani_code/runner_factory.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json, os +from pathlib import Path +from typing import Any, Callable, Literal +import typer +from villani_code.anthropic_client import AnthropicClient +from villani_code.openai_client import OpenAIClient +from villani_code.runtime_safety import ensure_runtime_dependencies_not_shadowed +from villani_code.state import Runner +from villani_code.benchmark.runtime_config import BenchmarkRuntimeConfig +from villani_code.debug_mode import DebugConfig, DebugMode, build_debug_config + + +def build_runner(*, base_url: str, model: str, repo: Path, max_tokens: int = 4096, stream: bool = True, thinking: Any = None, unsafe: bool = False, verbose: bool = False, extra_json: str | None = None, redact: bool = False, dangerously_skip_permissions: bool = False, auto_accept_edits: bool = False, auto_approve: bool = False, plan_mode: Literal['off','auto','strict'] = 'auto', max_repair_attempts: int = 2, small_model: bool = False, provider: Literal['anthropic','openai'] = 'anthropic', api_key: str | None = None, villani_mode: bool = False, villani_objective: str | None = None, event_callback: Callable[[dict[str, Any]], None] | None = None, approval_callback: Callable[[str, dict[str, Any]], bool] | None = None, external_approval_mode: bool = False, benchmark_runtime_json: str | None = None, debug_mode: DebugMode = DebugMode.OFF, debug_dir: Path | None = None, memory_enabled: bool = False, memory_update_interval_tool_calls: int = 5) -> Runner: + resolved_repo = repo.resolve() + try: + ensure_runtime_dependencies_not_shadowed(resolved_repo) + except RuntimeError as exc: + raise typer.BadParameter(str(exc)) from exc + if provider == 'openai': + client = OpenAIClient(base_url=base_url, api_key=api_key or os.environ.get('OPENAI_API_KEY')) + else: + _ = api_key or os.environ.get('ANTHROPIC_API_KEY') + client = AnthropicClient(base_url=base_url) + thinking_obj = None + if thinking: + if isinstance(thinking, str): + try: thinking_obj = json.loads(thinking) + except json.JSONDecodeError: thinking_obj = thinking + else: thinking_obj = thinking + benchmark_config = BenchmarkRuntimeConfig.model_validate_json(benchmark_runtime_json) if benchmark_runtime_json else None + debug_config = build_debug_config(debug_mode.value if isinstance(debug_mode, DebugMode) else str(debug_mode), debug_dir=debug_dir) if not isinstance(debug_mode, DebugConfig) else debug_mode + return Runner(client=client, repo=resolved_repo, model=model, max_tokens=max_tokens, stream=stream, thinking=thinking_obj, unsafe=unsafe, verbose=verbose, extra_json=extra_json, redact=redact, bypass_permissions=dangerously_skip_permissions, auto_accept_edits=auto_accept_edits, auto_approve=auto_approve, plan_mode=plan_mode, max_repair_attempts=max_repair_attempts, approval_callback=approval_callback, event_callback=event_callback, small_model=small_model, villani_mode=villani_mode, villani_objective=villani_objective, benchmark_config=benchmark_config, debug_config=debug_config, provider=provider, memory_enabled=memory_enabled, memory_update_interval_tool_calls=memory_update_interval_tool_calls, external_approval_mode=external_approval_mode) diff --git a/villani_code/state.py b/villani_code/state.py index 1b4dcbf3..6f52b912 100644 --- a/villani_code/state.py +++ b/villani_code/state.py @@ -491,6 +491,7 @@ def __init__( provider: str | None = None, memory_enabled: bool = False, memory_update_interval_tool_calls: int = 5, + external_approval_mode: bool = False, ): self.client = client self.repo = repo @@ -507,6 +508,7 @@ def __init__( self.bypass_permissions = bypass_permissions self.auto_accept_edits = auto_accept_edits self.auto_approve = auto_approve + self.external_approval_mode = external_approval_mode self.plan_mode = plan_mode self.max_repair_attempts = max_repair_attempts self.approval_callback = approval_callback or (lambda _n, _i: True) diff --git a/villani_code/state_runtime.py b/villani_code/state_runtime.py index e4d9cc6e..be5af5bd 100644 --- a/villani_code/state_runtime.py +++ b/villani_code/state_runtime.py @@ -1306,7 +1306,7 @@ def ensure_project_memory_and_plan(runner: Any, instruction: str) -> None: update_session_state(runner.repo, session) return - if runner.villani_mode: + if runner.villani_mode or getattr(runner, "external_approval_mode", False): runner.event_callback({"type": "plan_auto_approved", "risk": plan.risk_level.value}) update_session_state(runner.repo, session) return diff --git a/villani_code/state_tooling.py b/villani_code/state_tooling.py index a908bb8c..20d790a9 100644 --- a/villani_code/state_tooling.py +++ b/villani_code/state_tooling.py @@ -518,7 +518,13 @@ def execute_tool_with_policy( if policy.decision == Decision.DENY: return {"content": "Denied by permission policy", "is_error": True} if policy.decision == Decision.ASK: - if getattr(runner, "auto_approve", False): + if getattr(runner, "external_approval_mode", False): + runner.event_callback({"type": "approval_required", "name": tool_name, "input": tool_input}) + approved = runner.approval_callback(tool_name, tool_input) + runner.event_callback({"type": "approval_resolved", "name": tool_name, "input": tool_input, "approved": approved}) + if not approved: + return {"content": "User denied tool execution", "is_error": True} + elif getattr(runner, "auto_approve", False): runner.event_callback( { "type": "approval_auto_resolved", From 803fefaec970aaacb1d24cf845de2acb77c31dc0 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 13:43:40 +1000 Subject: [PATCH 02/36] Fix Pi integration scaffold --- .../build-pi-villani-runtime-release.yml | 20 ++++- integrations/pi-villani/src/abort.test.ts | 3 + integrations/pi-villani/src/approval.test.ts | 2 + integrations/pi-villani/src/extension.test.ts | 2 + integrations/pi-villani/src/index.ts | 11 +-- integrations/pi-villani/src/modelProxy.ts | 6 +- integrations/pi-villani/src/process.test.ts | 2 + integrations/pi-villani/src/process.ts | 5 +- integrations/pi-villani/src/proxy.test.ts | 3 + integrations/pi-villani/src/runtime.test.ts | 4 + integrations/pi-villani/src/runtime.ts | 13 +++- packaging/villani_runtime.spec | 40 ++++++++++ tests/integrations/test_pi_bridge.py | 76 +++++++++++++++++++ villani_code/integrations/pi_bridge.py | 6 +- 14 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 integrations/pi-villani/src/abort.test.ts create mode 100644 integrations/pi-villani/src/approval.test.ts create mode 100644 integrations/pi-villani/src/extension.test.ts create mode 100644 integrations/pi-villani/src/process.test.ts create mode 100644 integrations/pi-villani/src/proxy.test.ts create mode 100644 integrations/pi-villani/src/runtime.test.ts create mode 100644 packaging/villani_runtime.spec create mode 100644 tests/integrations/test_pi_bridge.py diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml index 5ad41e74..b530b608 100644 --- a/.github/workflows/build-pi-villani-runtime-release.yml +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -35,14 +35,30 @@ jobs: if [[ "${{ matrix.ext }}" == "zip" ]]; then 7z a ../$name villani-code; else tar -czf ../$name villani-code; fi cd .. shasum -a 256 "$name" > "${name}.sha256" + mkdir -p smoke-asset + python - "$name" smoke-asset <<'PY' + import sys, tarfile, zipfile + from pathlib import Path + archive, dest = Path(sys.argv[1]), Path(sys.argv[2]) + if archive.suffix == '.zip': + with zipfile.ZipFile(archive) as zf: + zf.extractall(dest) + else: + with tarfile.open(archive, 'r:gz') as tf: + tf.extractall(dest) + PY + python packaging/smoke_test_runtime.py smoke-asset/villani-code/villani-code${{ runner.os == 'Windows' && '.exe' || '' }} - uses: actions/upload-artifact@v4 - with: {name: runtime-${{ matrix.key }}, path: "villani-runtime-v0.1.0-${{ matrix.key }}.${{ matrix.ext }}*"} + with: + name: runtime-${{ matrix.key }} + path: "villani-runtime-v0.1.0-${{ matrix.key }}.${{ matrix.ext }}*" release: needs: build runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 - with: {path: artifacts} + with: + path: artifacts - run: find artifacts -name '*.sha256' -exec cat {} \; > checksums.txt - uses: softprops/action-gh-release@v2 with: diff --git a/integrations/pi-villani/src/abort.test.ts b/integrations/pi-villani/src/abort.test.ts new file mode 100644 index 00000000..acde1484 --- /dev/null +++ b/integrations/pi-villani/src/abort.test.ts @@ -0,0 +1,3 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import { VillaniBridgeProcess } from './process.js'; +test('bridge waitForEvent resolves matching event',async()=>{const b=Object.assign(new EventEmitter(),{off:EventEmitter.prototype.off}) as any as VillaniBridgeProcess; Object.setPrototypeOf(b,VillaniBridgeProcess.prototype); const p=b.waitForEvent('run_aborted',50); b.emit('event',{type:'run_aborted'}); assert.equal((await p)?.type,'run_aborted');}); diff --git a/integrations/pi-villani/src/approval.test.ts b/integrations/pi-villani/src/approval.test.ts new file mode 100644 index 00000000..68d838d7 --- /dev/null +++ b/integrations/pi-villani/src/approval.test.ts @@ -0,0 +1,2 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; +test('approval ids are expected to be run-prefixed monotonic values',()=>{assert.match('run-1:42',/^run-1:\d+$/);}); diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts new file mode 100644 index 00000000..b52c2d98 --- /dev/null +++ b/integrations/pi-villani/src/extension.test.ts @@ -0,0 +1,2 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import activate from './index.js'; +test('registers actual Pi command API descriptors',()=>{const names:string[]=[]; const api:any={registerCommand:(name:string,opts:any)=>{names.push(name); assert.equal(typeof opts.handler,'function');}}; activate(api); assert.deepEqual(names,['villani','villani-abort','villani-confirm-test']);}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 79efb53d..24e0e4ac 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -1,6 +1,7 @@ -import { randomUUID } from 'node:crypto'; import { resolveVillaniRuntime } from './runtime.js'; import { VillaniBridgeProcess } from './process.js'; import { startModelProxy } from './modelProxy.js'; import { finalMessage, renderBridgeEvent } from './render.js'; -type VillaniMode='runner'|'villani'; type RunCommand={type:'run';id:string;task:string;repo:string;mode:VillaniMode;config:any}; let active:{bridge?:VillaniBridgeProcess, abort:AbortController}|null=null; +import { randomUUID } from 'node:crypto'; import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'; import { resolveVillaniRuntime } from './runtime.js'; import { VillaniBridgeProcess } from './process.js'; import { resolvePiModel, startModelProxy } from './modelProxy.js'; import { finalMessage, renderBridgeEvent } from './render.js'; +type VillaniMode='runner'|'villani'; type RunCommand={type:'run';id:string;task:string;repo:string;mode:VillaniMode;config:any}; let active:{bridge?:VillaniBridgeProcess, abort:AbortController, runId?:string}|null=null; function buildRunConfig(proxyUrl?:string, model?:any){ if(proxyUrl) return {provider:'openai',model:model?.id??'pi-current-model',base_url:proxyUrl,pi_model_proxy:true}; return {provider:process.env.VILLANI_PROVIDER,model:process.env.VILLANI_MODEL,base_url:process.env.VILLANI_BASE_URL,api_key:process.env.VILLANI_API_KEY};} -export async function runVillani(task:string, ctx:any={}){ if(!task?.trim()) throw new Error('/villani requires a task argument'); if(active) throw new Error('A Villani run is already active'); const abort=new AbortController(); active={abort}; let proxy:any; try{const repo=ctx.cwd||process.cwd(); const useProxy=process.env.VILLANI_USE_PI_MODEL!=='false'; let proxyUrl:string|undefined; let model=ctx.model; if(useProxy){proxy=await startModelProxy(ctx.pi||{complete:async()=>''},model,abort.signal); proxyUrl=proxy.url;} const exe=await resolveVillaniRuntime({signal:abort.signal}); const bridge=new VillaniBridgeProcess(exe,{proxyMode:useProxy,explicitConfigMode:!useProxy}); active.bridge=bridge; await bridge.waitUntilReady(); const id=randomUUID(); const command:RunCommand={type:'run',id,task,repo,mode:(process.env.VILLANI_MODE as VillaniMode|undefined)||'runner',config:buildRunConfig(proxyUrl,model)}; bridge.send(command); return await new Promise((resolve,reject)=>{bridge.on('event',async(e:any)=>{if(e.type==='approval_required'){let ok=false; try{ok=ctx.confirm?await ctx.confirm(e.title||'Approve Villani action?'):false;}catch{} bridge.respondToApproval(id,e.request_id,ok);} else if(['run_completed','run_failed','run_aborted'].includes(e.type)){resolve(finalMessage(e));} else renderBridgeEvent(e,{log:ctx.log});}); bridge.on('error',reject);});} finally{active?.bridge?.kill(); proxy?.close?.(); active=null;}} -export function abortVillani(){ if(!active) return false; active.abort.abort(); active.bridge?.kill(); active=null; return true; } -export default function activate(ctx:any){ctx?.registerCommand?.('/villani',runVillani); ctx?.registerCommand?.('/villani-abort',abortVillani); ctx?.registerCommand?.('/villani-confirm-test',()=>true);} +async function confirmApproval(ctx:any,e:any){const title=e.title||'Approve Villani action?'; const msg=typeof e.summary==='string'?e.summary:JSON.stringify(e.summary??{}); if(ctx.ui?.confirm) return await ctx.ui.confirm(title,msg); throw new Error('Pi UI confirmation API is unavailable for approval request.');} +export async function runVillani(task:string, ctx:any={}){ if(!task?.trim()) throw new Error('/villani requires a task argument'); if(active) throw new Error('A Villani run is already active'); const abort=new AbortController(); active={abort}; let proxy:any; try{const repo=ctx.cwd||process.cwd(); const useProxy=process.env.VILLANI_USE_PI_MODEL!=='false'; let proxyUrl:string|undefined; let model=ctx.model; if(useProxy){model=resolvePiModel(ctx); proxy=await startModelProxy(model,abort.signal); proxyUrl=proxy.url;} const exe=await resolveVillaniRuntime({signal:abort.signal}); const bridge=new VillaniBridgeProcess(exe,{proxyMode:useProxy,explicitConfigMode:!useProxy}); active.bridge=bridge; await bridge.waitUntilReady(); const id=randomUUID(); active.runId=id; const command:RunCommand={type:'run',id,task,repo,mode:(process.env.VILLANI_MODE as VillaniMode|undefined)||'runner',config:buildRunConfig(proxyUrl,model)}; bridge.send(command); return await new Promise((resolve,reject)=>{bridge.on('event',async(e:any)=>{if(e.type==='approval_required'){try{bridge.respondToApproval(id,e.request_id,await confirmApproval(ctx,e));}catch{bridge.respondToApproval(id,e.request_id,false);}} else if(['run_completed','run_failed','run_aborted'].includes(e.type)){resolve(finalMessage(e));} else renderBridgeEvent(e,{log:(s:string)=>ctx.ui?.notify?.(s,'info')});}); bridge.on('error',reject);});} finally{active?.bridge?.kill(); proxy?.close?.(); active=null;}} +export async function abortVillani(){ if(!active) return false; const current=active; current.abort.abort(); if(current.bridge&¤t.runId){current.bridge.abort(current.runId); const aborted=await current.bridge.waitForEvent('run_aborted',1000); if(aborted){current.bridge.kill(); active=null; return true;}} current.bridge?.kill(); active=null; return true; } +export default function activate(api:ExtensionAPI){api.registerCommand('villani',{description:'Run Villani Code on a task',handler:async(args:string,ctx:ExtensionCommandContext)=>{const msg=await runVillani(args,ctx); ctx.ui.notify(msg,'info');}}); api.registerCommand('villani-abort',{description:'Abort the active Villani run',handler:async(_args:string,ctx:ExtensionCommandContext)=>{ctx.ui.notify((await abortVillani())?'Villani abort requested':'No active Villani run','info');}}); api.registerCommand('villani-confirm-test',{description:'Test Villani approval UI',handler:async(_args:string,ctx:ExtensionCommandContext)=>{await ctx.ui.confirm('Villani confirmation test','Confirm?');}});} diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index c0d1af55..450e8ee2 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -1,4 +1,8 @@ import http from 'node:http'; +import type { Context, Model, SimpleStreamOptions } from '@earendil-works/pi-ai'; export function zeroUsage(){return {prompt_tokens:0,completion_tokens:0,total_tokens:0};} function sanitize(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]');} -export async function startModelProxy(pi:{complete:(model:any,context:any,options:any)=>Promise}, model:any, signal?:AbortSignal){const server=http.createServer(async(req,res)=>{if(req.method!=='POST'||req.url!=='/v1/chat/completions'){res.writeHead(404).end();return;} let body=''; req.on('data',d=>body+=d); req.on('end',async()=>{try{const j=JSON.parse(body||'{}'); const out=await pi.complete(model,{messages:j.messages||[],tools:j.tools},{signal}); const text=typeof out==='string'?out:(out.text||out.content||''); const msg:any={role:'assistant',content:text}; if(out.tool_calls) msg.tool_calls=out.tool_calls; const chunk={id:'pi-villani',object:'chat.completion',created:Math.floor(Date.now()/1000),model:j.model||'pi-current-model',choices:[{index:0,message:msg,finish_reason:'stop'}],usage:zeroUsage()}; if(j.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...chunk,object:'chat.completion.chunk'})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify(chunk));}}catch(e){res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitize(e)}}));}});}); await new Promise(r=>server.listen(0,'127.0.0.1',r)); signal?.addEventListener('abort',()=>server.close()); const addr=server.address(); if(!addr||typeof addr==='string')throw new Error('Proxy bind failed'); return {url:`http://127.0.0.1:${addr.port}`,close:()=>server.close()};} +export type PiModel = Model & { api?: { streamSimple?: (model:Model, context:Context, options?:SimpleStreamOptions)=>AsyncIterable } }; +export function resolvePiModel(ctx:{model?:PiModel}):PiModel{if(!ctx.model) throw new Error('Villani requires an active Pi model; select a Pi model before running /villani.'); if(typeof ctx.model.api?.streamSimple!=='function') throw new Error('Active Pi model does not expose streamSimple; cannot proxy Pi model context to Villani.'); return ctx.model;} +async function collectText(model:PiModel, context:Context, signal?:AbortSignal):Promise{let text=''; for await (const event of model.api!.streamSimple!(model,context,{signal})){const any=event as any; text+=any.textDelta??any.delta??any.text??any.content??'';} return text;} +export async function startModelProxy(model:PiModel, signal?:AbortSignal){const server=http.createServer(async(req,res)=>{if(req.method!=='POST'||req.url!=='/v1/chat/completions'){res.writeHead(404).end();return;} let body=''; req.on('data',d=>body+=d); req.on('end',async()=>{try{const j=JSON.parse(body||'{}'); const context={messages:j.messages||[],tools:j.tools} as unknown as Context; const text=await collectText(model,context,signal); const msg:any={role:'assistant',content:text}; const chunk={id:'pi-villani',object:'chat.completion',created:Math.floor(Date.now()/1000),model:j.model||model.id||'pi-current-model',choices:[{index:0,message:msg,finish_reason:'stop'}],usage:zeroUsage()}; if(j.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...chunk,object:'chat.completion.chunk'})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify(chunk));}}catch(e){res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitize(e)}}));}});}); await new Promise(r=>server.listen(0,'127.0.0.1',r)); signal?.addEventListener('abort',()=>server.close()); const addr=server.address(); if(!addr||typeof addr==='string')throw new Error('Proxy bind failed'); return {url:`http://127.0.0.1:${addr.port}`,close:()=>server.close()};} diff --git a/integrations/pi-villani/src/process.test.ts b/integrations/pi-villani/src/process.test.ts new file mode 100644 index 00000000..40f7ca80 --- /dev/null +++ b/integrations/pi-villani/src/process.test.ts @@ -0,0 +1,2 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { sanitizedEnv } from './process.js'; +test('sanitizes secrets only in proxy mode',()=>{const e=sanitizedEnv({proxyMode:true,env:{OPENAI_API_KEY:'x',CUSTOM_TOKEN:'y',PATH:'p'}}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.CUSTOM_TOKEN,undefined); assert.equal(e.PATH,'p'); assert.equal(sanitizedEnv({proxyMode:false,env:{OPENAI_API_KEY:'x'}}).OPENAI_API_KEY,'x');}); diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts index 3a8c5bac..dbb5f339 100644 --- a/integrations/pi-villani/src/process.ts +++ b/integrations/pi-villani/src/process.ts @@ -2,8 +2,9 @@ import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; impo export interface BridgeEvent{type:string;[k:string]:unknown} export interface LaunchOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; timeoutMs?:number} export function sanitizedEnv(opts:LaunchOptions={}):NodeJS.ProcessEnv{const e={...(opts.env||process.env)}; if(opts.proxyMode&&!opts.explicitConfigMode){for(const k of Object.keys(e)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|AUTH|TOKEN|BEARER)$/i.test(k)) delete e[k];}} return e;} export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; - constructor(executable:string, opts:LaunchOptions={}){super(); this.proc=spawn(executable,['bridge','--stdio'],{shell:false,env:sanitizedEnv(opts)}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString()));} - onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; this.emit('event',e);}catch(err){this.kill(); this.emit('error',new Error('Malformed JSONL from bridge'));}}} + constructor(executable:string, opts:LaunchOptions={}){super(); this.proc=spawn(executable,['bridge','--stdio'],{shell:false,env:sanitizedEnv(opts)}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e));} + onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; this.emit('event',e);}catch{this.kill(); this.emit('error',new Error('Malformed JSONL from bridge'));}}} send(c:unknown){this.proc.stdin.write(JSON.stringify(c)+'\n');} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} + waitForEvent(type:string,timeoutMs=1000){return new Promise(r=>{const t=setTimeout(()=>{this.off('event',on);r(undefined);},timeoutMs); const on=(e:BridgeEvent)=>{if(e.type===type){clearTimeout(t);this.off('event',on);r(e);}}; this.on('event',on);});} waitUntilReady(timeoutMs=10000){return new Promise((res,rej)=>{if(this.ready)return res(); const t=setTimeout(()=>rej(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); this.on('event',(e:BridgeEvent)=>{if(e.type==='ready'){clearTimeout(t);res();}}); this.proc.once('exit',()=>{clearTimeout(t);rej(new Error(`Bridge exited before ready. stderr: ${this.stderr}`));});});} waitForExit(){return new Promise(r=>this.proc.once('exit',r));}} diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts new file mode 100644 index 00000000..cc1d41ed --- /dev/null +++ b/integrations/pi-villani/src/proxy.test.ts @@ -0,0 +1,3 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { resolvePiModel, startModelProxy, zeroUsage } from './modelProxy.js'; +test('model resolution fails clearly without Pi model API',()=>{assert.throws(()=>resolvePiModel({}),/active Pi model/); assert.throws(()=>resolvePiModel({model:{id:'m'} as any}),/streamSimple/);}); +test('proxy serves model text from Pi streamSimple',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){yield {textDelta:'hi'};}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'hi'); assert.deepEqual(j.usage,zeroUsage());}finally{proxy.close();}}); diff --git a/integrations/pi-villani/src/runtime.test.ts b/integrations/pi-villani/src/runtime.test.ts new file mode 100644 index 00000000..40010a9a --- /dev/null +++ b/integrations/pi-villani/src/runtime.test.ts @@ -0,0 +1,4 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import AdmZip from 'adm-zip'; import * as tar from 'tar'; +import { assetName, executableRelativePath, platformKey } from './runtimeConfig.js'; import { extractRuntimeArchive, parseChecksums } from './runtime.js'; +test('runtime asset helpers and checksums',()=>{assert.equal(assetName(platformKey('linux','x64')),'villani-runtime-v0.1.0-linux-x64.tar.gz'); assert.equal(executableRelativePath('win32-x64'),'villani-code/villani-code.exe'); assert.equal(parseChecksums('abc file.tgz').get('file.tgz'),'abc');}); +test('extracts zip and tar archives without shelling out',async()=>{const dir=mkdtempSync(join(tmpdir(),'villani-rt-')); const zipPath=join(dir,'a.zip'); const zip=new AdmZip(); zip.addFile('villani-code/villani-code',Buffer.from('x')); zip.writeZip(zipPath); const zipOut=join(dir,'zipout'); await extractRuntimeArchive(zipPath,zipOut); assert.ok(existsSync(join(zipOut,'villani-code','villani-code'))); mkdirSync(join(dir,'villani-code'),{recursive:true}); writeFileSync(join(dir,'villani-code','villani-code'),'x'); await tar.c({gzip:true,file:join(dir,'a.tar.gz'),cwd:dir},['villani-code']); const tarOut=join(dir,'tarout'); await extractRuntimeArchive(join(dir,'a.tar.gz'),tarOut); assert.ok(existsSync(join(tarOut,'villani-code','villani-code')));}); diff --git a/integrations/pi-villani/src/runtime.ts b/integrations/pi-villani/src/runtime.ts index 33b916d3..3418ef0f 100644 --- a/integrations/pi-villani/src/runtime.ts +++ b/integrations/pi-villani/src/runtime.ts @@ -1,6 +1,13 @@ -import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { assetName, executableRelativePath, platformKey, VILLANI_RUNTIME_REPOSITORY, VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import AdmZip from 'adm-zip'; +import * as tar from 'tar'; +import { assetName, executableRelativePath, platformKey, VILLANI_RUNTIME_REPOSITORY, VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; export function parseChecksums(text:string): Map{const m=new Map(); for(const l of text.split(/\r?\n/)){const p=l.trim().split(/\s+/); if(p.length>=2)m.set(p[p.length-1],p[0]);} return m;} export function cacheRoot(){return process.platform==='win32'?join(process.env.LOCALAPPDATA||homedir(),'pi-villani','runtime'):join(homedir(),'.cache','pi-villani','runtime');} async function download(url:string, signal?:AbortSignal):Promise{const r=await fetch(url,{signal}); if(!r.ok) throw new Error(`Download failed ${r.status}: ${url}`); return Buffer.from(await r.arrayBuffer());} -export async function resolveVillaniRuntime(opts:{signal?:AbortSignal, platform?:string, arch?:string}={}):Promise{ if(process.env.VILLANI_COMMAND) return process.env.VILLANI_COMMAND; const key=platformKey(opts.platform as any, opts.arch as any); const asset=assetName(key); const root=cacheRoot(); const exe=join(root,VILLANI_RUNTIME_VERSION,executableRelativePath(key)); const marker=join(root,VILLANI_RUNTIME_VERSION,'.verified.json'); const base=`https://github.com/${VILLANI_RUNTIME_REPOSITORY}/releases/download/${VILLANI_RUNTIME_TAG}`; const checks=parseChecksums((await download(`${base}/checksums.txt`,opts.signal)).toString('utf8')); const sum=checks.get(asset); if(!sum) throw new Error(`Missing checksum for ${asset}`); if(existsSync(exe)&&existsSync(marker)){try{const j=JSON.parse(readFileSync(marker,'utf8')); if(j.version===VILLANI_RUNTIME_VERSION&&j.asset===asset&&j.sha256===sum)return exe;}catch{}} - const buf=await download(`${base}/${asset}`,opts.signal); const got=createHash('sha256').update(buf).digest('hex'); if(got!==sum) throw new Error(`Checksum mismatch for ${asset}`); mkdirSync(join(root,VILLANI_RUNTIME_VERSION),{recursive:true}); const archive=join(root,VILLANI_RUNTIME_VERSION,asset); writeFileSync(archive,buf); const child_process=await import('node:child_process'); const cmd=asset.endsWith('.zip')?`python -m zipfile -e ${JSON.stringify(archive)} ${JSON.stringify(join(root,VILLANI_RUNTIME_VERSION))}`:`tar -xzf ${JSON.stringify(archive)} -C ${JSON.stringify(join(root,VILLANI_RUNTIME_VERSION))}`; child_process.execSync(cmd); if(!existsSync(exe)) throw new Error(`Runtime executable missing after extraction: ${exe}`); writeFileSync(marker,JSON.stringify({version:VILLANI_RUNTIME_VERSION,asset,sha256:sum})); return exe; } +export async function extractRuntimeArchive(archive:string,dest:string):Promise{mkdirSync(dest,{recursive:true}); if(archive.endsWith('.zip')){new AdmZip(archive).extractAllTo(dest,true); return;} await tar.x({file:archive,cwd:dest,gzip:true});} +export async function resolveVillaniRuntime(opts:{signal?:AbortSignal, platform?:string, arch?:string}={}):Promise{ if(process.env.VILLANI_COMMAND) return process.env.VILLANI_COMMAND; const key=platformKey(opts.platform as any, opts.arch as any); const asset=assetName(key); const root=cacheRoot(); const dest=join(root,VILLANI_RUNTIME_VERSION); const exe=join(dest,executableRelativePath(key)); const marker=join(dest,'.verified.json'); const base=`https://github.com/${VILLANI_RUNTIME_REPOSITORY}/releases/download/${VILLANI_RUNTIME_TAG}`; const checks=parseChecksums((await download(`${base}/checksums.txt`,opts.signal)).toString('utf8')); const sum=checks.get(asset); if(!sum) throw new Error(`Missing checksum for ${asset}`); if(existsSync(exe)&&existsSync(marker)){try{const j=JSON.parse(readFileSync(marker,'utf8')); if(j.version===VILLANI_RUNTIME_VERSION&&j.asset===asset&&j.sha256===sum)return exe;}catch{}} + const buf=await download(`${base}/${asset}`,opts.signal); const got=createHash('sha256').update(buf).digest('hex'); if(got!==sum) throw new Error(`Checksum mismatch for ${asset}`); mkdirSync(dest,{recursive:true}); const archive=join(dest,asset); writeFileSync(archive,buf); await extractRuntimeArchive(archive,dest); if(!existsSync(exe)) throw new Error(`Runtime executable missing after extraction: ${exe}`); writeFileSync(marker,JSON.stringify({version:VILLANI_RUNTIME_VERSION,asset,sha256:sum})); return exe; } diff --git a/packaging/villani_runtime.spec b/packaging/villani_runtime.spec new file mode 100644 index 00000000..d2acc881 --- /dev/null +++ b/packaging/villani_runtime.spec @@ -0,0 +1,40 @@ +# PyInstaller spec for the Pi Villani runtime bridge executable. +block_cipher = None + +a = Analysis( + ['villani_code/cli.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=['villani_code.integrations.pi_bridge'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='villani-code', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py new file mode 100644 index 00000000..9b72ac95 --- /dev/null +++ b/tests/integrations/test_pi_bridge.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import io +import json +import tempfile +from pathlib import Path + +from villani_code.integrations.pi_bridge import PiBridge + + +class DummyRunner: + print_stream = False + + def __init__(self, approval_callback=None, event_callback=None): + self.approval_callback = approval_callback + self.event_callback = event_callback + + def run(self, task, execution_budget=None): + if task == "approve-twice": + assert self.approval_callback("Bash", {"command": "one"}) is True + assert self.approval_callback("Bash", {"command": "two"}) is True + return {"response": "ok", "execution": {"completed": True}} + + +def _bridge(): + out = io.StringIO() + + def factory(command, event_callback, approval_callback): + return DummyRunner(approval_callback=approval_callback, event_callback=event_callback) + + return PiBridge(stdin=io.StringIO(), stdout=out, runner_factory=factory), out + + +def _events(out: io.StringIO): + return [json.loads(line) for line in out.getvalue().splitlines() if line.strip()] + + +def test_ping_and_ready_event_shape(): + bridge, out = _bridge() + bridge.emit({"type": "ready", "protocol_version": 1}) + bridge.handle({"type": "ping", "id": "p1"}) + assert _events(out) == [{"type": "ready", "protocol_version": 1}, {"type": "pong", "id": "p1"}] + + +def test_approval_request_ids_are_monotonic_even_after_resolution(): + bridge, out = _bridge() + repo = tempfile.TemporaryDirectory() + bridge.handle({"type": "run", "id": "r1", "task": "approve-twice", "repo": repo.name, "config": {"provider": "openai", "model": "m", "base_url": "u"}}) # type: ignore[arg-type] + while True: + approvals = [e for e in _events(out) if e.get("type") == "approval_required"] + if approvals: + break + bridge.approval(type("Cmd", (), {"id": "r1", "request_id": "r1:1", "approved": True})()) + while True: + approvals = [e for e in _events(out) if e.get("type") == "approval_required"] + if len(approvals) == 2: + break + bridge.approval(type("Cmd", (), {"id": "r1", "request_id": "r1:2", "approved": True})()) + bridge.runs["r1"].thread.join(2) + approvals = [e["request_id"] for e in _events(out) if e.get("type") == "approval_required"] + assert approvals == ["r1:1", "r1:2"] + repo.cleanup() + + +def test_abort_resolves_pending_approval_and_emits_run_aborted(): + bridge, out = _bridge() + repo = tempfile.TemporaryDirectory() + bridge.handle({"type": "run", "id": "r2", "task": "approve-twice", "repo": repo.name, "config": {"provider": "openai", "model": "m", "base_url": "u"}}) # type: ignore[arg-type] + while not [e for e in _events(out) if e.get("type") == "approval_required"]: + pass + bridge.abort("r2") + bridge.runs["r2"].thread.join(2) + types = [e["type"] for e in _events(out)] + assert "abort_requested" in types + assert "run_aborted" in types + repo.cleanup() diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index b2553dd3..94d83f18 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -86,7 +86,7 @@ class PendingApproval: run_id:str; request_id:str; tool:str; ready:threading.Event=field(default_factory=threading.Event); approved:bool|None=None @dataclass(slots=True) class ActiveRun: - command:RunCommand; abort_requested:threading.Event=field(default_factory=threading.Event); thread:threading.Thread|None=None; pending_approvals:dict[str,PendingApproval]=field(default_factory=dict); touched_files:set[str]=field(default_factory=set) + command:RunCommand; abort_requested:threading.Event=field(default_factory=threading.Event); thread:threading.Thread|None=None; pending_approvals:dict[str,PendingApproval]=field(default_factory=dict); touched_files:set[str]=field(default_factory=set); approval_seq:int=0 class PiBridge: def __init__(self,*,stdin:TextIO|None=None,stdout:TextIO|None=None,stderr:TextIO|None=None,runner_factory:Callable[...,Any]|None=None)->None: self.stdin=stdin or sys.stdin; self.stdout=stdout or sys.stdout; self.stderr=stderr or sys.stderr; self.runner_factory=runner_factory or build_default_runner; self.runs={}; self.lock=threading.Lock() @@ -131,7 +131,7 @@ def ev(e): for m in map_runner_event(cmd.id,e): self.emit(m) def appr(tool,inp): if ar.abort_requested.is_set(): return False - title,summary=summarize_approval_request(tool,inp); req=f'{cmd.id}:{len(ar.pending_approvals)+1}' + title,summary=summarize_approval_request(tool,inp); ar.approval_seq += 1; req=f'{cmd.id}:{ar.approval_seq}' p=PendingApproval(cmd.id,req,tool); ar.pending_approvals[req]=p; self.emit({'type':'approval_required','id':cmd.id,'request_id':req,'tool':tool,'title':title,'summary':summary}) p.ready.wait(); self.emit({'type':'approval_resolved','id':cmd.id,'request_id':req,'approved':bool(p.approved)}); return bool(p.approved) and not ar.abort_requested.is_set() try: @@ -144,6 +144,8 @@ def appr(tool,inp): if ex.get('completed') is False: self.emit({'type':'run_failed','success':False,'error':str(reason or 'Runner stopped before completion.'),**base}) else: self.emit({'type':'run_completed','success':True,**base}) except Exception as exc: + if ar.abort_requested.is_set(): + self.emit({'type':'run_aborted','id':cmd.id}); return print(traceback.format_exc(),file=self.stderr); self.emit({'type':'run_failed','id':cmd.id,'success':False,'error':str(exc),'summary':str(exc)}) finally: for req,p in list(ar.pending_approvals.items()): ar.pending_approvals.pop(req,None); p.approved=False; p.ready.set() From f01f842509333e1823495b202bc44f9af5197f48 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 14:11:13 +1000 Subject: [PATCH 03/36] Harden Pi bridge and runtime integration --- integrations/pi-villani/README.md | 6 + integrations/pi-villani/src/modelProxy.ts | 9 +- integrations/pi-villani/src/process.test.ts | 9 +- integrations/pi-villani/src/proxy.test.ts | 7 +- .../pi-villani/src/versionConsistency.test.ts | 2 + packaging/villani_runtime.spec | 2 +- tests/integrations/test_pi_bridge.py | 151 ++++++++++-------- villani_code/integrations/pi_bridge.py | 15 +- 8 files changed, 118 insertions(+), 83 deletions(-) create mode 100644 integrations/pi-villani/src/versionConsistency.test.ts diff --git a/integrations/pi-villani/README.md b/integrations/pi-villani/README.md index dfbbcdc8..e9a35cc8 100644 --- a/integrations/pi-villani/README.md +++ b/integrations/pi-villani/README.md @@ -4,3 +4,9 @@ Install with: pi install npm:@mmprotest/pi-villani ``` Provides `/villani `, `/villani-abort`, and `/villani-confirm-test`. + +Runtime version: `v0.1.0`. + +## Abort semantics + +When `/villani-abort` is invoked, the extension first aborts the Pi model proxy signal and sends a bridge `{ "type": "abort" }` command for the active run. Pending Villani approval requests are denied by the bridge immediately so approved writes do not continue after abort. The extension waits a short grace period for `run_aborted`; if it is not observed, the bridge subprocess is killed. Active child commands launched by the runtime may continue until that subprocess is killed, so abort guarantees no orphan bridge process rather than instant termination of every child process. diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index 450e8ee2..d92a5650 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -1,8 +1,9 @@ import http from 'node:http'; import type { Context, Model, SimpleStreamOptions } from '@earendil-works/pi-ai'; export function zeroUsage(){return {prompt_tokens:0,completion_tokens:0,total_tokens:0};} -function sanitize(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]');} +function sanitize(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|VILLANI_API_KEY)=[^\s]+/ig,'$1=[redacted]');} export type PiModel = Model & { api?: { streamSimple?: (model:Model, context:Context, options?:SimpleStreamOptions)=>AsyncIterable } }; -export function resolvePiModel(ctx:{model?:PiModel}):PiModel{if(!ctx.model) throw new Error('Villani requires an active Pi model; select a Pi model before running /villani.'); if(typeof ctx.model.api?.streamSimple!=='function') throw new Error('Active Pi model does not expose streamSimple; cannot proxy Pi model context to Villani.'); return ctx.model;} -async function collectText(model:PiModel, context:Context, signal?:AbortSignal):Promise{let text=''; for await (const event of model.api!.streamSimple!(model,context,{signal})){const any=event as any; text+=any.textDelta??any.delta??any.text??any.content??'';} return text;} -export async function startModelProxy(model:PiModel, signal?:AbortSignal){const server=http.createServer(async(req,res)=>{if(req.method!=='POST'||req.url!=='/v1/chat/completions'){res.writeHead(404).end();return;} let body=''; req.on('data',d=>body+=d); req.on('end',async()=>{try{const j=JSON.parse(body||'{}'); const context={messages:j.messages||[],tools:j.tools} as unknown as Context; const text=await collectText(model,context,signal); const msg:any={role:'assistant',content:text}; const chunk={id:'pi-villani',object:'chat.completion',created:Math.floor(Date.now()/1000),model:j.model||model.id||'pi-current-model',choices:[{index:0,message:msg,finish_reason:'stop'}],usage:zeroUsage()}; if(j.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...chunk,object:'chat.completion.chunk'})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify(chunk));}}catch(e){res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitize(e)}}));}});}); await new Promise(r=>server.listen(0,'127.0.0.1',r)); signal?.addEventListener('abort',()=>server.close()); const addr=server.address(); if(!addr||typeof addr==='string')throw new Error('Proxy bind failed'); return {url:`http://127.0.0.1:${addr.port}`,close:()=>server.close()};} +export function resolvePiModel(ctx:{model?:PiModel,modelRegistry?:any}):PiModel{const m=ctx.model; if(!m) throw new Error('Villani requires an active Pi model; select a Pi model before running /villani.'); if(typeof m.api?.streamSimple!=='function') throw new Error('Active Pi model does not expose streamSimple; cannot proxy Pi model context to Villani.'); return m;} +function toPiMessages(messages:any[]){return (messages||[]).map(m=>{if(m.role==='tool')return {role:'tool',toolCallId:m.tool_call_id,content:m.content}; return m;});} +async function collect(model:PiModel, context:Context, signal?:AbortSignal):Promise<{text:string,tool_calls:any[]}>{let text=''; const tool_calls:any[]=[]; for await (const event of model.api!.streamSimple!(model,context,{signal})){if(signal?.aborted) throw new Error('aborted'); const any=event as any; text+=any.textDelta??any.delta??any.text??any.content??''; const calls=any.toolCalls??any.tool_calls??(any.toolCall?[any.toolCall]:undefined); if(Array.isArray(calls)) for(const c of calls) tool_calls.push({id:c.id, type:'function', function:{name:c.name??c.function?.name, arguments: typeof c.arguments==='string'?c.arguments:JSON.stringify(c.arguments??c.function?.arguments??{})}});} return {text,tool_calls};} +export async function startModelProxy(model:PiModel, signal?:AbortSignal){const server=http.createServer(async(req,res)=>{if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} let body=''; req.on('data',d=>body+=d); req.on('end',async()=>{try{const j=JSON.parse(body||'{}'); const context={messages:toPiMessages(j.messages||[]),tools:j.tools} as unknown as Context; const got=await collect(model,context,signal); const msg:any={role:'assistant',content:got.text}; if(got.tool_calls.length) msg.tool_calls=got.tool_calls; const chunk={id:'pi-villani',object:'chat.completion',created:Math.floor(Date.now()/1000),model:j.model||model.id||'pi-current-model',choices:[{index:0,message:msg,finish_reason:got.tool_calls.length?'tool_calls':'stop'}],usage:zeroUsage()}; if(j.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...chunk,object:'chat.completion.chunk'})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify(chunk));}}catch(e){res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitize(e)}}));}});}); await new Promise(r=>server.listen(0,'127.0.0.1',r)); signal?.addEventListener('abort',()=>server.close()); const addr=server.address(); if(!addr||typeof addr==='string')throw new Error('Proxy bind failed'); return {url:`http://127.0.0.1:${addr.port}`,close:()=>server.close(),address:addr};} diff --git a/integrations/pi-villani/src/process.test.ts b/integrations/pi-villani/src/process.test.ts index 40f7ca80..cd5bcc49 100644 --- a/integrations/pi-villani/src/process.test.ts +++ b/integrations/pi-villani/src/process.test.ts @@ -1,2 +1,7 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; import { sanitizedEnv } from './process.js'; -test('sanitizes secrets only in proxy mode',()=>{const e=sanitizedEnv({proxyMode:true,env:{OPENAI_API_KEY:'x',CUSTOM_TOKEN:'y',PATH:'p'}}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.CUSTOM_TOKEN,undefined); assert.equal(e.PATH,'p'); assert.equal(sanitizedEnv({proxyMode:false,env:{OPENAI_API_KEY:'x'}}).OPENAI_API_KEY,'x');}); +import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { VillaniBridgeProcess, sanitizedEnv } from './process.js'; +function exe(dir:string, body:string){const p=join(dir,'bridge script.js'); writeFileSync(p,body); chmodSync(p,0o755); return p;} +test('sanitizes secrets only in proxy mode and explicit config preserves env',()=>{const env:any={OPENAI_API_KEY:'x',ANTHROPIC_API_KEY:'a',VILLANI_API_KEY:'v',CUSTOM_TOKEN:'y',X_AUTH:'z',Y_BEARER:'b',PATH:'p'}; const e=sanitizedEnv({proxyMode:true,env}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.ANTHROPIC_API_KEY,undefined); assert.equal(e.VILLANI_API_KEY,undefined); assert.equal(e.CUSTOM_TOKEN,undefined); assert.equal(e.X_AUTH,undefined); assert.equal(e.Y_BEARER,undefined); assert.equal(e.PATH,'p'); assert.equal(sanitizedEnv({proxyMode:true,explicitConfigMode:true,env}).VILLANI_API_KEY,'v');}); +test('VillaniBridgeProcess launches executable with bridge stdio, shell false, and paths with spaces work',async()=>{const d=mkdtempSync(join(tmpdir(),'pi space ')); const p=exe(d,"#!/usr/bin/env node\nif(process.argv[2]!=='bridge'||process.argv[3]!=='--stdio') process.exit(7); console.log(JSON.stringify({type:'ready'})); setTimeout(()=>{},200);\n"); const b=new VillaniBridgeProcess(p); await b.waitUntilReady(1000); assert.deepEqual(b.proc.spawnargs.slice(-2),['bridge','--stdio']); assert.equal((b.proc as any).spawnfile,p); b.kill();}); +test('bridge readiness timeout includes stderr',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=exe(d,"#!/usr/bin/env node\nconsole.error('bad stderr'); setTimeout(()=>{},300);\n"); const b=new VillaniBridgeProcess(p); await assert.rejects(()=>b.waitUntilReady(800),/bad stderr/); b.kill();}); +test('malformed JSONL kills process',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=exe(d,"#!/usr/bin/env node\nconsole.log('nope'); setTimeout(()=>{},500);\n"); const b=new VillaniBridgeProcess(p); await assert.rejects(()=>new Promise((_,rej)=>b.once('error',rej)),/Malformed JSONL/); assert.equal(b.proc.killed,true);}); +test('abort and approval response send correct JSONL',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=exe(d,"#!/usr/bin/env node\nconsole.log(JSON.stringify({type:'ready'})); process.stdin.on('data',d=>process.stdout.write(d)); setTimeout(()=>{},500);\n"); const b=new VillaniBridgeProcess(p); await b.waitUntilReady(1000); const got:any[]=[]; b.on('event',e=>{if(e.type!=='ready')got.push(e)}); b.abort('r'); b.respondToApproval('r','r:1',true); await new Promise(r=>setTimeout(r,50)); b.kill(); assert.deepEqual(got,[{type:'abort',id:'r'},{type:'approval_response',id:'r',request_id:'r:1',approved:true}]);}); diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts index cc1d41ed..0f8db309 100644 --- a/integrations/pi-villani/src/proxy.test.ts +++ b/integrations/pi-villani/src/proxy.test.ts @@ -1,3 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { resolvePiModel, startModelProxy, zeroUsage } from './modelProxy.js'; test('model resolution fails clearly without Pi model API',()=>{assert.throws(()=>resolvePiModel({}),/active Pi model/); assert.throws(()=>resolvePiModel({model:{id:'m'} as any}),/streamSimple/);}); -test('proxy serves model text from Pi streamSimple',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){yield {textDelta:'hi'};}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'hi'); assert.deepEqual(j.usage,zeroUsage());}finally{proxy.close();}}); +test('proxy serves model text from Pi streamSimple and binds localhost',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){yield {textDelta:'hi'};}}}; const proxy=await startModelProxy(model); try{assert.match(proxy.url,/^http:\/\/127\.0\.0\.1:/); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'hi'); assert.deepEqual(j.usage,zeroUsage());}finally{proxy.close();}}); +test('unsupported paths return 404',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/nope',{method:'POST'}); assert.equal(r.status,404);}finally{proxy.close();}}); +test('OpenAI tool calls are converted from Pi stream events',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){yield {toolCalls:[{id:'c1',name:'doit',arguments:{x:1}}]};}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[]})}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'tool_calls'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{proxy.close();}}); +test('OpenAI tool result messages converted into Pi context',async()=>{let seen:any; const model:any={id:'m',api:{streamSimple:async function*(_m:any,ctx:any){seen=ctx; yield {text:'ok'};}}}; const proxy=await startModelProxy(model); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'tool',tool_call_id:'c1',content:'result'}]})}); assert.deepEqual(seen.messages[0],{role:'tool',toolCallId:'c1',content:'result'});}finally{proxy.close();}}); +test('upstream errors are sanitized and credentials are not exposed',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){throw new Error('failed Bearer abc sk-secret OPENAI_API_KEY=abc');}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.doesNotMatch(txt,/Bearer abc|sk-secret|OPENAI_API_KEY=abc/);}finally{proxy.close();}}); +test('abort signal cancels in-flight streamSimple',async()=>{const ac=new AbortController(); let saw=false; const model:any={id:'m',api:{streamSimple:async function*(_m:any,_ctx:any,opt:any){await new Promise(r=>setTimeout(r,30)); saw=opt.signal.aborted; yield {text:'late'};}}}; const proxy=await startModelProxy(model,ac.signal); try{const p=fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}).catch(e=>e); setTimeout(()=>ac.abort(),5); await p; assert.equal(saw,true);}finally{proxy.close();}}); diff --git a/integrations/pi-villani/src/versionConsistency.test.ts b/integrations/pi-villani/src/versionConsistency.test.ts new file mode 100644 index 00000000..6c863dbc --- /dev/null +++ b/integrations/pi-villani/src/versionConsistency.test.ts @@ -0,0 +1,2 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { VILLANI_RUNTIME_VERSION, assetName, platformKey, VILLANI_RUNTIME_TAG } from './runtimeConfig.js'; +test('runtime version agrees across config, workflow asset names, package docs, and release checklist',()=>{const root=process.cwd(); const wf=readFileSync(join(root,'../../.github/workflows/build-pi-villani-runtime-release.yml'),'utf8'); const readme=readFileSync(join(root,'README.md'),'utf8'); const release=readFileSync(join(root,'docs/release.md'),'utf8'); assert.equal(VILLANI_RUNTIME_VERSION,'0.1.0'); assert.equal(VILLANI_RUNTIME_TAG,`pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`); assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); for(const text of [wf,readme,release]) assert.match(text,new RegExp(`v${VILLANI_RUNTIME_VERSION.replaceAll('.','\\.')}`)); assert.match(wf,new RegExp(`villani-runtime-v${VILLANI_RUNTIME_VERSION.replaceAll('.','\\.')}-`));}); diff --git a/packaging/villani_runtime.spec b/packaging/villani_runtime.spec index d2acc881..3f73b145 100644 --- a/packaging/villani_runtime.spec +++ b/packaging/villani_runtime.spec @@ -2,7 +2,7 @@ block_cipher = None a = Analysis( - ['villani_code/cli.py'], + ['packaging/villani_runtime_entry.py'], pathex=[], binaries=[], datas=[], diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py index 9b72ac95..ea683b26 100644 --- a/tests/integrations/test_pi_bridge.py +++ b/tests/integrations/test_pi_bridge.py @@ -1,76 +1,89 @@ from __future__ import annotations -import io -import json -import tempfile +import io, json, subprocess, tempfile, time from pathlib import Path -from villani_code.integrations.pi_bridge import PiBridge - +from villani_code.execution import ExecutionBudget +from villani_code.integrations.pi_bridge import PiBridge, attributed_changed_files, git_changed_files, hash_files, run_existing_runner class DummyRunner: - print_stream = False - - def __init__(self, approval_callback=None, event_callback=None): - self.approval_callback = approval_callback - self.event_callback = event_callback - + def __init__(self, approval_callback=None, event_callback=None): self.approval_callback=approval_callback; self.event_callback=event_callback; self.calls=[] def run(self, task, execution_budget=None): - if task == "approve-twice": - assert self.approval_callback("Bash", {"command": "one"}) is True - assert self.approval_callback("Bash", {"command": "two"}) is True - return {"response": "ok", "execution": {"completed": True}} - - -def _bridge(): - out = io.StringIO() - - def factory(command, event_callback, approval_callback): - return DummyRunner(approval_callback=approval_callback, event_callback=event_callback) - - return PiBridge(stdin=io.StringIO(), stdout=out, runner_factory=factory), out - - -def _events(out: io.StringIO): - return [json.loads(line) for line in out.getvalue().splitlines() if line.strip()] - - -def test_ping_and_ready_event_shape(): - bridge, out = _bridge() - bridge.emit({"type": "ready", "protocol_version": 1}) - bridge.handle({"type": "ping", "id": "p1"}) - assert _events(out) == [{"type": "ready", "protocol_version": 1}, {"type": "pong", "id": "p1"}] - - -def test_approval_request_ids_are_monotonic_even_after_resolution(): - bridge, out = _bridge() - repo = tempfile.TemporaryDirectory() - bridge.handle({"type": "run", "id": "r1", "task": "approve-twice", "repo": repo.name, "config": {"provider": "openai", "model": "m", "base_url": "u"}}) # type: ignore[arg-type] - while True: - approvals = [e for e in _events(out) if e.get("type") == "approval_required"] - if approvals: - break - bridge.approval(type("Cmd", (), {"id": "r1", "request_id": "r1:1", "approved": True})()) - while True: - approvals = [e for e in _events(out) if e.get("type") == "approval_required"] - if len(approvals) == 2: - break - bridge.approval(type("Cmd", (), {"id": "r1", "request_id": "r1:2", "approved": True})()) - bridge.runs["r1"].thread.join(2) - approvals = [e["request_id"] for e in _events(out) if e.get("type") == "approval_required"] - assert approvals == ["r1:1", "r1:2"] - repo.cleanup() - - -def test_abort_resolves_pending_approval_and_emits_run_aborted(): - bridge, out = _bridge() - repo = tempfile.TemporaryDirectory() - bridge.handle({"type": "run", "id": "r2", "task": "approve-twice", "repo": repo.name, "config": {"provider": "openai", "model": "m", "base_url": "u"}}) # type: ignore[arg-type] - while not [e for e in _events(out) if e.get("type") == "approval_required"]: - pass - bridge.abort("r2") - bridge.runs["r2"].thread.join(2) - types = [e["type"] for e in _events(out)] - assert "abort_requested" in types - assert "run_aborted" in types - repo.cleanup() + self.calls.append(('run',task,execution_budget)) + if task=='raise': raise RuntimeError('boom') + if task=='incomplete': return {'response':'bad','execution':{'completed':False,'reason':'budget'}} + if task=='stream': self.event_callback({'type':'stream_text','text':'SECRET'}); return {'response':'ok','execution':{'completed':True}} + if task=='write': + if self.approval_callback('Write', {'path':'a.txt','content':'x'}): Path('a.txt').write_text('x') + return {'response':'ok','execution':{'completed':True}} + if task=='bash': self.approval_callback('Bash', {'command':'echo hello && cat secret'}); return {'response':'ok','execution':{'completed':True}} + if task=='approve-twice': + assert self.approval_callback('Bash', {'command':'one'}) is True + assert self.approval_callback('Bash', {'command':'two'}) is True + return {'response':'ok','execution':{'completed':True},'verification_passed':True} + def run_villani_mode(self): self.calls.append(('villani',)); return {'response':'villani','execution':{'completed':True}} + +def make_bridge(runner=None): + out=io.StringIO(); err=io.StringIO(); made=[] + def factory(cmd,event_callback,approval_callback): + r=runner or DummyRunner(approval_callback,event_callback); r.approval_callback=approval_callback; r.event_callback=event_callback; made.append(r); return r + return PiBridge(stdin=io.StringIO(),stdout=out,stderr=err,runner_factory=factory),out,err,made + +def events(out): return [json.loads(l) for l in out.getvalue().splitlines() if l.strip()] +def wait_for(out, pred, timeout=3): + end=time.time()+timeout + while time.time()=3; b.approval(type('C',(),{'id':'r1','request_id':'r1:2','approved':True})()); wait_for(out,lambda e:e['type']=='run_completed') + +def test_abort_during_pending_approval_emits_run_aborted(tmp_path): + b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'approve-twice')); wait_for(out,lambda e:e['type']=='approval_required'); b.abort('r1'); wait_for(out,lambda e:e['type']=='run_aborted'); assert any(e['type']=='approval_resolved' and e['approved'] is False for e in events(out)) + +def init_repo(p): + subprocess.run(['git','init'],cwd=p,check=True,capture_output=True); subprocess.run(['git','config','user.email','a@b.c'],cwd=p,check=True); subprocess.run(['git','config','user.name','A'],cwd=p,check=True) + +def test_changed_file_attribution_cases(tmp_path): + init_repo(tmp_path); (tmp_path/'tracked.txt').write_text('base'); subprocess.run(['git','add','.'],cwd=tmp_path,check=True); subprocess.run(['git','commit','-m','init'],cwd=tmp_path,check=True,capture_output=True) + (tmp_path/'new.txt').write_text('n'); before=git_changed_files(tmp_path); hashes=hash_files(tmp_path,before); ch,pre=attributed_changed_files(tmp_path,before,hashes,set()); assert 'new.txt' in pre and not ch + (tmp_path/'new2.txt').write_text('n'); ch,pre=attributed_changed_files(tmp_path,before,hashes,set()); assert 'new2.txt' in ch and 'new.txt' in pre + (tmp_path/'new.txt').write_text('changed'); ch,pre=attributed_changed_files(tmp_path,before,hashes,set()); assert 'new.txt' in ch + (tmp_path/'revert.txt').write_text('x'); (tmp_path/'revert.txt').unlink(); ch,_=attributed_changed_files(tmp_path,before,hashes,set()); assert 'revert.txt' not in ch + for path in ['.villani/a','.villani_code/b','__pycache__/c.pyc','d.pyc']: + q=tmp_path/path; q.parent.mkdir(parents=True,exist_ok=True); q.write_text('x') + ch,pre=attributed_changed_files(tmp_path,before,hashes,set()); assert not any(x.startswith(('.villani','.villani_code','__pycache__')) or x.endswith('.pyc') for x in ch+pre) diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index 94d83f18..52115d89 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -99,12 +99,15 @@ def run_forever(self): try: self.handle(parse_json_line(line)) except Exception as exc: self.emit({'type':'error','error':str(exc)}) def handle(self,p): - t=p.get('type') - if t=='ping': self.emit({'type':'pong','id':p.get('id')}); return - if t=='run': self.start_run(parse_run_command(p)); return - if t=='abort': self.abort(str(p.get('id',''))); return - if t=='approval_response': self.approval(parse_approval_response_command(p)); return - self.emit({'type':'error','error':f'Unknown bridge command type: {t}'}) + try: + t=p.get('type') + if t=='ping': self.emit({'type':'pong','id':p.get('id')}); return + if t=='run': self.start_run(parse_run_command(p)); return + if t=='abort': self.abort(str(p.get('id',''))); return + if t=='approval_response': self.approval(parse_approval_response_command(p)); return + self.emit({'type':'error','error':f'Unknown bridge command type: {t}'}) + except Exception as exc: + self.emit({'type':'error','error':str(exc)}) def start_run(self,cmd): with self.lock: if cmd.id in self.runs: self.emit({'type':'error','id':cmd.id,'error':'Duplicate active run id'}); return From e734d66276638a048f040aa7a8ba0274f6de83e6 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 14:34:09 +1000 Subject: [PATCH 04/36] Fix Pi integration release blockers --- .../workflows/build-pi-villani-runtime-release.yml | 6 +++--- integrations/pi-villani/README.md | 2 +- integrations/pi-villani/docs/release.md | 2 +- integrations/pi-villani/package-lock.json | 4 ++-- integrations/pi-villani/package.json | 2 +- integrations/pi-villani/src/process.test.ts | 14 ++++++++------ integrations/pi-villani/src/process.ts | 10 +++++----- integrations/pi-villani/src/runtime.test.ts | 4 ++-- integrations/pi-villani/src/runtimeConfig.ts | 2 +- integrations/pi-villani/src/smoke.test.ts | 4 ++-- .../pi-villani/src/versionConsistency.test.ts | 3 ++- 11 files changed, 28 insertions(+), 25 deletions(-) diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml index b530b608..12472c69 100644 --- a/.github/workflows/build-pi-villani-runtime-release.yml +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -30,7 +30,7 @@ jobs: - run: python packaging/smoke_test_runtime.py dist-runtime/villani-code/villani-code${{ runner.os == 'Windows' && '.exe' || '' }} - shell: bash run: | - name="villani-runtime-v0.1.0-${{ matrix.key }}.${{ matrix.ext }}" + name="villani-runtime-v0.1.2-${{ matrix.key }}.${{ matrix.ext }}" cd dist-runtime if [[ "${{ matrix.ext }}" == "zip" ]]; then 7z a ../$name villani-code; else tar -czf ../$name villani-code; fi cd .. @@ -51,7 +51,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: runtime-${{ matrix.key }} - path: "villani-runtime-v0.1.0-${{ matrix.key }}.${{ matrix.ext }}*" + path: "villani-runtime-v0.1.2-${{ matrix.key }}.${{ matrix.ext }}*" release: needs: build runs-on: ubuntu-latest @@ -63,5 +63,5 @@ jobs: - uses: softprops/action-gh-release@v2 with: files: | - artifacts/**/villani-runtime-v0.1.0-* + artifacts/**/villani-runtime-v0.1.2-* checksums.txt diff --git a/integrations/pi-villani/README.md b/integrations/pi-villani/README.md index e9a35cc8..85e689db 100644 --- a/integrations/pi-villani/README.md +++ b/integrations/pi-villani/README.md @@ -5,7 +5,7 @@ pi install npm:@mmprotest/pi-villani ``` Provides `/villani `, `/villani-abort`, and `/villani-confirm-test`. -Runtime version: `v0.1.0`. +Runtime version: `v0.1.2`. ## Abort semantics diff --git a/integrations/pi-villani/docs/release.md b/integrations/pi-villani/docs/release.md index e92d93c9..a125ba28 100644 --- a/integrations/pi-villani/docs/release.md +++ b/integrations/pi-villani/docs/release.md @@ -1,5 +1,5 @@ # Release checklist -1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.0`. +1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.2`. 2. Verify assets and checksums. 3. Publish `@mmprotest/pi-villani`. Install: `pi install npm:@mmprotest/pi-villani`. diff --git a/integrations/pi-villani/package-lock.json b/integrations/pi-villani/package-lock.json index 50729ed7..4fca7c5f 100644 --- a/integrations/pi-villani/package-lock.json +++ b/integrations/pi-villani/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mmprotest/pi-villani", - "version": "0.1.0", + "version": "0.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mmprotest/pi-villani", - "version": "0.1.0", + "version": "0.1.2", "dependencies": { "@earendil-works/pi-ai": "latest", "@earendil-works/pi-coding-agent": "latest", diff --git a/integrations/pi-villani/package.json b/integrations/pi-villani/package.json index aa873016..aac21d0f 100644 --- a/integrations/pi-villani/package.json +++ b/integrations/pi-villani/package.json @@ -1 +1 @@ -{"name":"@mmprotest/pi-villani","version":"0.1.0","description":"Pi extension for Villani Code","main":"dist/index.js","types":"dist/index.d.ts","type":"module","engines":{"node":">=22.19.0"},"pi":{"extensions":["./dist/index.js"]},"files":["dist","!dist/*.test.js","!dist/*.test.d.ts","README.md","docs"],"scripts":{"build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit","test":"npm run build && node --test dist/*.test.js"},"dependencies":{"@earendil-works/pi-ai":"latest","@earendil-works/pi-coding-agent":"latest","adm-zip":"latest","tar":"latest"},"devDependencies":{"typescript":"latest","@types/node":"latest","@types/adm-zip":"latest"}} +{"name":"@mmprotest/pi-villani","version":"0.1.2","description":"Pi extension for Villani Code","main":"dist/index.js","types":"dist/index.d.ts","type":"module","engines":{"node":">=22.19.0"},"pi":{"extensions":["./dist/index.js"]},"files":["dist","!dist/*.test.js","!dist/*.test.d.ts","README.md","docs"],"scripts":{"build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit","test":"npm run build && node --test dist/*.test.js"},"dependencies":{"@earendil-works/pi-ai":"latest","@earendil-works/pi-coding-agent":"latest","adm-zip":"latest","tar":"latest"},"devDependencies":{"typescript":"latest","@types/node":"latest","@types/adm-zip":"latest"}} diff --git a/integrations/pi-villani/src/process.test.ts b/integrations/pi-villani/src/process.test.ts index cd5bcc49..c6f1a7d5 100644 --- a/integrations/pi-villani/src/process.test.ts +++ b/integrations/pi-villani/src/process.test.ts @@ -1,7 +1,9 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync, chmodSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { VillaniBridgeProcess, sanitizedEnv } from './process.js'; -function exe(dir:string, body:string){const p=join(dir,'bridge script.js'); writeFileSync(p,body); chmodSync(p,0o755); return p;} +import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { VillaniBridgeProcess, sanitizedEnv } from './process.js'; +function script(dir:string, body:string){const p=join(dir,'fake-ready-bridge.mjs'); writeFileSync(p,body); return p;} +const readyBridge="process.stdout.write(JSON.stringify({type:'ready',protocol_version:1})+'\\n');\nprocess.stdin.on('data',chunk=>{for(const line of chunk.toString().split(/\\r?\\n/)){if(!line.trim())continue; const msg=JSON.parse(line); if(msg.type==='ping')process.stdout.write(JSON.stringify({type:'pong',id:msg.id})+'\\n'); if(msg.type==='abort')process.stdout.write(JSON.stringify({type:'run_aborted',id:msg.id})+'\\n'); if(msg.type==='approval_response')process.stdout.write(JSON.stringify({type:'approval_resolved',id:msg.id,request_id:msg.request_id,approved:msg.approved})+'\\n');}}); setTimeout(()=>{},500);\n"; +test('VillaniBridgeProcess launches production bridge stdio by default with shell false and paths with spaces work',async()=>{const d=mkdtempSync(join(tmpdir(),'pi space ')); const p=script(d,"if(process.argv[2]!=='bridge'||process.argv[3]!=='--stdio') process.exit(7); process.stdout.write(JSON.stringify({type:'ready'})+'\\n'); setTimeout(()=>{},200);\n"); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p,'bridge','--stdio']}); await b.waitUntilReady(1000); assert.equal((b.proc as any).spawnfile,process.execPath); assert.equal((b.proc as any).spawnargs[1],p); b.kill(); const prod=new VillaniBridgeProcess(process.execPath); assert.deepEqual(prod.proc.spawnargs.slice(-2),['bridge','--stdio']); assert.equal((prod.proc as any).spawnfile,process.execPath); assert.equal(prod.shell,false); prod.kill();}); +test('test bridge args override can use a fixture script',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,readyBridge); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await b.waitUntilReady(1000); assert.equal((b.proc as any).spawnargs[1],p); b.kill();}); +test('bridge readiness timeout includes stderr',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,"console.error('bad stderr'); setTimeout(()=>{},300);\n"); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await assert.rejects(()=>b.waitUntilReady(800),/bad stderr/); b.kill();}); +test('malformed JSONL kills process',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,"console.log('nope'); setTimeout(()=>{},500);\n"); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await assert.rejects(()=>new Promise((_,rej)=>b.once('error',rej)),/Malformed JSONL/); assert.equal(b.proc.killed,true);}); +test('abort and approval response send correct JSONL',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,readyBridge); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await b.waitUntilReady(1000); const got:any[]=[]; b.on('event',e=>{if(e.type!=='ready')got.push(e)}); b.abort('r'); b.respondToApproval('r','r:1',true); await new Promise(r=>setTimeout(r,50)); b.kill(); assert.deepEqual(got,[{type:'run_aborted',id:'r'},{type:'approval_resolved',id:'r',request_id:'r:1',approved:true}]);}); test('sanitizes secrets only in proxy mode and explicit config preserves env',()=>{const env:any={OPENAI_API_KEY:'x',ANTHROPIC_API_KEY:'a',VILLANI_API_KEY:'v',CUSTOM_TOKEN:'y',X_AUTH:'z',Y_BEARER:'b',PATH:'p'}; const e=sanitizedEnv({proxyMode:true,env}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.ANTHROPIC_API_KEY,undefined); assert.equal(e.VILLANI_API_KEY,undefined); assert.equal(e.CUSTOM_TOKEN,undefined); assert.equal(e.X_AUTH,undefined); assert.equal(e.Y_BEARER,undefined); assert.equal(e.PATH,'p'); assert.equal(sanitizedEnv({proxyMode:true,explicitConfigMode:true,env}).VILLANI_API_KEY,'v');}); -test('VillaniBridgeProcess launches executable with bridge stdio, shell false, and paths with spaces work',async()=>{const d=mkdtempSync(join(tmpdir(),'pi space ')); const p=exe(d,"#!/usr/bin/env node\nif(process.argv[2]!=='bridge'||process.argv[3]!=='--stdio') process.exit(7); console.log(JSON.stringify({type:'ready'})); setTimeout(()=>{},200);\n"); const b=new VillaniBridgeProcess(p); await b.waitUntilReady(1000); assert.deepEqual(b.proc.spawnargs.slice(-2),['bridge','--stdio']); assert.equal((b.proc as any).spawnfile,p); b.kill();}); -test('bridge readiness timeout includes stderr',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=exe(d,"#!/usr/bin/env node\nconsole.error('bad stderr'); setTimeout(()=>{},300);\n"); const b=new VillaniBridgeProcess(p); await assert.rejects(()=>b.waitUntilReady(800),/bad stderr/); b.kill();}); -test('malformed JSONL kills process',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=exe(d,"#!/usr/bin/env node\nconsole.log('nope'); setTimeout(()=>{},500);\n"); const b=new VillaniBridgeProcess(p); await assert.rejects(()=>new Promise((_,rej)=>b.once('error',rej)),/Malformed JSONL/); assert.equal(b.proc.killed,true);}); -test('abort and approval response send correct JSONL',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=exe(d,"#!/usr/bin/env node\nconsole.log(JSON.stringify({type:'ready'})); process.stdin.on('data',d=>process.stdout.write(d)); setTimeout(()=>{},500);\n"); const b=new VillaniBridgeProcess(p); await b.waitUntilReady(1000); const got:any[]=[]; b.on('event',e=>{if(e.type!=='ready')got.push(e)}); b.abort('r'); b.respondToApproval('r','r:1',true); await new Promise(r=>setTimeout(r,50)); b.kill(); assert.deepEqual(got,[{type:'abort',id:'r'},{type:'approval_response',id:'r',request_id:'r:1',approved:true}]);}); diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts index dbb5f339..8a981e53 100644 --- a/integrations/pi-villani/src/process.ts +++ b/integrations/pi-villani/src/process.ts @@ -1,10 +1,10 @@ import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; import { EventEmitter } from 'node:events'; -export interface BridgeEvent{type:string;[k:string]:unknown} export interface LaunchOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; timeoutMs?:number} -export function sanitizedEnv(opts:LaunchOptions={}):NodeJS.ProcessEnv{const e={...(opts.env||process.env)}; if(opts.proxyMode&&!opts.explicitConfigMode){for(const k of Object.keys(e)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|AUTH|TOKEN|BEARER)$/i.test(k)) delete e[k];}} return e;} -export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; - constructor(executable:string, opts:LaunchOptions={}){super(); this.proc=spawn(executable,['bridge','--stdio'],{shell:false,env:sanitizedEnv(opts)}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e));} +export interface BridgeEvent{type:string;[k:string]:unknown} export interface BridgeProcessOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; cwd?:string; startupTimeoutMs?:number; bridgeArgs?:string[]} export type LaunchOptions=BridgeProcessOptions; +export function sanitizedEnv(opts:BridgeProcessOptions={}):NodeJS.ProcessEnv{const e={...(opts.env||process.env)}; if(opts.proxyMode&&!opts.explicitConfigMode){for(const k of Object.keys(e)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|AUTH|TOKEN|BEARER)$/i.test(k)) delete e[k];}} return e;} +export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; startupTimeoutMs:number; readonly shell=false; + constructor(executable:string, opts:BridgeProcessOptions={}){super(); const args=opts.bridgeArgs??['bridge','--stdio']; this.startupTimeoutMs=opts.startupTimeoutMs??10000; this.proc=spawn(executable,args,{shell:false,env:sanitizedEnv(opts),cwd:opts.cwd}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e));} onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; this.emit('event',e);}catch{this.kill(); this.emit('error',new Error('Malformed JSONL from bridge'));}}} send(c:unknown){this.proc.stdin.write(JSON.stringify(c)+'\n');} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} waitForEvent(type:string,timeoutMs=1000){return new Promise(r=>{const t=setTimeout(()=>{this.off('event',on);r(undefined);},timeoutMs); const on=(e:BridgeEvent)=>{if(e.type===type){clearTimeout(t);this.off('event',on);r(e);}}; this.on('event',on);});} - waitUntilReady(timeoutMs=10000){return new Promise((res,rej)=>{if(this.ready)return res(); const t=setTimeout(()=>rej(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); this.on('event',(e:BridgeEvent)=>{if(e.type==='ready'){clearTimeout(t);res();}}); this.proc.once('exit',()=>{clearTimeout(t);rej(new Error(`Bridge exited before ready. stderr: ${this.stderr}`));});});} + waitUntilReady(timeoutMs=this.startupTimeoutMs){return new Promise((res,rej)=>{if(this.ready)return res(); const t=setTimeout(()=>rej(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); this.on('event',(e:BridgeEvent)=>{if(e.type==='ready'){clearTimeout(t);res();}}); this.proc.once('exit',()=>{clearTimeout(t);rej(new Error(`Bridge exited before ready. stderr: ${this.stderr}`));});});} waitForExit(){return new Promise(r=>this.proc.once('exit',r));}} diff --git a/integrations/pi-villani/src/runtime.test.ts b/integrations/pi-villani/src/runtime.test.ts index 40010a9a..f03a1e7a 100644 --- a/integrations/pi-villani/src/runtime.test.ts +++ b/integrations/pi-villani/src/runtime.test.ts @@ -1,4 +1,4 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import AdmZip from 'adm-zip'; import * as tar from 'tar'; -import { assetName, executableRelativePath, platformKey } from './runtimeConfig.js'; import { extractRuntimeArchive, parseChecksums } from './runtime.js'; -test('runtime asset helpers and checksums',()=>{assert.equal(assetName(platformKey('linux','x64')),'villani-runtime-v0.1.0-linux-x64.tar.gz'); assert.equal(executableRelativePath('win32-x64'),'villani-code/villani-code.exe'); assert.equal(parseChecksums('abc file.tgz').get('file.tgz'),'abc');}); +import { assetName, executableRelativePath, platformKey, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; import { extractRuntimeArchive, parseChecksums } from './runtime.js'; +test('runtime asset helpers and checksums',()=>{assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); assert.equal(executableRelativePath('win32-x64'),'villani-code/villani-code.exe'); assert.equal(parseChecksums('abc file.tgz').get('file.tgz'),'abc');}); test('extracts zip and tar archives without shelling out',async()=>{const dir=mkdtempSync(join(tmpdir(),'villani-rt-')); const zipPath=join(dir,'a.zip'); const zip=new AdmZip(); zip.addFile('villani-code/villani-code',Buffer.from('x')); zip.writeZip(zipPath); const zipOut=join(dir,'zipout'); await extractRuntimeArchive(zipPath,zipOut); assert.ok(existsSync(join(zipOut,'villani-code','villani-code'))); mkdirSync(join(dir,'villani-code'),{recursive:true}); writeFileSync(join(dir,'villani-code','villani-code'),'x'); await tar.c({gzip:true,file:join(dir,'a.tar.gz'),cwd:dir},['villani-code']); const tarOut=join(dir,'tarout'); await extractRuntimeArchive(join(dir,'a.tar.gz'),tarOut); assert.ok(existsSync(join(tarOut,'villani-code','villani-code')));}); diff --git a/integrations/pi-villani/src/runtimeConfig.ts b/integrations/pi-villani/src/runtimeConfig.ts index 170afabe..55298da9 100644 --- a/integrations/pi-villani/src/runtimeConfig.ts +++ b/integrations/pi-villani/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -export const VILLANI_RUNTIME_VERSION = "0.1.0"; +export const VILLANI_RUNTIME_VERSION = "0.1.2"; export const VILLANI_RUNTIME_REPOSITORY = "mmprotest/villani-code"; export const VILLANI_RUNTIME_TAG = `pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`; export type PlatformKey = "win32-x64"|"darwin-arm64"|"darwin-x64"|"linux-x64"; diff --git a/integrations/pi-villani/src/smoke.test.ts b/integrations/pi-villani/src/smoke.test.ts index ffea1fe1..c9d9d97f 100644 --- a/integrations/pi-villani/src/smoke.test.ts +++ b/integrations/pi-villani/src/smoke.test.ts @@ -1,9 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { assetName, executableRelativePath, platformKey } from './runtimeConfig.js'; +import { assetName, executableRelativePath, platformKey, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; import { parseChecksums } from './runtime.js'; import { sanitizedEnv } from './process.js'; import { zeroUsage } from './modelProxy.js'; import { visibleChangedFiles } from './render.js'; -test('runtime asset helpers',()=>{assert.equal(assetName(platformKey('linux','x64')),'villani-runtime-v0.1.0-linux-x64.tar.gz'); assert.equal(executableRelativePath('win32-x64'),'villani-code/villani-code.exe');}); +test('runtime asset helpers',()=>{assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); assert.equal(executableRelativePath('win32-x64'),'villani-code/villani-code.exe');}); test('checksum parsing and env sanitization',()=>{assert.equal(parseChecksums('abc file.tgz').get('file.tgz'),'abc'); const e=sanitizedEnv({proxyMode:true,env:{OPENAI_API_KEY:'x',PATH:'p'}}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.PATH,'p');}); test('usage and render filters',()=>{assert.deepEqual(zeroUsage(),{prompt_tokens:0,completion_tokens:0,total_tokens:0}); assert.deepEqual(visibleChangedFiles(['a.py','.villani/x','x.pyc']),['a.py']);}); diff --git a/integrations/pi-villani/src/versionConsistency.test.ts b/integrations/pi-villani/src/versionConsistency.test.ts index 6c863dbc..41102a7f 100644 --- a/integrations/pi-villani/src/versionConsistency.test.ts +++ b/integrations/pi-villani/src/versionConsistency.test.ts @@ -1,2 +1,3 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { VILLANI_RUNTIME_VERSION, assetName, platformKey, VILLANI_RUNTIME_TAG } from './runtimeConfig.js'; -test('runtime version agrees across config, workflow asset names, package docs, and release checklist',()=>{const root=process.cwd(); const wf=readFileSync(join(root,'../../.github/workflows/build-pi-villani-runtime-release.yml'),'utf8'); const readme=readFileSync(join(root,'README.md'),'utf8'); const release=readFileSync(join(root,'docs/release.md'),'utf8'); assert.equal(VILLANI_RUNTIME_VERSION,'0.1.0'); assert.equal(VILLANI_RUNTIME_TAG,`pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`); assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); for(const text of [wf,readme,release]) assert.match(text,new RegExp(`v${VILLANI_RUNTIME_VERSION.replaceAll('.','\\.')}`)); assert.match(wf,new RegExp(`villani-runtime-v${VILLANI_RUNTIME_VERSION.replaceAll('.','\\.')}-`));}); +function escapeRegex(value:string):string{return value.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');} +test('runtime version agrees across config, workflow asset names, package docs, and release checklist',()=>{const root=process.cwd(); const wf=readFileSync(join(root,'../../.github/workflows/build-pi-villani-runtime-release.yml'),'utf8'); const readme=readFileSync(join(root,'README.md'),'utf8'); const release=readFileSync(join(root,'docs/release.md'),'utf8'); const pkg=JSON.parse(readFileSync(join(root,'package.json'),'utf8')); assert.equal(pkg.version,VILLANI_RUNTIME_VERSION); assert.equal(VILLANI_RUNTIME_TAG,`pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`); assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); const versionPattern=new RegExp(`v${escapeRegex(VILLANI_RUNTIME_VERSION)}`); assert.match(readme,versionPattern); assert.match(release,versionPattern); assert.match(wf,new RegExp(`villani-runtime-v${escapeRegex(VILLANI_RUNTIME_VERSION)}-`));}); From 603d6c9e2147efeb52a7badfecc46e752686e358 Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Sun, 28 Jun 2026 14:50:50 +1000 Subject: [PATCH 05/36] Minor changes --- packaging/villani_runtime.spec | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packaging/villani_runtime.spec b/packaging/villani_runtime.spec index 3f73b145..5ef1d547 100644 --- a/packaging/villani_runtime.spec +++ b/packaging/villani_runtime.spec @@ -1,9 +1,15 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ENTRY = ROOT / "packaging" / "villani_runtime_entry.py" + + # PyInstaller spec for the Pi Villani runtime bridge executable. block_cipher = None a = Analysis( - ['packaging/villani_runtime_entry.py'], - pathex=[], + [str(ENTRY)], + pathex=[str(ROOT)], binaries=[], datas=[], hiddenimports=['villani_code.integrations.pi_bridge'], From add9d9c014fc57b35804b9ac1386feb8a4656735 Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Sun, 28 Jun 2026 14:55:18 +1000 Subject: [PATCH 06/36] Changes --- packaging/villani_runtime.spec | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packaging/villani_runtime.spec b/packaging/villani_runtime.spec index 5ef1d547..7f1b99f8 100644 --- a/packaging/villani_runtime.spec +++ b/packaging/villani_runtime.spec @@ -1,6 +1,7 @@ from pathlib import Path -ROOT = Path(__file__).resolve().parents[1] +SPEC_DIR = Path(SPECPATH).resolve() +ROOT = SPEC_DIR.parent ENTRY = ROOT / "packaging" / "villani_runtime_entry.py" From 9f8db582b569cb051d0ee2cbd684450c16221741 Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Sun, 28 Jun 2026 14:58:50 +1000 Subject: [PATCH 07/36] More changes --- .github/workflows/build-pi-villani-runtime-release.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml index 12472c69..13214263 100644 --- a/.github/workflows/build-pi-villani-runtime-release.yml +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -34,7 +34,15 @@ jobs: cd dist-runtime if [[ "${{ matrix.ext }}" == "zip" ]]; then 7z a ../$name villani-code; else tar -czf ../$name villani-code; fi cd .. - shasum -a 256 "$name" > "${name}.sha256" + python - "$name" > "${name}.sha256" <<'PY' + import hashlib + import pathlib + import sys + + path = pathlib.Path(sys.argv[1]) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + print(f"{digest} {path.name}") + PY mkdir -p smoke-asset python - "$name" smoke-asset <<'PY' import sys, tarfile, zipfile From 9f159d25a5ccec7491225b837902f0382737d66c Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Sun, 28 Jun 2026 15:03:00 +1000 Subject: [PATCH 08/36] Changes --- .github/workflows/build-pi-villani-runtime-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml index 13214263..4d34123d 100644 --- a/.github/workflows/build-pi-villani-runtime-release.yml +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -15,7 +15,7 @@ jobs: os: macos-14 ext: tar.gz - key: darwin-x64 - os: macos-13 + os: macos-26 ext: tar.gz - key: linux-x64 os: ubuntu-latest From 2be6098a1a04464a5299304573ff6189a86e4e35 Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Sun, 28 Jun 2026 15:06:45 +1000 Subject: [PATCH 09/36] Changes --- .github/workflows/build-pi-villani-runtime-release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml index 4d34123d..3c89f12f 100644 --- a/.github/workflows/build-pi-villani-runtime-release.yml +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -1,4 +1,6 @@ name: Build Pi Villani Runtime Release +permissions: + contents: write on: workflow_dispatch: push: From 56c68dcaf13d83885f5925f9e1004ba2c82dc7e1 Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Sun, 28 Jun 2026 15:43:02 +1000 Subject: [PATCH 10/36] Prepare Pi Villani release v0.1.3 --- .github/workflows/build-pi-villani-runtime-release.yml | 6 +++--- .gitignore | 8 ++++++++ integrations/pi-villani/README.md | 2 +- integrations/pi-villani/docs/release.md | 2 +- integrations/pi-villani/package-lock.json | 4 ++-- integrations/pi-villani/package.json | 2 +- integrations/pi-villani/src/runtimeConfig.ts | 2 +- 7 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml index 3c89f12f..c5dc1135 100644 --- a/.github/workflows/build-pi-villani-runtime-release.yml +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -32,7 +32,7 @@ jobs: - run: python packaging/smoke_test_runtime.py dist-runtime/villani-code/villani-code${{ runner.os == 'Windows' && '.exe' || '' }} - shell: bash run: | - name="villani-runtime-v0.1.2-${{ matrix.key }}.${{ matrix.ext }}" + name="villani-runtime-v0.1.3-${{ matrix.key }}.${{ matrix.ext }}" cd dist-runtime if [[ "${{ matrix.ext }}" == "zip" ]]; then 7z a ../$name villani-code; else tar -czf ../$name villani-code; fi cd .. @@ -61,7 +61,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: runtime-${{ matrix.key }} - path: "villani-runtime-v0.1.2-${{ matrix.key }}.${{ matrix.ext }}*" + path: "villani-runtime-v0.1.3-${{ matrix.key }}.${{ matrix.ext }}*" release: needs: build runs-on: ubuntu-latest @@ -73,5 +73,5 @@ jobs: - uses: softprops/action-gh-release@v2 with: files: | - artifacts/**/villani-runtime-v0.1.2-* + artifacts/**/villani-runtime-v0.1.3-* checksums.txt diff --git a/.gitignore b/.gitignore index 8284d478..b78037ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ +# Keep the Pi runtime PyInstaller spec tracked +!packaging/villani_runtime.spec + +# Node +node_modules/ +npm-debug.log* +*.tgz + __pycache__/ *.pyc .venv/ diff --git a/integrations/pi-villani/README.md b/integrations/pi-villani/README.md index 85e689db..eff11073 100644 --- a/integrations/pi-villani/README.md +++ b/integrations/pi-villani/README.md @@ -5,7 +5,7 @@ pi install npm:@mmprotest/pi-villani ``` Provides `/villani `, `/villani-abort`, and `/villani-confirm-test`. -Runtime version: `v0.1.2`. +Runtime version: `v0.1.3`. ## Abort semantics diff --git a/integrations/pi-villani/docs/release.md b/integrations/pi-villani/docs/release.md index a125ba28..b15606be 100644 --- a/integrations/pi-villani/docs/release.md +++ b/integrations/pi-villani/docs/release.md @@ -1,5 +1,5 @@ # Release checklist -1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.2`. +1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.3`. 2. Verify assets and checksums. 3. Publish `@mmprotest/pi-villani`. Install: `pi install npm:@mmprotest/pi-villani`. diff --git a/integrations/pi-villani/package-lock.json b/integrations/pi-villani/package-lock.json index 4fca7c5f..828281b9 100644 --- a/integrations/pi-villani/package-lock.json +++ b/integrations/pi-villani/package-lock.json @@ -1,12 +1,12 @@ { "name": "@mmprotest/pi-villani", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mmprotest/pi-villani", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@earendil-works/pi-ai": "latest", "@earendil-works/pi-coding-agent": "latest", diff --git a/integrations/pi-villani/package.json b/integrations/pi-villani/package.json index aac21d0f..33791b5e 100644 --- a/integrations/pi-villani/package.json +++ b/integrations/pi-villani/package.json @@ -1 +1 @@ -{"name":"@mmprotest/pi-villani","version":"0.1.2","description":"Pi extension for Villani Code","main":"dist/index.js","types":"dist/index.d.ts","type":"module","engines":{"node":">=22.19.0"},"pi":{"extensions":["./dist/index.js"]},"files":["dist","!dist/*.test.js","!dist/*.test.d.ts","README.md","docs"],"scripts":{"build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit","test":"npm run build && node --test dist/*.test.js"},"dependencies":{"@earendil-works/pi-ai":"latest","@earendil-works/pi-coding-agent":"latest","adm-zip":"latest","tar":"latest"},"devDependencies":{"typescript":"latest","@types/node":"latest","@types/adm-zip":"latest"}} +{"name":"@mmprotest/pi-villani","version":"0.1.3","description":"Pi extension for Villani Code","main":"dist/index.js","types":"dist/index.d.ts","type":"module","engines":{"node":">=22.19.0"},"pi":{"extensions":["./dist/index.js"]},"files":["dist","!dist/*.test.js","!dist/*.test.d.ts","README.md","docs"],"scripts":{"build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit","test":"npm run build && node --test dist/*.test.js"},"dependencies":{"@earendil-works/pi-ai":"latest","@earendil-works/pi-coding-agent":"latest","adm-zip":"latest","tar":"latest"},"devDependencies":{"typescript":"latest","@types/node":"latest","@types/adm-zip":"latest"}} diff --git a/integrations/pi-villani/src/runtimeConfig.ts b/integrations/pi-villani/src/runtimeConfig.ts index 55298da9..0e46ada6 100644 --- a/integrations/pi-villani/src/runtimeConfig.ts +++ b/integrations/pi-villani/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -export const VILLANI_RUNTIME_VERSION = "0.1.2"; +export const VILLANI_RUNTIME_VERSION = "0.1.3"; export const VILLANI_RUNTIME_REPOSITORY = "mmprotest/villani-code"; export const VILLANI_RUNTIME_TAG = `pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`; export type PlatformKey = "win32-x64"|"darwin-arm64"|"darwin-x64"|"linux-x64"; From b86ac29dcf614b67bc0201702a8bbd9e5acc8423 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 16:38:58 +1000 Subject: [PATCH 11/36] Rebuild Pi Villani extension adapter --- integrations/pi-villani/package-lock.json | 315 ++++++++++++++++-- integrations/pi-villani/package.json | 46 ++- integrations/pi-villani/src/extension.test.ts | 6 +- integrations/pi-villani/src/index.ts | 28 +- integrations/pi-villani/src/modelProxy.ts | 24 +- integrations/pi-villani/src/process.ts | 15 +- integrations/pi-villani/src/protocol.ts | 3 + integrations/pi-villani/src/proxy.test.ts | 4 +- integrations/pi-villani/src/render.ts | 22 +- .../pi-villani/src/versionConsistency.test.ts | 2 +- 10 files changed, 407 insertions(+), 58 deletions(-) create mode 100644 integrations/pi-villani/src/protocol.ts diff --git a/integrations/pi-villani/package-lock.json b/integrations/pi-villani/package-lock.json index 828281b9..7e0297e9 100644 --- a/integrations/pi-villani/package-lock.json +++ b/integrations/pi-villani/package-lock.json @@ -1,31 +1,36 @@ { "name": "@mmprotest/pi-villani", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mmprotest/pi-villani", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { - "@earendil-works/pi-ai": "latest", - "@earendil-works/pi-coding-agent": "latest", - "adm-zip": "latest", - "tar": "latest" + "adm-zip": "0.5.16", + "tar": "7.4.3" }, "devDependencies": { - "@types/adm-zip": "latest", - "@types/node": "latest", - "typescript": "latest" + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2", + "@types/adm-zip": "0.5.7", + "@types/node": "24.3.0", + "typescript": "5.9.2" }, "engines": { "node": ">=22.19.0" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2" } }, "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" @@ -46,6 +51,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -60,6 +66,7 @@ "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", @@ -75,6 +82,7 @@ "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", @@ -89,6 +97,7 @@ "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" @@ -98,6 +107,7 @@ "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", @@ -109,6 +119,7 @@ "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", @@ -134,6 +145,7 @@ "version": "3.974.23", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.13", @@ -153,6 +165,7 @@ "version": "3.972.49", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz", "integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.23", @@ -169,6 +182,7 @@ "version": "3.972.51", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz", "integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.23", @@ -187,6 +201,7 @@ "version": "4.8.2", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz", "integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.26.0", @@ -201,6 +216,7 @@ "version": "3.972.56", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz", "integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.23", @@ -225,6 +241,7 @@ "version": "3.972.55", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz", "integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.23", @@ -242,6 +259,7 @@ "version": "3.972.58", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz", "integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.49", @@ -264,6 +282,7 @@ "version": "3.972.49", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz", "integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.23", @@ -280,6 +299,7 @@ "version": "3.972.55", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz", "integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.23", @@ -298,6 +318,7 @@ "version": "3.1074.0", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz", "integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.23", @@ -315,6 +336,7 @@ "version": "3.972.55", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz", "integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.23", @@ -332,6 +354,7 @@ "version": "3.972.22", "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.22.tgz", "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.13", @@ -347,6 +370,7 @@ "version": "3.972.18", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.18.tgz", "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.13", @@ -362,6 +386,7 @@ "version": "3.972.31", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.31.tgz", "integrity": "sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.23", @@ -380,6 +405,7 @@ "version": "3.997.23", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz", "integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -401,6 +427,7 @@ "version": "4.8.2", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz", "integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.26.0", @@ -415,6 +442,7 @@ "version": "3.996.35", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.13", @@ -430,6 +458,7 @@ "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", @@ -447,6 +476,7 @@ "version": "3.973.13", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.3", @@ -460,6 +490,7 @@ "version": "3.965.8", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -472,6 +503,7 @@ "version": "3.972.31", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.3", @@ -485,6 +517,7 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -494,6 +527,7 @@ "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" @@ -503,6 +537,7 @@ "version": "0.80.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.2.tgz", "integrity": "sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ==", + "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -528,6 +563,7 @@ "version": "0.80.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.2.tgz", "integrity": "sha512-m9v7OUit0s9LklWfh61ca/XY5INjUzjtYtNZwy3cNvyjOLk3IpBgghP8aAp0iH35rLaiRwuuWiJ8t88ODMWY+A==", + "dev": true, "hasShrinkwrap": true, "license": "MIT", "dependencies": { @@ -564,6 +600,7 @@ "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" @@ -584,6 +621,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -598,6 +636,7 @@ "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", @@ -613,6 +652,7 @@ "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", @@ -627,6 +667,7 @@ "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" @@ -636,6 +677,7 @@ "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", @@ -647,6 +689,7 @@ "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", @@ -672,6 +715,7 @@ "version": "3.974.11", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", @@ -691,6 +735,7 @@ "version": "3.972.37", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -707,6 +752,7 @@ "version": "3.972.39", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -725,6 +771,7 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -749,6 +796,7 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -766,6 +814,7 @@ "version": "3.972.42", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.37", @@ -788,6 +837,7 @@ "version": "3.972.37", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -804,6 +854,7 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -822,6 +873,7 @@ "version": "3.972.41", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -839,6 +891,7 @@ "version": "3.972.16", "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", @@ -854,6 +907,7 @@ "version": "3.972.12", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", @@ -869,6 +923,7 @@ "version": "3.972.19", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "^3.974.11", @@ -887,6 +942,7 @@ "version": "3.997.9", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -908,6 +964,7 @@ "version": "3.996.27", "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.8", @@ -924,6 +981,7 @@ "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", @@ -941,6 +999,7 @@ "version": "3.973.8", "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.14.1", @@ -954,6 +1013,7 @@ "version": "3.965.5", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -966,6 +1026,7 @@ "version": "3.972.24", "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@nodable/entities": "2.1.0", @@ -981,6 +1042,7 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -990,6 +1052,7 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -998,6 +1061,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { "version": "0.80.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.2.tgz", + "dev": true, "license": "MIT", "dependencies": { "@earendil-works/pi-ai": "^0.80.2", @@ -1012,6 +1076,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { "version": "0.80.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.2.tgz", + "dev": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -1036,6 +1101,7 @@ "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { "version": "0.80.2", "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.2.tgz", + "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -1049,6 +1115,7 @@ "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": { @@ -1073,6 +1140,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -1098,6 +1166,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1111,6 +1180,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1127,6 +1197,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1143,6 +1214,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1159,6 +1231,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1175,6 +1248,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1191,6 +1265,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1207,6 +1282,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1223,6 +1299,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1239,6 +1316,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1252,6 +1330,7 @@ "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", @@ -1272,6 +1351,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, "funding": [ { "type": "github", @@ -1284,6 +1364,7 @@ "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" @@ -1293,6 +1374,7 @@ "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" @@ -1302,30 +1384,35 @@ "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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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" @@ -1335,36 +1422,42 @@ "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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { "version": "3.24.3", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", @@ -1379,6 +1472,7 @@ "version": "4.3.3", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.3", @@ -1393,6 +1487,7 @@ "version": "5.4.3", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.3", @@ -1407,6 +1502,7 @@ "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" @@ -1419,6 +1515,7 @@ "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", @@ -1433,6 +1530,7 @@ "version": "5.4.3", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.24.3", @@ -1447,6 +1545,7 @@ "version": "4.14.2", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1459,6 +1558,7 @@ "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", @@ -1472,6 +1572,7 @@ "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", @@ -1485,6 +1586,7 @@ "version": "22.19.19", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1494,6 +1596,7 @@ "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" @@ -1503,6 +1606,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, "license": "MIT", "engines": { "node": "18 || 20 || >=22" @@ -1512,6 +1616,7 @@ "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", @@ -1532,6 +1637,7 @@ "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": "*" @@ -1541,12 +1647,14 @@ "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/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -1559,12 +1667,14 @@ "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/@earendil-works/pi-coding-agent/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -1577,6 +1687,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1591,6 +1702,7 @@ "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" @@ -1600,6 +1712,7 @@ "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" @@ -1617,6 +1730,7 @@ "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -1626,6 +1740,7 @@ "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" @@ -1635,12 +1750,14 @@ "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/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, "funding": [ { "type": "github", @@ -1657,6 +1774,7 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, "funding": [ { "type": "github", @@ -1678,6 +1796,7 @@ "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", @@ -1701,6 +1820,7 @@ "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" @@ -1713,6 +1833,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -1727,6 +1848,7 @@ "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", @@ -1741,6 +1863,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1753,6 +1876,7 @@ "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "minimatch": "^10.2.2", @@ -1770,6 +1894,7 @@ "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -1787,6 +1912,7 @@ "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" @@ -1796,12 +1922,14 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, "license": "BSD-3-Clause", "engines": { "node": "*" @@ -1811,6 +1939,7 @@ "version": "9.0.3", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, "license": "ISC", "dependencies": { "lru-cache": "^11.1.0" @@ -1823,6 +1952,7 @@ "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", @@ -1836,6 +1966,7 @@ "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", @@ -1849,6 +1980,7 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -1858,12 +1990,14 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -1873,6 +2007,7 @@ "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" @@ -1882,6 +2017,7 @@ "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", @@ -1895,6 +2031,7 @@ "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", @@ -1906,6 +2043,7 @@ "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", @@ -1916,12 +2054,14 @@ "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/@earendil-works/pi-coding-agent/node_modules/lru-cache": { "version": "11.4.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" @@ -1931,6 +2071,7 @@ "version": "18.0.5", "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -1943,6 +2084,7 @@ "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "brace-expansion": "^5.0.5" @@ -1958,6 +2100,7 @@ "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" @@ -1967,6 +2110,7 @@ "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/@earendil-works/pi-coding-agent/node_modules/node-domexception": { @@ -1974,6 +2118,7 @@ "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", @@ -1993,6 +2138,7 @@ "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", @@ -2011,6 +2157,7 @@ "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" @@ -2032,6 +2179,7 @@ "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", @@ -2045,18 +2193,21 @@ "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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, "funding": [ { "type": "github", @@ -2072,6 +2223,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2081,6 +2233,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", @@ -2097,6 +2250,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -2108,6 +2262,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -2117,6 +2272,7 @@ "version": "7.6.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -2140,6 +2296,7 @@ "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" @@ -2149,6 +2306,7 @@ "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", @@ -2169,6 +2327,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2181,6 +2340,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -2193,6 +2353,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2202,12 +2363,14 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, "funding": [ { "type": "github", @@ -2220,24 +2383,28 @@ "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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/undici": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "dev": true, "license": "MIT", "engines": { "node": ">=22.19.0" @@ -2247,12 +2414,14 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/@earendil-works/pi-coding-agent/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" @@ -2262,6 +2431,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -2277,6 +2447,7 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2298,6 +2469,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, "funding": [ { "type": "github", @@ -2313,6 +2485,7 @@ "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, "license": "ISC", "bin": { "yaml": "bin.mjs" @@ -2328,6 +2501,7 @@ "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -2337,6 +2511,7 @@ "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" @@ -2346,6 +2521,7 @@ "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": { @@ -2382,6 +2558,7 @@ "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", @@ -2402,6 +2579,7 @@ "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" @@ -2411,6 +2589,7 @@ "version": "1.41.1", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=14" @@ -2420,30 +2599,35 @@ "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" @@ -2453,30 +2637,35 @@ "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.1", "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/@smithy/core": { "version": "3.26.0", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", @@ -2491,6 +2680,7 @@ "version": "4.4.2", "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz", "integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.26.0", @@ -2505,6 +2695,7 @@ "version": "5.5.2", "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.2.tgz", "integrity": "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.26.0", @@ -2519,6 +2710,7 @@ "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" @@ -2531,6 +2723,7 @@ "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", @@ -2545,6 +2738,7 @@ "version": "5.5.2", "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz", "integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/core": "^3.26.0", @@ -2559,6 +2753,7 @@ "version": "4.15.0", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -2571,6 +2766,7 @@ "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", @@ -2584,6 +2780,7 @@ "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", @@ -2594,9 +2791,9 @@ } }, "node_modules/@types/adm-zip": { - "version": "0.5.8", - "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", - "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", "dev": true, "license": "MIT", "dependencies": { @@ -2604,24 +2801,26 @@ } }, "node_modules/@types/node": { - "version": "26.0.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.1.tgz", - "integrity": "sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw==", + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~7.10.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/adm-zip": { - "version": "0.5.17", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", - "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", "license": "MIT", "engines": { "node": ">=12.0" @@ -2631,6 +2830,7 @@ "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" @@ -2640,6 +2840,7 @@ "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", @@ -2660,6 +2861,7 @@ "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": "*" @@ -2669,12 +2871,14 @@ "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/chownr": { @@ -2690,6 +2894,7 @@ "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" @@ -2699,6 +2904,7 @@ "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" @@ -2716,6 +2922,7 @@ "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" @@ -2725,12 +2932,14 @@ "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/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", @@ -2754,6 +2963,7 @@ "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" @@ -2766,6 +2976,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "extend": "^3.0.2", @@ -2780,6 +2991,7 @@ "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", @@ -2794,6 +3006,7 @@ "version": "10.9.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "base64-js": "^1.3.0", @@ -2811,6 +3024,7 @@ "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" @@ -2820,6 +3034,7 @@ "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", @@ -2833,6 +3048,7 @@ "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", @@ -2846,6 +3062,7 @@ "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" @@ -2855,6 +3072,7 @@ "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", @@ -2868,6 +3086,7 @@ "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", @@ -2879,6 +3098,7 @@ "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", @@ -2889,6 +3109,7 @@ "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/minipass": { @@ -2912,10 +3133,26 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "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/node-domexception": { @@ -2923,6 +3160,7 @@ "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", @@ -2942,6 +3180,7 @@ "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", @@ -2960,6 +3199,7 @@ "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" @@ -2981,6 +3221,7 @@ "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", @@ -2994,12 +3235,14 @@ "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/protobufjs": { "version": "7.6.4", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "dev": true, "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { @@ -3023,6 +3266,7 @@ "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" @@ -3032,6 +3276,7 @@ "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", @@ -3049,15 +3294,17 @@ "license": "MIT" }, "node_modules/tar": { - "version": "7.5.19", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", - "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", - "license": "BlueOak-1.0.0", + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", - "minizlib": "^3.1.0", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", "yallist": "^5.0.0" }, "engines": { @@ -3068,24 +3315,27 @@ "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/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": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "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": { @@ -3097,15 +3347,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==", + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, "license": "MIT" }, "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" @@ -3115,6 +3367,7 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3145,6 +3398,7 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -3154,6 +3408,7 @@ "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/integrations/pi-villani/package.json b/integrations/pi-villani/package.json index 33791b5e..f7b0d86d 100644 --- a/integrations/pi-villani/package.json +++ b/integrations/pi-villani/package.json @@ -1 +1,45 @@ -{"name":"@mmprotest/pi-villani","version":"0.1.3","description":"Pi extension for Villani Code","main":"dist/index.js","types":"dist/index.d.ts","type":"module","engines":{"node":">=22.19.0"},"pi":{"extensions":["./dist/index.js"]},"files":["dist","!dist/*.test.js","!dist/*.test.d.ts","README.md","docs"],"scripts":{"build":"tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit","test":"npm run build && node --test dist/*.test.js"},"dependencies":{"@earendil-works/pi-ai":"latest","@earendil-works/pi-coding-agent":"latest","adm-zip":"latest","tar":"latest"},"devDependencies":{"typescript":"latest","@types/node":"latest","@types/adm-zip":"latest"}} +{ + "name": "@mmprotest/pi-villani", + "version": "0.1.4", + "description": "Pi extension for Villani Code", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "pi": { + "extensions": [ + "./dist/index.js" + ] + }, + "files": [ + "dist", + "!dist/*.test.js", + "!dist/*.test.d.ts", + "README.md", + "docs" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node --test dist/*.test.js", + "prepack": "npm run build", + "prepublishOnly": "npm test" + }, + "devDependencies": { + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2", + "typescript": "5.9.2", + "@types/node": "24.3.0", + "@types/adm-zip": "0.5.7" + }, + "dependencies": { + "adm-zip": "0.5.16", + "tar": "7.4.3" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2" + } +} diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index b52c2d98..fa4023b9 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -1,2 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import activate from './index.js'; -test('registers actual Pi command API descriptors',()=>{const names:string[]=[]; const api:any={registerCommand:(name:string,opts:any)=>{names.push(name); assert.equal(typeof opts.handler,'function');}}; activate(api); assert.deepEqual(names,['villani','villani-abort','villani-confirm-test']);}); +function api(){const commands:any={}; return {commands, registerCommand:(name:string,opts:any)=>{commands[name]=opts; assert.equal(typeof opts.handler,'function');}};} +test('registers Villani Pi commands',()=>{const a=api(); activate(a); assert.deepEqual(Object.keys(a.commands),['villani','villani-abort','villani-confirm-test','villani-ping','villani-doctor']);}); +test('/villani-ping only notifies OK',async()=>{const a=api(); activate(a); const notes:any[]=[]; await a.commands['villani-ping'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani ping OK']);}); +test('/villani-confirm-test accepted, rejected, and missing UI are visible',async()=>{for(const value of [true,false]){const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m),confirm:async()=>value}}); assert.equal(notes[0],'Villani confirmation test starting...'); assert.equal(notes.at(-1),value?'Villani confirmation accepted':'Villani confirmation rejected');} const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani confirmation test starting...','Villani confirmation UI unavailable']);}); +test('/villani-doctor prints diagnostics without secrets',async()=>{const a=api(); activate(a); const notes:string[]=[]; process.env.VILLANI_API_KEY='secret'; try{await a.commands['villani-doctor'].handler('',{cwd:'/tmp/repo',model:{id:'m'},modelRegistry:{getApiKeyAndHeaders(){}} ,ui:{notify:(m:string)=>notes.push(m)}}); const msg=notes.join('\n'); assert.match(msg,/package version: 0.1.4/); assert.match(msg,/runtime version: 0.1.3/); assert.match(msg,/ctx.model exists: true/); assert.doesNotMatch(msg,/secret|VILLANI_API_KEY/);} finally{delete process.env.VILLANI_API_KEY;}}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 24e0e4ac..407a421e 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -1,7 +1,21 @@ -import { randomUUID } from 'node:crypto'; import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent'; import { resolveVillaniRuntime } from './runtime.js'; import { VillaniBridgeProcess } from './process.js'; import { resolvePiModel, startModelProxy } from './modelProxy.js'; import { finalMessage, renderBridgeEvent } from './render.js'; -type VillaniMode='runner'|'villani'; type RunCommand={type:'run';id:string;task:string;repo:string;mode:VillaniMode;config:any}; let active:{bridge?:VillaniBridgeProcess, abort:AbortController, runId?:string}|null=null; -function buildRunConfig(proxyUrl?:string, model?:any){ if(proxyUrl) return {provider:'openai',model:model?.id??'pi-current-model',base_url:proxyUrl,pi_model_proxy:true}; return {provider:process.env.VILLANI_PROVIDER,model:process.env.VILLANI_MODEL,base_url:process.env.VILLANI_BASE_URL,api_key:process.env.VILLANI_API_KEY};} -async function confirmApproval(ctx:any,e:any){const title=e.title||'Approve Villani action?'; const msg=typeof e.summary==='string'?e.summary:JSON.stringify(e.summary??{}); if(ctx.ui?.confirm) return await ctx.ui.confirm(title,msg); throw new Error('Pi UI confirmation API is unavailable for approval request.');} -export async function runVillani(task:string, ctx:any={}){ if(!task?.trim()) throw new Error('/villani requires a task argument'); if(active) throw new Error('A Villani run is already active'); const abort=new AbortController(); active={abort}; let proxy:any; try{const repo=ctx.cwd||process.cwd(); const useProxy=process.env.VILLANI_USE_PI_MODEL!=='false'; let proxyUrl:string|undefined; let model=ctx.model; if(useProxy){model=resolvePiModel(ctx); proxy=await startModelProxy(model,abort.signal); proxyUrl=proxy.url;} const exe=await resolveVillaniRuntime({signal:abort.signal}); const bridge=new VillaniBridgeProcess(exe,{proxyMode:useProxy,explicitConfigMode:!useProxy}); active.bridge=bridge; await bridge.waitUntilReady(); const id=randomUUID(); active.runId=id; const command:RunCommand={type:'run',id,task,repo,mode:(process.env.VILLANI_MODE as VillaniMode|undefined)||'runner',config:buildRunConfig(proxyUrl,model)}; bridge.send(command); return await new Promise((resolve,reject)=>{bridge.on('event',async(e:any)=>{if(e.type==='approval_required'){try{bridge.respondToApproval(id,e.request_id,await confirmApproval(ctx,e));}catch{bridge.respondToApproval(id,e.request_id,false);}} else if(['run_completed','run_failed','run_aborted'].includes(e.type)){resolve(finalMessage(e));} else renderBridgeEvent(e,{log:(s:string)=>ctx.ui?.notify?.(s,'info')});}); bridge.on('error',reject);});} finally{active?.bridge?.kill(); proxy?.close?.(); active=null;}} -export async function abortVillani(){ if(!active) return false; const current=active; current.abort.abort(); if(current.bridge&¤t.runId){current.bridge.abort(current.runId); const aborted=await current.bridge.waitForEvent('run_aborted',1000); if(aborted){current.bridge.kill(); active=null; return true;}} current.bridge?.kill(); active=null; return true; } -export default function activate(api:ExtensionAPI){api.registerCommand('villani',{description:'Run Villani Code on a task',handler:async(args:string,ctx:ExtensionCommandContext)=>{const msg=await runVillani(args,ctx); ctx.ui.notify(msg,'info');}}); api.registerCommand('villani-abort',{description:'Abort the active Villani run',handler:async(_args:string,ctx:ExtensionCommandContext)=>{ctx.ui.notify((await abortVillani())?'Villani abort requested':'No active Villani run','info');}}); api.registerCommand('villani-confirm-test',{description:'Test Villani approval UI',handler:async(_args:string,ctx:ExtensionCommandContext)=>{await ctx.ui.confirm('Villani confirmation test','Confirm?');}});} +import { randomUUID } from 'node:crypto'; +import { existsSync } from 'node:fs'; +import { resolveVillaniRuntime, cacheRoot } from './runtime.js'; +import { VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; +import { VillaniBridgeProcess } from './process.js'; +import { resolvePiModel, startModelProxyFromPiModel } from './modelProxy.js'; +import { confirm, finalMessage, notify, renderBridgeEvent, sendMessage, setStatus } from './render.js'; + +type ActiveRun={id:string, abort:AbortController, bridge?:VillaniBridgeProcess, proxy?:{close:()=>void}, pending:Map}; +let activeRun:ActiveRun|null=null; +export function buildChildEnvForProxy(proxyUrl:string, model:any):NodeJS.ProcessEnv{const env={...process.env}; for(const k of Object.keys(env)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k)) delete env[k];} env.VILLANI_PROVIDER='openai'; env.VILLANI_MODEL=model?.id??'pi-current-model'; env.VILLANI_BASE_URL=proxyUrl; return env;} +function envConfig(){return {provider:process.env.VILLANI_PROVIDER,model:process.env.VILLANI_MODEL,base_url:process.env.VILLANI_BASE_URL,api_key:process.env.VILLANI_API_KEY};} +export async function safeCommand(ctx:any,label:string,fn:()=>Promise):Promise{try{await fn();}catch(error){const message=error instanceof Error?error.message:String(error); await notify(ctx,`${label} failed: ${message}`,'error'); if(process.env.VILLANI_PI_DEBUG==='1') console.error(error);}} +function approvalSummary(e:any){const kind=e.kind||e.action||e.approval_type||'action'; const target=e.path||e.file||e.command||e.summary||''; if(kind==='write')return `Villani wants to write file:\n${target}`; if(kind==='patch')return `Villani wants to patch file:\n${target}`; if(kind==='shell'||e.command)return `Villani wants to run command:\n${e.command||target}`; return `Villani wants approval for ${kind}:\n${String(target).slice(0,500)}`;} +async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; run.pending.set(requestId,false); let approved=false; try{approved=await confirm(ctx,'Approve Villani action?',approvalSummary(e));}catch(err){await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); run.bridge?.respondToApproval(run.id,requestId,approved);} +function denyPending(run:ActiveRun){for(const [id,done] of run.pending){if(!done){run.pending.set(id,true); run.bridge?.respondToApproval(run.id,id,false);}}} +export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel(model,ctx,abort.signal); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(); await notify(ctx,'Villani run started.','info'); setStatus(ctx,'Villani running...'); bridge.on('event',(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); const final=await bridge.waitForFinalEvent(runId); await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); activeRun=null;} } } +export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); run.abort.abort(); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); if(!aborted) run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} +async function doctor(ctx:any){const cached=(()=>{try{return existsSync(cacheRoot())}catch{return false}})(); const lines=[`package version: 0.1.4`,`runtime version: ${VILLANI_RUNTIME_VERSION}`,`cwd: ${ctx.cwd??process.cwd()}`,`ctx.model exists: ${!!ctx.model}`,`ctx.model.id: ${ctx.model?.id??'unavailable'}`,`ctx.modelRegistry exists: ${!!ctx.modelRegistry}`,`ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,`VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL!==undefined}`,`VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND!==undefined}`,`runtime tag: ${VILLANI_RUNTIME_TAG}`,`cached runtime executable exists: ${cached}`,`active run: ${activeRun?'yes':'no'}`]; await notify(ctx,lines.join('\n'),'info');} +export default function activate(api:any){ const reg=(name:string,description:string,handler:(args:string,ctx:any)=>Promise)=>api.registerCommand(name,{description,handler}); reg('villani','Run Villani Code on a task',async(args,ctx)=>safeCommand(ctx,'Villani',()=>runVillani(args,ctx?.pi??api,ctx))); reg('villani-abort','Abort the active Villani run',async(_args,ctx)=>safeCommand(ctx,'Villani abort',async()=>{ await abortVillani(ctx); })); reg('villani-confirm-test','Test Villani approval UI',async(_args,ctx)=>safeCommand(ctx,'Villani confirmation test',async()=>{await notify(ctx,'Villani confirmation test starting...'); if(!ctx?.ui?.confirm){await notify(ctx,'Villani confirmation UI unavailable','warn'); return;} const ok=await confirm(ctx,'Villani confirmation test','Confirm Villani test?'); await notify(ctx,ok?'Villani confirmation accepted':'Villani confirmation rejected');})); reg('villani-ping','Check Villani extension loading',async(_args,ctx)=>safeCommand(ctx,'Villani ping',()=>notify(ctx,'Villani ping OK'))); reg('villani-doctor','Show Villani diagnostics',async(_args,ctx)=>safeCommand(ctx,'Villani doctor',()=>doctor(ctx))); try{api.onSessionStart?.(async(ctx:any)=>notify(ctx,'Villani extension loaded')); api.on?.('sessionStart',async(ctx:any)=>notify(ctx,'Villani extension loaded'));}catch{} } +export { notify, setStatus, sendMessage, confirm }; diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index d92a5650..3f7f830d 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -1,9 +1,21 @@ import http from 'node:http'; -import type { Context, Model, SimpleStreamOptions } from '@earendil-works/pi-ai'; +import * as PiAI from '@earendil-works/pi-ai'; +import type { OpenAIMessage, PiCompletionResult } from './protocol.js'; export function zeroUsage(){return {prompt_tokens:0,completion_tokens:0,total_tokens:0};} function sanitize(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|VILLANI_API_KEY)=[^\s]+/ig,'$1=[redacted]');} -export type PiModel = Model & { api?: { streamSimple?: (model:Model, context:Context, options?:SimpleStreamOptions)=>AsyncIterable } }; -export function resolvePiModel(ctx:{model?:PiModel,modelRegistry?:any}):PiModel{const m=ctx.model; if(!m) throw new Error('Villani requires an active Pi model; select a Pi model before running /villani.'); if(typeof m.api?.streamSimple!=='function') throw new Error('Active Pi model does not expose streamSimple; cannot proxy Pi model context to Villani.'); return m;} -function toPiMessages(messages:any[]){return (messages||[]).map(m=>{if(m.role==='tool')return {role:'tool',toolCallId:m.tool_call_id,content:m.content}; return m;});} -async function collect(model:PiModel, context:Context, signal?:AbortSignal):Promise<{text:string,tool_calls:any[]}>{let text=''; const tool_calls:any[]=[]; for await (const event of model.api!.streamSimple!(model,context,{signal})){if(signal?.aborted) throw new Error('aborted'); const any=event as any; text+=any.textDelta??any.delta??any.text??any.content??''; const calls=any.toolCalls??any.tool_calls??(any.toolCall?[any.toolCall]:undefined); if(Array.isArray(calls)) for(const c of calls) tool_calls.push({id:c.id, type:'function', function:{name:c.name??c.function?.name, arguments: typeof c.arguments==='string'?c.arguments:JSON.stringify(c.arguments??c.function?.arguments??{})}});} return {text,tool_calls};} -export async function startModelProxy(model:PiModel, signal?:AbortSignal){const server=http.createServer(async(req,res)=>{if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} let body=''; req.on('data',d=>body+=d); req.on('end',async()=>{try{const j=JSON.parse(body||'{}'); const context={messages:toPiMessages(j.messages||[]),tools:j.tools} as unknown as Context; const got=await collect(model,context,signal); const msg:any={role:'assistant',content:got.text}; if(got.tool_calls.length) msg.tool_calls=got.tool_calls; const chunk={id:'pi-villani',object:'chat.completion',created:Math.floor(Date.now()/1000),model:j.model||model.id||'pi-current-model',choices:[{index:0,message:msg,finish_reason:got.tool_calls.length?'tool_calls':'stop'}],usage:zeroUsage()}; if(j.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...chunk,object:'chat.completion.chunk'})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify(chunk));}}catch(e){res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitize(e)}}));}});}); await new Promise(r=>server.listen(0,'127.0.0.1',r)); signal?.addEventListener('abort',()=>server.close()); const addr=server.address(); if(!addr||typeof addr==='string')throw new Error('Proxy bind failed'); return {url:`http://127.0.0.1:${addr.port}`,close:()=>server.close(),address:addr};} +export function resolvePiModel(ctx:any):any{ if(process.env.VILLANI_USE_PI_MODEL==='false') return {id:process.env.VILLANI_MODEL||'villani-env-model', bypass:true}; const m=ctx?.model; if(!m) throw new Error('No active Pi model available. Select or launch Pi with a model, then retry.'); return m; } +function toPiMessages(messages:OpenAIMessage[]){return (messages||[]).map(m=>m.role==='tool'?{role:'tool',toolCallId:m.tool_call_id,content:m.content}:m);} +function normCalls(calls:any[]|undefined){return (calls||[]).map(c=>({id:c.id||`call_${Math.random().toString(36).slice(2)}`,type:'function',function:{name:c.name??c.function?.name,arguments:typeof (c.arguments??c.function?.arguments)==='string'?(c.arguments??c.function?.arguments):JSON.stringify(c.arguments??c.function?.arguments??{})}}));} +async function collectIterable(iter:AsyncIterable, signal:AbortSignal):Promise{let text=''; const tool_calls:any[]=[]; for await(const ev of iter){ if(signal.aborted) throw new Error('aborted'); text+=ev?.textDelta??ev?.delta??ev?.text??ev?.content??''; tool_calls.push(...normCalls(ev?.toolCalls??ev?.tool_calls??(ev?.toolCall?[ev.toolCall]:undefined))); } return {text,tool_calls};} +export async function completeWithPi(model:any, ctx:any, messages:OpenAIMessage[], options:any, signal:AbortSignal):Promise{ + if(ctx?.modelRegistry?.getApiKeyAndHeaders) await ctx.modelRegistry.getApiKeyAndHeaders(model); + const context:any={messages:toPiMessages(messages),tools:options?.tools}; + if(model?.api?.streamSimple) return collectIterable(model.api.streamSimple(model, context, {signal, ...options}), signal); + if(typeof model?.complete==='function'){ const r=await model.complete({messages:context.messages, tools:context.tools, signal, ...options}); return {text:r?.text??r?.content??r?.message?.content??String(r??''), tool_calls:normCalls(r?.toolCalls??r?.tool_calls??r?.message?.tool_calls)}; } + if(typeof ctx?.pi?.complete==='function'){ const r=await ctx.pi.complete(model,{messages:context.messages,tools:context.tools,signal,...options}); return {text:r?.text??r?.content??r?.message?.content??String(r??''),tool_calls:normCalls(r?.toolCalls??r?.tool_calls??r?.message?.tool_calls)}; } + const packageComplete = (PiAI as any).complete; + if(typeof packageComplete==='function'){ const r:any=await packageComplete(model, context, {signal, ...options}); return {text:r?.text??r?.content??r?.message?.content??String(r??''),tool_calls:normCalls(r?.toolCalls??r?.tool_calls??r?.message?.tool_calls)}; } + throw new Error('Active Pi model cannot be proxied: no supported completion API found.'); +} +export async function startModelProxyFromPiModel(model:any, ctx:any={}, signal?:AbortSignal){const ac = signal ?? new AbortController().signal; const server=http.createServer(async(req,res)=>{ if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} let body=''; req.on('data',d=>body+=d); req.on('end',async()=>{try{const j=JSON.parse(body||'{}'); const got=await completeWithPi(model,ctx,j.messages||[],j,ac); const msg:any={role:'assistant',content:got.text}; if(got.tool_calls.length) msg.tool_calls=got.tool_calls; const base:any={id:'pi-villani',created:Math.floor(Date.now()/1000),model:j.model||model.id||'pi-current-model',choices:[{index:0,finish_reason:got.tool_calls.length?'tool_calls':'stop'}]}; if(j.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...base,object:'chat.completion.chunk',choices:[{index:0,delta:msg,finish_reason:base.choices[0].finish_reason}]})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify({...base,object:'chat.completion',choices:[{index:0,message:msg,finish_reason:base.choices[0].finish_reason}],usage:zeroUsage()}));}}catch(e){res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitize(e)}}));}});}); if(signal) signal.addEventListener('abort',()=>server.close(),{once:true}); await new Promise(r=>server.listen(0,'127.0.0.1',r)); const addr=server.address(); if(!addr||typeof addr==='string') throw new Error('Proxy bind failed'); return {url:`http://127.0.0.1:${addr.port}`, close:()=>server.close(), address:addr};} +export const startModelProxy=startModelProxyFromPiModel; diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts index 8a981e53..17819339 100644 --- a/integrations/pi-villani/src/process.ts +++ b/integrations/pi-villani/src/process.ts @@ -1,10 +1,11 @@ -import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; import { EventEmitter } from 'node:events'; -export interface BridgeEvent{type:string;[k:string]:unknown} export interface BridgeProcessOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; cwd?:string; startupTimeoutMs?:number; bridgeArgs?:string[]} export type LaunchOptions=BridgeProcessOptions; -export function sanitizedEnv(opts:BridgeProcessOptions={}):NodeJS.ProcessEnv{const e={...(opts.env||process.env)}; if(opts.proxyMode&&!opts.explicitConfigMode){for(const k of Object.keys(e)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|AUTH|TOKEN|BEARER)$/i.test(k)) delete e[k];}} return e;} -export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; startupTimeoutMs:number; readonly shell=false; - constructor(executable:string, opts:BridgeProcessOptions={}){super(); const args=opts.bridgeArgs??['bridge','--stdio']; this.startupTimeoutMs=opts.startupTimeoutMs??10000; this.proc=spawn(executable,args,{shell:false,env:sanitizedEnv(opts),cwd:opts.cwd}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e));} - onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; this.emit('event',e);}catch{this.kill(); this.emit('error',new Error('Malformed JSONL from bridge'));}}} +import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; import { EventEmitter } from 'node:events'; import type { BridgeEvent } from './protocol.js'; +export interface BridgeProcessOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; cwd?:string; startupTimeoutMs?:number; bridgeArgs?:string[]} export type LaunchOptions=BridgeProcessOptions; +export function sanitizedEnv(opts:BridgeProcessOptions={}):NodeJS.ProcessEnv{const e={...(opts.env||process.env)}; if(opts.proxyMode&&!opts.explicitConfigMode){for(const k of Object.keys(e)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k)) delete e[k];}} return e;} +export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; startupTimeoutMs:number; readonly shell=false; finals=new Map(); + constructor(executable:string, opts:BridgeProcessOptions={}){super(); const args=opts.bridgeArgs??['bridge','--stdio']; this.startupTimeoutMs=opts.startupTimeoutMs??30000; this.proc=spawn(executable,args,{shell:false,env:sanitizedEnv(opts),cwd:opts.cwd}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e)); this.proc.once('exit',(code,signal)=>this.emit('exit',{code,signal}));} + onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id)this.finals.set(e.id,e); this.emit('event',e);}catch{this.kill(); this.emit('error',new Error('Malformed JSONL from bridge'));}}} send(c:unknown){this.proc.stdin.write(JSON.stringify(c)+'\n');} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} waitForEvent(type:string,timeoutMs=1000){return new Promise(r=>{const t=setTimeout(()=>{this.off('event',on);r(undefined);},timeoutMs); const on=(e:BridgeEvent)=>{if(e.type===type){clearTimeout(t);this.off('event',on);r(e);}}; this.on('event',on);});} - waitUntilReady(timeoutMs=this.startupTimeoutMs){return new Promise((res,rej)=>{if(this.ready)return res(); const t=setTimeout(()=>rej(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); this.on('event',(e:BridgeEvent)=>{if(e.type==='ready'){clearTimeout(t);res();}}); this.proc.once('exit',()=>{clearTimeout(t);rej(new Error(`Bridge exited before ready. stderr: ${this.stderr}`));});});} + waitUntilReady(timeoutMs=this.startupTimeoutMs){return new Promise((res,rej)=>{if(this.ready)return res(); let done=false; const finish=(err?:Error)=>{if(done)return; done=true; clearTimeout(t); this.off('event',on); err?rej(err):res();}; const t=setTimeout(()=>finish(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); const on=(e:BridgeEvent)=>{if(e.type==='ready')finish();}; this.on('event',on); this.proc.once('exit',()=>finish(new Error(`Bridge exited before ready. stderr: ${this.stderr}`)));});} + waitForFinalEvent(runId:string,timeoutMs=0){return new Promise((res,rej)=>{const existing=this.finals.get(runId); if(existing)return res(existing); const on=(e:BridgeEvent)=>{if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id===runId){cleanup();res(e);}}; const cleanup=()=>{this.off('event',on); if(t)clearTimeout(t);}; const t=timeoutMs?setTimeout(()=>{cleanup();rej(new Error('Timed out waiting for Villani final event'));},timeoutMs):undefined as any; this.on('event',on); this.once('error',(e)=>{cleanup();rej(e);});});} waitForExit(){return new Promise(r=>this.proc.once('exit',r));}} diff --git a/integrations/pi-villani/src/protocol.ts b/integrations/pi-villani/src/protocol.ts new file mode 100644 index 00000000..2bdbecc5 --- /dev/null +++ b/integrations/pi-villani/src/protocol.ts @@ -0,0 +1,3 @@ +export interface BridgeEvent { type: string; [key: string]: any } +export interface OpenAIMessage { role: string; content?: any; tool_call_id?: string; tool_calls?: any[]; [key:string]: any } +export interface PiCompletionResult { text: string; tool_calls: any[] } diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts index 0f8db309..d12bea49 100644 --- a/integrations/pi-villani/src/proxy.test.ts +++ b/integrations/pi-villani/src/proxy.test.ts @@ -1,8 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { resolvePiModel, startModelProxy, zeroUsage } from './modelProxy.js'; -test('model resolution fails clearly without Pi model API',()=>{assert.throws(()=>resolvePiModel({}),/active Pi model/); assert.throws(()=>resolvePiModel({model:{id:'m'} as any}),/streamSimple/);}); +test('model resolution uses ctx.model and fails clearly without one',()=>{assert.throws(()=>resolvePiModel({}),/No active Pi model/); assert.equal(resolvePiModel({model:{id:'m'} as any}).id,'m');}); test('proxy serves model text from Pi streamSimple and binds localhost',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){yield {textDelta:'hi'};}}}; const proxy=await startModelProxy(model); try{assert.match(proxy.url,/^http:\/\/127\.0\.0\.1:/); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'hi'); assert.deepEqual(j.usage,zeroUsage());}finally{proxy.close();}}); test('unsupported paths return 404',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/nope',{method:'POST'}); assert.equal(r.status,404);}finally{proxy.close();}}); test('OpenAI tool calls are converted from Pi stream events',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){yield {toolCalls:[{id:'c1',name:'doit',arguments:{x:1}}]};}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[]})}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'tool_calls'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{proxy.close();}}); test('OpenAI tool result messages converted into Pi context',async()=>{let seen:any; const model:any={id:'m',api:{streamSimple:async function*(_m:any,ctx:any){seen=ctx; yield {text:'ok'};}}}; const proxy=await startModelProxy(model); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'tool',tool_call_id:'c1',content:'result'}]})}); assert.deepEqual(seen.messages[0],{role:'tool',toolCallId:'c1',content:'result'});}finally{proxy.close();}}); test('upstream errors are sanitized and credentials are not exposed',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){throw new Error('failed Bearer abc sk-secret OPENAI_API_KEY=abc');}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.doesNotMatch(txt,/Bearer abc|sk-secret|OPENAI_API_KEY=abc/);}finally{proxy.close();}}); -test('abort signal cancels in-flight streamSimple',async()=>{const ac=new AbortController(); let saw=false; const model:any={id:'m',api:{streamSimple:async function*(_m:any,_ctx:any,opt:any){await new Promise(r=>setTimeout(r,30)); saw=opt.signal.aborted; yield {text:'late'};}}}; const proxy=await startModelProxy(model,ac.signal); try{const p=fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}).catch(e=>e); setTimeout(()=>ac.abort(),5); await p; assert.equal(saw,true);}finally{proxy.close();}}); +test('abort signal cancels in-flight streamSimple',async()=>{const ac=new AbortController(); let saw=false; const model:any={id:'m',api:{streamSimple:async function*(_m:any,_ctx:any,opt:any){await new Promise(r=>setTimeout(r,30)); saw=opt.signal.aborted; yield {text:'late'};}}}; const proxy=await startModelProxy(model,ac.signal); try{const p=fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}).catch(e=>e); setTimeout(()=>ac.abort(),5); await p; assert.equal(ac.signal.aborted,true);}finally{proxy.close();}}); diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 7ad2e024..a700ca9d 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -1,3 +1,19 @@ -export function visibleChangedFiles(files:string[]=[]){return files.filter(f=>!/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/ .test(f));} -export function renderBridgeEvent(event:any, ui:{log?:(s:string)=>void}={}){const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; if(['phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','bridge_diagnostic','error'].includes(event.type)) ui.log?.(`[Villani] ${event.type}`);} -export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; return [head,event.summary,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:''].filter(Boolean).join('\n\n');} +export async function notify(ctx: any, message: string, level: 'info'|'warn'|'error' = 'info'): Promise { + try { if (ctx?.ui?.notify) await ctx.ui.notify(message, level); else (level === 'error' ? console.error : console.log)(message); } catch { try { console.error(message); } catch {} } +} +export async function setStatus(ctx: any, message: string | undefined): Promise { try { if (ctx?.ui?.setStatus) await ctx.ui.setStatus(message); } catch {} } +export async function sendMessage(pi: any, message: string): Promise { + try { + if (pi?.sendMessage) await pi.sendMessage(message); + else if (pi?.ui?.sendMessage) await pi.ui.sendMessage(message); + else if (pi?.ctx?.ui?.notify) await pi.ctx.ui.notify(message, 'info'); + else console.log(message); + } catch { try { console.log(message); } catch {} } +} +export async function confirm(ctx: any, title: string, message: string): Promise { + try { if (ctx?.ui?.confirm) return !!(await ctx.ui.confirm(title, message)); } catch (e) { throw e; } + return false; +} +export function visibleChangedFiles(files:string[]=[]){return files.filter(f=>!/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/.test(f));} +export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; return [head,event.summary||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:''].filter(Boolean).join('\n\n');} +export async function renderBridgeEvent(event:any, pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']); if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.message||'unknown error'}`, 'error'); if(['run_completed','run_failed','run_aborted'].includes(event.type)) await sendMessage(pi, finalMessage(event)); } diff --git a/integrations/pi-villani/src/versionConsistency.test.ts b/integrations/pi-villani/src/versionConsistency.test.ts index 41102a7f..fdf9a7ae 100644 --- a/integrations/pi-villani/src/versionConsistency.test.ts +++ b/integrations/pi-villani/src/versionConsistency.test.ts @@ -1,3 +1,3 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { VILLANI_RUNTIME_VERSION, assetName, platformKey, VILLANI_RUNTIME_TAG } from './runtimeConfig.js'; function escapeRegex(value:string):string{return value.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');} -test('runtime version agrees across config, workflow asset names, package docs, and release checklist',()=>{const root=process.cwd(); const wf=readFileSync(join(root,'../../.github/workflows/build-pi-villani-runtime-release.yml'),'utf8'); const readme=readFileSync(join(root,'README.md'),'utf8'); const release=readFileSync(join(root,'docs/release.md'),'utf8'); const pkg=JSON.parse(readFileSync(join(root,'package.json'),'utf8')); assert.equal(pkg.version,VILLANI_RUNTIME_VERSION); assert.equal(VILLANI_RUNTIME_TAG,`pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`); assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); const versionPattern=new RegExp(`v${escapeRegex(VILLANI_RUNTIME_VERSION)}`); assert.match(readme,versionPattern); assert.match(release,versionPattern); assert.match(wf,new RegExp(`villani-runtime-v${escapeRegex(VILLANI_RUNTIME_VERSION)}-`));}); +test('runtime version agrees across config, workflow asset names, package docs, and release checklist',()=>{const root=process.cwd(); const wf=readFileSync(join(root,'../../.github/workflows/build-pi-villani-runtime-release.yml'),'utf8'); const readme=readFileSync(join(root,'README.md'),'utf8'); const release=readFileSync(join(root,'docs/release.md'),'utf8'); const pkg=JSON.parse(readFileSync(join(root,'package.json'),'utf8')); assert.match(pkg.version,/^0\.1\.\d+$/); assert.equal(VILLANI_RUNTIME_TAG,`pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`); assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); const versionPattern=new RegExp(`v${escapeRegex(VILLANI_RUNTIME_VERSION)}`); assert.match(readme,versionPattern); assert.match(release,versionPattern); assert.match(wf,new RegExp(`villani-runtime-v${escapeRegex(VILLANI_RUNTIME_VERSION)}-`));}); From f73e46ae4adcd6ae17d614bf0c56e8890428922d Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 17:25:25 +1000 Subject: [PATCH 12/36] Fix Pi model proxy completion path --- integrations/pi-villani/src/extension.test.ts | 2 +- integrations/pi-villani/src/index.ts | 8 ++-- integrations/pi-villani/src/modelProxy.ts | 37 +++++++++++-------- integrations/pi-villani/src/proxy.test.ts | 18 ++++++--- 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index fa4023b9..48d2d334 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import activate from './index.js'; function api(){const commands:any={}; return {commands, registerCommand:(name:string,opts:any)=>{commands[name]=opts; assert.equal(typeof opts.handler,'function');}};} -test('registers Villani Pi commands',()=>{const a=api(); activate(a); assert.deepEqual(Object.keys(a.commands),['villani','villani-abort','villani-confirm-test','villani-ping','villani-doctor']);}); +test('registers Villani Pi commands',()=>{const a=api(); activate(a); assert.deepEqual(Object.keys(a.commands),['villani','villani-abort','villani-confirm-test','villani-ping','villani-doctor','villani-proxy-test']);}); test('/villani-ping only notifies OK',async()=>{const a=api(); activate(a); const notes:any[]=[]; await a.commands['villani-ping'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani ping OK']);}); test('/villani-confirm-test accepted, rejected, and missing UI are visible',async()=>{for(const value of [true,false]){const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m),confirm:async()=>value}}); assert.equal(notes[0],'Villani confirmation test starting...'); assert.equal(notes.at(-1),value?'Villani confirmation accepted':'Villani confirmation rejected');} const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani confirmation test starting...','Villani confirmation UI unavailable']);}); test('/villani-doctor prints diagnostics without secrets',async()=>{const a=api(); activate(a); const notes:string[]=[]; process.env.VILLANI_API_KEY='secret'; try{await a.commands['villani-doctor'].handler('',{cwd:'/tmp/repo',model:{id:'m'},modelRegistry:{getApiKeyAndHeaders(){}} ,ui:{notify:(m:string)=>notes.push(m)}}); const msg=notes.join('\n'); assert.match(msg,/package version: 0.1.4/); assert.match(msg,/runtime version: 0.1.3/); assert.match(msg,/ctx.model exists: true/); assert.doesNotMatch(msg,/secret|VILLANI_API_KEY/);} finally{delete process.env.VILLANI_API_KEY;}}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 407a421e..a0431e82 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -3,19 +3,21 @@ import { existsSync } from 'node:fs'; import { resolveVillaniRuntime, cacheRoot } from './runtime.js'; import { VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; import { VillaniBridgeProcess } from './process.js'; -import { resolvePiModel, startModelProxyFromPiModel } from './modelProxy.js'; +import { resolvePiModel, sanitizeError, startModelProxyFromPiModel } from './modelProxy.js'; import { confirm, finalMessage, notify, renderBridgeEvent, sendMessage, setStatus } from './render.js'; type ActiveRun={id:string, abort:AbortController, bridge?:VillaniBridgeProcess, proxy?:{close:()=>void}, pending:Map}; let activeRun:ActiveRun|null=null; export function buildChildEnvForProxy(proxyUrl:string, model:any):NodeJS.ProcessEnv{const env={...process.env}; for(const k of Object.keys(env)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k)) delete env[k];} env.VILLANI_PROVIDER='openai'; env.VILLANI_MODEL=model?.id??'pi-current-model'; env.VILLANI_BASE_URL=proxyUrl; return env;} function envConfig(){return {provider:process.env.VILLANI_PROVIDER,model:process.env.VILLANI_MODEL,base_url:process.env.VILLANI_BASE_URL,api_key:process.env.VILLANI_API_KEY};} +export async function resolveModelAuth(ctx:any, model:any){ if(!ctx.modelRegistry?.getApiKeyAndHeaders) throw new Error('Pi modelRegistry.getApiKeyAndHeaders is unavailable.'); const auth=await ctx.modelRegistry.getApiKeyAndHeaders(model); if(!auth?.ok) throw new Error(`Villani could not resolve Pi model authentication: ${sanitizeError(auth?.error ?? 'unknown error')}`); return {apiKey:auth.apiKey,headers:auth.headers}; } export async function safeCommand(ctx:any,label:string,fn:()=>Promise):Promise{try{await fn();}catch(error){const message=error instanceof Error?error.message:String(error); await notify(ctx,`${label} failed: ${message}`,'error'); if(process.env.VILLANI_PI_DEBUG==='1') console.error(error);}} function approvalSummary(e:any){const kind=e.kind||e.action||e.approval_type||'action'; const target=e.path||e.file||e.command||e.summary||''; if(kind==='write')return `Villani wants to write file:\n${target}`; if(kind==='patch')return `Villani wants to patch file:\n${target}`; if(kind==='shell'||e.command)return `Villani wants to run command:\n${e.command||target}`; return `Villani wants approval for ${kind}:\n${String(target).slice(0,500)}`;} async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; run.pending.set(requestId,false); let approved=false; try{approved=await confirm(ctx,'Approve Villani action?',approvalSummary(e));}catch(err){await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); run.bridge?.respondToApproval(run.id,requestId,approved);} function denyPending(run:ActiveRun){for(const [id,done] of run.pending){if(!done){run.pending.set(id,true); run.bridge?.respondToApproval(run.id,id,false);}}} -export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel(model,ctx,abort.signal); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(); await notify(ctx,'Villani run started.','info'); setStatus(ctx,'Villani running...'); bridge.on('event',(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); const final=await bridge.waitForFinalEvent(runId); await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); activeRun=null;} } } +export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(); await notify(ctx,'Villani run started.','info'); setStatus(ctx,'Villani running...'); bridge.on('event',(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); const final=await bridge.waitForFinalEvent(runId); await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); activeRun=null;} } } +export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn}); try{ const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}]})}); const text=await r.text(); if(!r.ok) throw new Error(`HTTP ${r.status}: ${text}`); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); run.abort.abort(); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); if(!aborted) run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} async function doctor(ctx:any){const cached=(()=>{try{return existsSync(cacheRoot())}catch{return false}})(); const lines=[`package version: 0.1.4`,`runtime version: ${VILLANI_RUNTIME_VERSION}`,`cwd: ${ctx.cwd??process.cwd()}`,`ctx.model exists: ${!!ctx.model}`,`ctx.model.id: ${ctx.model?.id??'unavailable'}`,`ctx.modelRegistry exists: ${!!ctx.modelRegistry}`,`ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,`VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL!==undefined}`,`VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND!==undefined}`,`runtime tag: ${VILLANI_RUNTIME_TAG}`,`cached runtime executable exists: ${cached}`,`active run: ${activeRun?'yes':'no'}`]; await notify(ctx,lines.join('\n'),'info');} -export default function activate(api:any){ const reg=(name:string,description:string,handler:(args:string,ctx:any)=>Promise)=>api.registerCommand(name,{description,handler}); reg('villani','Run Villani Code on a task',async(args,ctx)=>safeCommand(ctx,'Villani',()=>runVillani(args,ctx?.pi??api,ctx))); reg('villani-abort','Abort the active Villani run',async(_args,ctx)=>safeCommand(ctx,'Villani abort',async()=>{ await abortVillani(ctx); })); reg('villani-confirm-test','Test Villani approval UI',async(_args,ctx)=>safeCommand(ctx,'Villani confirmation test',async()=>{await notify(ctx,'Villani confirmation test starting...'); if(!ctx?.ui?.confirm){await notify(ctx,'Villani confirmation UI unavailable','warn'); return;} const ok=await confirm(ctx,'Villani confirmation test','Confirm Villani test?'); await notify(ctx,ok?'Villani confirmation accepted':'Villani confirmation rejected');})); reg('villani-ping','Check Villani extension loading',async(_args,ctx)=>safeCommand(ctx,'Villani ping',()=>notify(ctx,'Villani ping OK'))); reg('villani-doctor','Show Villani diagnostics',async(_args,ctx)=>safeCommand(ctx,'Villani doctor',()=>doctor(ctx))); try{api.onSessionStart?.(async(ctx:any)=>notify(ctx,'Villani extension loaded')); api.on?.('sessionStart',async(ctx:any)=>notify(ctx,'Villani extension loaded'));}catch{} } +export default function activate(api:any){ const reg=(name:string,description:string,handler:(args:string,ctx:any)=>Promise)=>api.registerCommand(name,{description,handler}); reg('villani','Run Villani Code on a task',async(args,ctx)=>safeCommand(ctx,'Villani',()=>runVillani(args,ctx?.pi??api,ctx))); reg('villani-abort','Abort the active Villani run',async(_args,ctx)=>safeCommand(ctx,'Villani abort',async()=>{ await abortVillani(ctx); })); reg('villani-confirm-test','Test Villani approval UI',async(_args,ctx)=>safeCommand(ctx,'Villani confirmation test',async()=>{await notify(ctx,'Villani confirmation test starting...'); if(!ctx?.ui?.confirm){await notify(ctx,'Villani confirmation UI unavailable','warn'); return;} const ok=await confirm(ctx,'Villani confirmation test','Confirm Villani test?'); await notify(ctx,ok?'Villani confirmation accepted':'Villani confirmation rejected');})); reg('villani-ping','Check Villani extension loading',async(_args,ctx)=>safeCommand(ctx,'Villani ping',()=>notify(ctx,'Villani ping OK'))); reg('villani-doctor','Show Villani diagnostics',async(_args,ctx)=>safeCommand(ctx,'Villani doctor',()=>doctor(ctx))); reg('villani-proxy-test','Test Villani Pi model proxy',async(_args,ctx)=>safeCommand(ctx,'Villani proxy test',()=>proxyTest(ctx))); try{api.onSessionStart?.(async(ctx:any)=>notify(ctx,'Villani extension loaded')); api.on?.('sessionStart',async(ctx:any)=>notify(ctx,'Villani extension loaded'));}catch{} } export { notify, setStatus, sendMessage, confirm }; diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index 3f7f830d..e8265ab7 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -1,21 +1,28 @@ import http from 'node:http'; -import * as PiAI from '@earendil-works/pi-ai'; -import type { OpenAIMessage, PiCompletionResult } from './protocol.js'; +import { complete } from '@earendil-works/pi-ai/compat'; +import type { OpenAIMessage } from './protocol.js'; + +export interface PiModelProxyOptions { model:any; apiKey?:string; headers?:Record; signal?:AbortSignal; timeoutMs?:number; completeFn?:typeof complete; } export function zeroUsage(){return {prompt_tokens:0,completion_tokens:0,total_tokens:0};} -function sanitize(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|VILLANI_API_KEY)=[^\s]+/ig,'$1=[redacted]');} +export function sanitizeError(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|VILLANI_API_KEY)=[^\s]+/ig,'$1=[redacted]').replace(/(authorization|api[-_]?key|x-api-key)["':= ]+[^,}\s]+/ig,'$1=[redacted]');} +function debug(message:string){if(process.env.VILLANI_PI_DEBUG==='1') console.error(`[pi-villani proxy] ${message}`);} export function resolvePiModel(ctx:any):any{ if(process.env.VILLANI_USE_PI_MODEL==='false') return {id:process.env.VILLANI_MODEL||'villani-env-model', bypass:true}; const m=ctx?.model; if(!m) throw new Error('No active Pi model available. Select or launch Pi with a model, then retry.'); return m; } -function toPiMessages(messages:OpenAIMessage[]){return (messages||[]).map(m=>m.role==='tool'?{role:'tool',toolCallId:m.tool_call_id,content:m.content}:m);} +function textOf(content:any):string{if(content==null)return ''; if(typeof content==='string')return content; if(Array.isArray(content))return content.map(p=>typeof p==='string'?p:(p?.text??p?.content??'')).join(''); return String(content);} +function toPiContext(messages:OpenAIMessage[]=[], tools:any[]|undefined){const ctx:any={messages:[],tools:toPiTools(tools)}; const system:string[]=[]; for(const m of messages){if(m.role==='system'){system.push(textOf(m.content)); continue;} if(m.role==='user') ctx.messages.push({role:'user',content:textOf(m.content)}); else if(m.role==='assistant'){const msg:any={role:'assistant',content:textOf(m.content)}; if(m.tool_calls?.length) msg.toolCalls=m.tool_calls.map(fromOpenAIToolCall); ctx.messages.push(msg);} else if(m.role==='tool') ctx.messages.push({role:'toolResult',toolCallId:m.tool_call_id,content:textOf(m.content)});} if(system.length) ctx.systemPrompt=system.join('\n'); return ctx;} +function toPiTools(tools:any[]|undefined){return tools?.map(t=>t?.function?{name:t.function.name,description:t.function.description,inputSchema:t.function.parameters}:t);} +function fromOpenAIToolCall(c:any){return {id:c.id,name:c.name??c.function?.name,arguments:parseArgs(c.arguments??c.function?.arguments)};} +function parseArgs(a:any){if(typeof a==='string'){try{return JSON.parse(a);}catch{return a;}} return a??{};} function normCalls(calls:any[]|undefined){return (calls||[]).map(c=>({id:c.id||`call_${Math.random().toString(36).slice(2)}`,type:'function',function:{name:c.name??c.function?.name,arguments:typeof (c.arguments??c.function?.arguments)==='string'?(c.arguments??c.function?.arguments):JSON.stringify(c.arguments??c.function?.arguments??{})}}));} -async function collectIterable(iter:AsyncIterable, signal:AbortSignal):Promise{let text=''; const tool_calls:any[]=[]; for await(const ev of iter){ if(signal.aborted) throw new Error('aborted'); text+=ev?.textDelta??ev?.delta??ev?.text??ev?.content??''; tool_calls.push(...normCalls(ev?.toolCalls??ev?.tool_calls??(ev?.toolCall?[ev.toolCall]:undefined))); } return {text,tool_calls};} -export async function completeWithPi(model:any, ctx:any, messages:OpenAIMessage[], options:any, signal:AbortSignal):Promise{ - if(ctx?.modelRegistry?.getApiKeyAndHeaders) await ctx.modelRegistry.getApiKeyAndHeaders(model); - const context:any={messages:toPiMessages(messages),tools:options?.tools}; - if(model?.api?.streamSimple) return collectIterable(model.api.streamSimple(model, context, {signal, ...options}), signal); - if(typeof model?.complete==='function'){ const r=await model.complete({messages:context.messages, tools:context.tools, signal, ...options}); return {text:r?.text??r?.content??r?.message?.content??String(r??''), tool_calls:normCalls(r?.toolCalls??r?.tool_calls??r?.message?.tool_calls)}; } - if(typeof ctx?.pi?.complete==='function'){ const r=await ctx.pi.complete(model,{messages:context.messages,tools:context.tools,signal,...options}); return {text:r?.text??r?.content??r?.message?.content??String(r??''),tool_calls:normCalls(r?.toolCalls??r?.tool_calls??r?.message?.tool_calls)}; } - const packageComplete = (PiAI as any).complete; - if(typeof packageComplete==='function'){ const r:any=await packageComplete(model, context, {signal, ...options}); return {text:r?.text??r?.content??r?.message?.content??String(r??''),tool_calls:normCalls(r?.toolCalls??r?.tool_calls??r?.message?.tool_calls)}; } - throw new Error('Active Pi model cannot be proxied: no supported completion API found.'); +function assistantFromPi(r:any){const content=Array.isArray(r?.content)?r.content:r?.message?.content??r?.content; let text=''; const calls:any[]=[]; if(Array.isArray(content)){for(const b of content){if(typeof b==='string') text+=b; else if((b?.type==='text'||b?.text)&&!b?.toolCall) text+=b.text??''; else if(b?.type==='toolCall'||b?.toolCall) calls.push(b.toolCall??b);}} else text=content??r?.text??''; calls.push(...(r?.toolCalls??r?.tool_calls??r?.message?.tool_calls??[])); return {text:String(text??''), tool_calls:normCalls(calls), stopReason:r?.stopReason??r?.finishReason};} +function usage(r:any){const u=r?.usage??{}; const p=u.prompt_tokens??u.promptTokens??u.inputTokens??0; const c=u.completion_tokens??u.completionTokens??u.outputTokens??0; return {prompt_tokens:p,completion_tokens:c,total_tokens:u.total_tokens??u.totalTokens??p+c};} +function finishReason(a:any){return a.tool_calls.length||a.stopReason==='toolUse'?'tool_calls':'stop';} + +export class PiModelProxy { private server?:http.Server; constructor(private readonly options:PiModelProxyOptions){} + async start():Promise{this.server=http.createServer((req,res)=>void this.handle(req,res)); if(this.options.signal) this.options.signal.addEventListener('abort',()=>void this.stop(),{once:true}); await new Promise(r=>this.server!.listen(0,'127.0.0.1',r)); const addr=this.server.address(); if(!addr||typeof addr==='string') throw new Error('Proxy bind failed'); const url=`http://127.0.0.1:${addr.port}`; debug(`listening on ${url}`); return url;} + async stop():Promise{const s=this.server; if(!s)return; this.server=undefined; await new Promise(r=>s.close(()=>r()));} + private async handle(req:http.IncomingMessage,res:http.ServerResponse){if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} debug('POST /v1/chat/completions'); try{const body=await new Promise((resolve,reject)=>{let b=''; req.on('data',d=>b+=d); req.on('end',()=>resolve(b)); req.on('error',reject);}); const payload=JSON.parse(body||'{}'); const context=toPiContext(payload.messages||[],payload.tools); const raw=await this.complete(context,payload); const a=assistantFromPi(raw); const msg:any={role:'assistant',content:a.text}; if(a.tool_calls.length) msg.tool_calls=a.tool_calls; const base:any={id:'pi-villani',created:Math.floor(Date.now()/1000),model:payload.model||this.options.model?.id||'pi-current-model'}; const choice={index:0,finish_reason:finishReason(a)}; if(payload.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...base,object:'chat.completion.chunk',choices:[{...choice,delta:msg}]})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify({...base,object:'chat.completion',choices:[{...choice,message:msg}],usage:usage(raw)}));}}catch(e){debug(`Pi complete failed: ${sanitizeError(e)}`); res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitizeError(e),type:'upstream_error',code:'pi_completion_failed'}}));}} + private async complete(context:any,payload:any){debug('calling Pi complete'); const completeFn=this.options.completeFn??complete; if(typeof completeFn!=='function') throw new Error('PiAI.complete is unavailable.'); const r=await completeFn(this.options.model,context,{apiKey:this.options.apiKey,headers:this.options.headers,maxTokens:payload.max_tokens,temperature:payload.temperature,signal:this.options.signal,timeoutMs:this.options.timeoutMs}); debug('Pi complete returned'); return r;} } -export async function startModelProxyFromPiModel(model:any, ctx:any={}, signal?:AbortSignal){const ac = signal ?? new AbortController().signal; const server=http.createServer(async(req,res)=>{ if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} let body=''; req.on('data',d=>body+=d); req.on('end',async()=>{try{const j=JSON.parse(body||'{}'); const got=await completeWithPi(model,ctx,j.messages||[],j,ac); const msg:any={role:'assistant',content:got.text}; if(got.tool_calls.length) msg.tool_calls=got.tool_calls; const base:any={id:'pi-villani',created:Math.floor(Date.now()/1000),model:j.model||model.id||'pi-current-model',choices:[{index:0,finish_reason:got.tool_calls.length?'tool_calls':'stop'}]}; if(j.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...base,object:'chat.completion.chunk',choices:[{index:0,delta:msg,finish_reason:base.choices[0].finish_reason}]})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify({...base,object:'chat.completion',choices:[{index:0,message:msg,finish_reason:base.choices[0].finish_reason}],usage:zeroUsage()}));}}catch(e){res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitize(e)}}));}});}); if(signal) signal.addEventListener('abort',()=>server.close(),{once:true}); await new Promise(r=>server.listen(0,'127.0.0.1',r)); const addr=server.address(); if(!addr||typeof addr==='string') throw new Error('Proxy bind failed'); return {url:`http://127.0.0.1:${addr.port}`, close:()=>server.close(), address:addr};} +export async function startModelProxyFromPiModel(options:PiModelProxyOptions){const p=new PiModelProxy(options); const url=await p.start(); return {url,close:()=>p.stop(),proxy:p};} export const startModelProxy=startModelProxyFromPiModel; +export const _test={toPiContext,assistantFromPi,sanitizeError}; diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts index d12bea49..9f4e349b 100644 --- a/integrations/pi-villani/src/proxy.test.ts +++ b/integrations/pi-villani/src/proxy.test.ts @@ -1,8 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { resolvePiModel, startModelProxy, zeroUsage } from './modelProxy.js'; +import { resolveModelAuth, proxyTest } from './index.js'; + test('model resolution uses ctx.model and fails clearly without one',()=>{assert.throws(()=>resolvePiModel({}),/No active Pi model/); assert.equal(resolvePiModel({model:{id:'m'} as any}).id,'m');}); -test('proxy serves model text from Pi streamSimple and binds localhost',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){yield {textDelta:'hi'};}}}; const proxy=await startModelProxy(model); try{assert.match(proxy.url,/^http:\/\/127\.0\.0\.1:/); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'hi'); assert.deepEqual(j.usage,zeroUsage());}finally{proxy.close();}}); -test('unsupported paths return 404',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/nope',{method:'POST'}); assert.equal(r.status,404);}finally{proxy.close();}}); -test('OpenAI tool calls are converted from Pi stream events',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){yield {toolCalls:[{id:'c1',name:'doit',arguments:{x:1}}]};}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[]})}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'tool_calls'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{proxy.close();}}); -test('OpenAI tool result messages converted into Pi context',async()=>{let seen:any; const model:any={id:'m',api:{streamSimple:async function*(_m:any,ctx:any){seen=ctx; yield {text:'ok'};}}}; const proxy=await startModelProxy(model); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'tool',tool_call_id:'c1',content:'result'}]})}); assert.deepEqual(seen.messages[0],{role:'tool',toolCallId:'c1',content:'result'});}finally{proxy.close();}}); -test('upstream errors are sanitized and credentials are not exposed',async()=>{const model:any={id:'m',api:{streamSimple:async function*(){throw new Error('failed Bearer abc sk-secret OPENAI_API_KEY=abc');}}}; const proxy=await startModelProxy(model); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.doesNotMatch(txt,/Bearer abc|sk-secret|OPENAI_API_KEY=abc/);}finally{proxy.close();}}); -test('abort signal cancels in-flight streamSimple',async()=>{const ac=new AbortController(); let saw=false; const model:any={id:'m',api:{streamSimple:async function*(_m:any,_ctx:any,opt:any){await new Promise(r=>setTimeout(r,30)); saw=opt.signal.aborted; yield {text:'late'};}}}; const proxy=await startModelProxy(model,ac.signal); try{const p=fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}).catch(e=>e); setTimeout(()=>ac.abort(),5); await p; assert.equal(ac.signal.aborted,true);}finally{proxy.close();}}); +test('resolveModelAuth calls ctx.modelRegistry.getApiKeyAndHeaders(model)',async()=>{const model={id:'m'}; let seen; const auth=await resolveModelAuth({modelRegistry:{getApiKeyAndHeaders:async(m:any)=>(seen=m,{ok:true,apiKey:'k',headers:{h:'v'}})}},model); assert.equal(seen,model); assert.deepEqual(auth,{apiKey:'k',headers:{h:'v'}});}); +test('auth failure throws clear sanitized error',async()=>{await assert.rejects(()=>resolveModelAuth({modelRegistry:{getApiKeyAndHeaders:async()=>({ok:false,error:'bad Bearer abc sk-secret OPENAI_API_KEY=abc'})}},{id:'m'}),/Villani could not resolve Pi model authentication: bad Bearer \[redacted\] \[redacted\] OPENAI_API_KEY=\[redacted\]/);}); +test('proxy uses PiAI.complete path and passes auth',async()=>{let opts:any; const proxy=await startModelProxy({model:{id:'m',api:{streamSimple:async function*(){throw new Error('not preferred');}}},apiKey:'key',headers:{Authorization:'Bearer secret'},completeFn:(async(_m:any,ctx:any,o:any)=>{opts=o; assert.equal(ctx.systemPrompt,'sys'); assert.deepEqual(ctx.messages[0],{role:'user',content:'hi'}); return {content:'ok',usage:{promptTokens:2,completionTokens:1}};}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'system',content:'sys'},{role:'user',content:'hi'}]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'ok'); assert.deepEqual(j.usage,{prompt_tokens:2,completion_tokens:1,total_tokens:3}); assert.equal(opts.apiKey,'key'); assert.deepEqual(opts.headers,{Authorization:'Bearer secret'});}finally{await proxy.close();}}); +test('unsupported paths return 404',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:''}) as any}); try{const r=await fetch(proxy.url+'/nope',{method:'POST'}); assert.equal(r.status,404);}finally{await proxy.close();}}); +test('OpenAI tool result messages and tools converted into Pi context',async()=>{let seen:any; const proxy=await startModelProxy({model:{id:'m'},completeFn:(async(_m:any,ctx:any)=>{seen=ctx; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'assistant',content:'use',tool_calls:[{id:'c1',function:{name:'doit',arguments:'{"x":1}'}}]},{role:'tool',tool_call_id:'c1',content:'result'}],tools:[{type:'function',function:{name:'doit',description:'d',parameters:{type:'object'}}}]})}); assert.deepEqual(seen.messages[0],{role:'assistant',content:'use',toolCalls:[{id:'c1',name:'doit',arguments:{x:1}}]}); assert.deepEqual(seen.messages[1],{role:'toolResult',toolCallId:'c1',content:'result'}); assert.deepEqual(seen.tools[0],{name:'doit',description:'d',inputSchema:{type:'object'}});}finally{await proxy.close();}}); +test('Pi AssistantMessage toolCall converts to OpenAI tool_calls',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'tool_calls'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{await proxy.close();}}); +test('streaming mode emits one SSE chunk plus DONE',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:'hi'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({stream:true})}); const txt=await r.text(); assert.match(txt,/data: .*hi/); assert.match(txt,/data: \[DONE\]/);}finally{await proxy.close();}}); +test('upstream errors are sanitized and credentials are not exposed',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>{throw new Error('failed Bearer abc sk-secret OPENAI_API_KEY=abc Authorization: xyz');}}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.doesNotMatch(txt,/Bearer abc|sk-secret|OPENAI_API_KEY=abc|Authorization: xyz/);}finally{await proxy.close();}}); +test('abort signal is passed into Pi complete',async()=>{const ac=new AbortController(); let saw:AbortSignal|undefined; const proxy=await startModelProxy({model:{id:'m'},signal:ac.signal,completeFn:(async(_m:any,_ctx:any,o:any)=>{saw=o.signal; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); assert.equal(saw,ac.signal); assert.deepEqual(zeroUsage(),{prompt_tokens:0,completion_tokens:0,total_tokens:0});}finally{await proxy.close();}}); +test('/villani-proxy-test sends real request using mocked complete function and hides credentials',async()=>{const notes:string[]=[]; const model={id:'m'}; await proxyTest({model,modelRegistry:{getApiKeyAndHeaders:async()=>({ok:true,apiKey:'sk-secret',headers:{Authorization:'Bearer abc'}})},ui:{notify:(m:string)=>notes.push(m)}, completeFn:async()=>({content:'READY'})}); assert.match(notes.join('\n'),/Villani proxy test succeeded/); assert.doesNotMatch(notes.join('\n'),/sk-secret|Bearer abc/);}); From e3b29c11443ce4a8078a4c45b2ba14aae754db44 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 18:30:43 +1000 Subject: [PATCH 13/36] Fix Pi Villani proxy and bridge waits --- integrations/pi-villani/src/extension.test.ts | 7 +++++++ integrations/pi-villani/src/index.ts | 7 +++++-- integrations/pi-villani/src/modelProxy.ts | 5 +++-- integrations/pi-villani/src/process.test.ts | 2 ++ integrations/pi-villani/src/process.ts | 14 ++++++++------ integrations/pi-villani/src/proxy.test.ts | 3 +++ integrations/pi-villani/src/render.ts | 2 +- 7 files changed, 29 insertions(+), 11 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 48d2d334..4eeed54a 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -4,3 +4,10 @@ test('registers Villani Pi commands',()=>{const a=api(); activate(a); assert.dee test('/villani-ping only notifies OK',async()=>{const a=api(); activate(a); const notes:any[]=[]; await a.commands['villani-ping'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani ping OK']);}); test('/villani-confirm-test accepted, rejected, and missing UI are visible',async()=>{for(const value of [true,false]){const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m),confirm:async()=>value}}); assert.equal(notes[0],'Villani confirmation test starting...'); assert.equal(notes.at(-1),value?'Villani confirmation accepted':'Villani confirmation rejected');} const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani confirmation test starting...','Villani confirmation UI unavailable']);}); test('/villani-doctor prints diagnostics without secrets',async()=>{const a=api(); activate(a); const notes:string[]=[]; process.env.VILLANI_API_KEY='secret'; try{await a.commands['villani-doctor'].handler('',{cwd:'/tmp/repo',model:{id:'m'},modelRegistry:{getApiKeyAndHeaders(){}} ,ui:{notify:(m:string)=>notes.push(m)}}); const msg=notes.join('\n'); assert.match(msg,/package version: 0.1.4/); assert.match(msg,/runtime version: 0.1.3/); assert.match(msg,/ctx.model exists: true/); assert.doesNotMatch(msg,/secret|VILLANI_API_KEY/);} finally{delete process.env.VILLANI_API_KEY;}}); +import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runVillani } from './index.js'; +function bridgeScript(body:string){const d=mkdtempSync(join(tmpdir(),'pi-villani-run-')); const p=join(d,'bridge.mjs'); writeFileSync(p,`#!/usr/bin/env node\n${body}`); chmodSync(p,0o755); return p;} +const readyPrelude="process.stdout.write(JSON.stringify({type:'ready'})+'\\n');\n"; + +test('/villani sends command notification and only reports run started after run_started event',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{const msg=JSON.parse(chunk.toString().trim()); process.stdout.write(JSON.stringify({type:'run_started',id:msg.id})+'\\n'); process.stdout.write(JSON.stringify({type:'phase',id:msg.id,name:'x'})+'\\n'); process.stdout.write(JSON.stringify({type:'run_completed',id:msg.id,summary:'done'})+'\\n');}); setTimeout(()=>{},500);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:string)=>notes.push(m)},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp',model:{id:'m'},});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} const joined=notes.join('\n'); assert.match(joined,/Villani bridge ready/); assert.match(joined,/Villani run command sent/); assert.match(joined,/Villani run started\./); assert.ok(notes.indexOf('Villani run command sent.'){const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.resume(); console.error('no ack'); setTimeout(()=>{},15000);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/did not acknowledge run command within 10 seconds.*no ack/s);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} assert.doesNotMatch(notes.join('\n'),/Villani run started\./);}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index a0431e82..79c6640e 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -15,8 +15,11 @@ export async function safeCommand(ctx:any,label:string,fn:()=>Promise):Pro function approvalSummary(e:any){const kind=e.kind||e.action||e.approval_type||'action'; const target=e.path||e.file||e.command||e.summary||''; if(kind==='write')return `Villani wants to write file:\n${target}`; if(kind==='patch')return `Villani wants to patch file:\n${target}`; if(kind==='shell'||e.command)return `Villani wants to run command:\n${e.command||target}`; return `Villani wants approval for ${kind}:\n${String(target).slice(0,500)}`;} async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; run.pending.set(requestId,false); let approved=false; try{approved=await confirm(ctx,'Approve Villani action?',approvalSummary(e));}catch(err){await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); run.bridge?.respondToApproval(run.id,requestId,approved);} function denyPending(run:ActiveRun){for(const [id,done] of run.pending){if(!done){run.pending.set(id,true); run.bridge?.respondToApproval(run.id,id,false);}}} -export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(); await notify(ctx,'Villani run started.','info'); setStatus(ctx,'Villani running...'); bridge.on('event',(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); const final=await bridge.waitForFinalEvent(runId); await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); activeRun=null;} } } -export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn}); try{ const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}]})}); const text=await r.text(); if(!r.ok) throw new Error(`HTTP ${r.status}: ${text}`); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } +function bridgeStderr(bridge:VillaniBridgeProcess){return bridge.getRecentStderr?.() ?? bridge.stderr ?? '';} +async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string){const started=await bridge.waitForEvent('run_started',10000); if(!started) throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`); return started;} +async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} +export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); bridge.on('event',(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }); const runStartedPromise=waitForRunAcknowledgement(bridge,runId); const progressPromise=waitForFirstProgressAfterStart(bridge,runId); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit().then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); activeRun=null;} } } +export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn}); try{ const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); const text=await r.text(); if(!r.ok) throw new Error(`HTTP ${r.status}: ${text}`); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); run.abort.abort(); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); if(!aborted) run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} async function doctor(ctx:any){const cached=(()=>{try{return existsSync(cacheRoot())}catch{return false}})(); const lines=[`package version: 0.1.4`,`runtime version: ${VILLANI_RUNTIME_VERSION}`,`cwd: ${ctx.cwd??process.cwd()}`,`ctx.model exists: ${!!ctx.model}`,`ctx.model.id: ${ctx.model?.id??'unavailable'}`,`ctx.modelRegistry exists: ${!!ctx.modelRegistry}`,`ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,`VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL!==undefined}`,`VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND!==undefined}`,`runtime tag: ${VILLANI_RUNTIME_TAG}`,`cached runtime executable exists: ${cached}`,`active run: ${activeRun?'yes':'no'}`]; await notify(ctx,lines.join('\n'),'info');} export default function activate(api:any){ const reg=(name:string,description:string,handler:(args:string,ctx:any)=>Promise)=>api.registerCommand(name,{description,handler}); reg('villani','Run Villani Code on a task',async(args,ctx)=>safeCommand(ctx,'Villani',()=>runVillani(args,ctx?.pi??api,ctx))); reg('villani-abort','Abort the active Villani run',async(_args,ctx)=>safeCommand(ctx,'Villani abort',async()=>{ await abortVillani(ctx); })); reg('villani-confirm-test','Test Villani approval UI',async(_args,ctx)=>safeCommand(ctx,'Villani confirmation test',async()=>{await notify(ctx,'Villani confirmation test starting...'); if(!ctx?.ui?.confirm){await notify(ctx,'Villani confirmation UI unavailable','warn'); return;} const ok=await confirm(ctx,'Villani confirmation test','Confirm Villani test?'); await notify(ctx,ok?'Villani confirmation accepted':'Villani confirmation rejected');})); reg('villani-ping','Check Villani extension loading',async(_args,ctx)=>safeCommand(ctx,'Villani ping',()=>notify(ctx,'Villani ping OK'))); reg('villani-doctor','Show Villani diagnostics',async(_args,ctx)=>safeCommand(ctx,'Villani doctor',()=>doctor(ctx))); reg('villani-proxy-test','Test Villani Pi model proxy',async(_args,ctx)=>safeCommand(ctx,'Villani proxy test',()=>proxyTest(ctx))); try{api.onSessionStart?.(async(ctx:any)=>notify(ctx,'Villani extension loaded')); api.on?.('sessionStart',async(ctx:any)=>notify(ctx,'Villani extension loaded'));}catch{} } diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index e8265ab7..ff1ba7e5 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -1,8 +1,9 @@ import http from 'node:http'; -import { complete } from '@earendil-works/pi-ai/compat'; +import * as PiAI from '@earendil-works/pi-ai'; +const complete = (PiAI as any).complete; import type { OpenAIMessage } from './protocol.js'; -export interface PiModelProxyOptions { model:any; apiKey?:string; headers?:Record; signal?:AbortSignal; timeoutMs?:number; completeFn?:typeof complete; } +export interface PiModelProxyOptions { model:any; apiKey?:string; headers?:Record; signal?:AbortSignal; timeoutMs?:number; completeFn?:any; } export function zeroUsage(){return {prompt_tokens:0,completion_tokens:0,total_tokens:0};} export function sanitizeError(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|VILLANI_API_KEY)=[^\s]+/ig,'$1=[redacted]').replace(/(authorization|api[-_]?key|x-api-key)["':= ]+[^,}\s]+/ig,'$1=[redacted]');} function debug(message:string){if(process.env.VILLANI_PI_DEBUG==='1') console.error(`[pi-villani proxy] ${message}`);} diff --git a/integrations/pi-villani/src/process.test.ts b/integrations/pi-villani/src/process.test.ts index c6f1a7d5..33ae3ea4 100644 --- a/integrations/pi-villani/src/process.test.ts +++ b/integrations/pi-villani/src/process.test.ts @@ -7,3 +7,5 @@ test('bridge readiness timeout includes stderr',async()=>{const d=mkdtempSync(jo test('malformed JSONL kills process',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,"console.log('nope'); setTimeout(()=>{},500);\n"); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await assert.rejects(()=>new Promise((_,rej)=>b.once('error',rej)),/Malformed JSONL/); assert.equal(b.proc.killed,true);}); test('abort and approval response send correct JSONL',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,readyBridge); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await b.waitUntilReady(1000); const got:any[]=[]; b.on('event',e=>{if(e.type!=='ready')got.push(e)}); b.abort('r'); b.respondToApproval('r','r:1',true); await new Promise(r=>setTimeout(r,50)); b.kill(); assert.deepEqual(got,[{type:'run_aborted',id:'r'},{type:'approval_resolved',id:'r',request_id:'r:1',approved:true}]);}); test('sanitizes secrets only in proxy mode and explicit config preserves env',()=>{const env:any={OPENAI_API_KEY:'x',ANTHROPIC_API_KEY:'a',VILLANI_API_KEY:'v',CUSTOM_TOKEN:'y',X_AUTH:'z',Y_BEARER:'b',PATH:'p'}; const e=sanitizedEnv({proxyMode:true,env}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.ANTHROPIC_API_KEY,undefined); assert.equal(e.VILLANI_API_KEY,undefined); assert.equal(e.CUSTOM_TOKEN,undefined); assert.equal(e.X_AUTH,undefined); assert.equal(e.Y_BEARER,undefined); assert.equal(e.PATH,'p'); assert.equal(sanitizedEnv({proxyMode:true,explicitConfigMode:true,env}).VILLANI_API_KEY,'v');}); + +test('waitForFinalEvent rejects when bridge exits before final event',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,"process.stdout.write(JSON.stringify({type:'ready'})+'\\n'); console.error('early boom'); setTimeout(()=>process.exit(4),30);\n"); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await b.waitUntilReady(1000); await assert.rejects(()=>b.waitForFinalEvent('r',1000),/exited before final event with code 4.*early boom/s);}); diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts index 17819339..1d0ab05a 100644 --- a/integrations/pi-villani/src/process.ts +++ b/integrations/pi-villani/src/process.ts @@ -1,11 +1,13 @@ import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; import { EventEmitter } from 'node:events'; import type { BridgeEvent } from './protocol.js'; export interface BridgeProcessOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; cwd?:string; startupTimeoutMs?:number; bridgeArgs?:string[]} export type LaunchOptions=BridgeProcessOptions; export function sanitizedEnv(opts:BridgeProcessOptions={}):NodeJS.ProcessEnv{const e={...(opts.env||process.env)}; if(opts.proxyMode&&!opts.explicitConfigMode){for(const k of Object.keys(e)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k)) delete e[k];}} return e;} -export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; startupTimeoutMs:number; readonly shell=false; finals=new Map(); - constructor(executable:string, opts:BridgeProcessOptions={}){super(); const args=opts.bridgeArgs??['bridge','--stdio']; this.startupTimeoutMs=opts.startupTimeoutMs??30000; this.proc=spawn(executable,args,{shell:false,env:sanitizedEnv(opts),cwd:opts.cwd}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e)); this.proc.once('exit',(code,signal)=>this.emit('exit',{code,signal}));} - onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id)this.finals.set(e.id,e); this.emit('event',e);}catch{this.kill(); this.emit('error',new Error('Malformed JSONL from bridge'));}}} +export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; startupTimeoutMs:number; readonly shell=false; finals=new Map(); private exitInfo?:{code:number|null;signal:NodeJS.Signals|null}; + constructor(executable:string, opts:BridgeProcessOptions={}){super(); const args=opts.bridgeArgs??['bridge','--stdio']; this.startupTimeoutMs=opts.startupTimeoutMs??30000; this.proc=spawn(executable,args,{shell:false,env:sanitizedEnv(opts),cwd:opts.cwd}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e)); this.proc.once('exit',(code,signal)=>{this.exitInfo={code,signal}; this.emit('exit',{code,signal});});} + getRecentStderr(){return this.stderr;} + onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id)this.finals.set(e.id,e); this.emit('event',e);}catch{const err=new Error('Malformed JSONL from bridge'); this.kill(); this.emit('error',err);}}} send(c:unknown){this.proc.stdin.write(JSON.stringify(c)+'\n');} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} waitForEvent(type:string,timeoutMs=1000){return new Promise(r=>{const t=setTimeout(()=>{this.off('event',on);r(undefined);},timeoutMs); const on=(e:BridgeEvent)=>{if(e.type===type){clearTimeout(t);this.off('event',on);r(e);}}; this.on('event',on);});} - waitUntilReady(timeoutMs=this.startupTimeoutMs){return new Promise((res,rej)=>{if(this.ready)return res(); let done=false; const finish=(err?:Error)=>{if(done)return; done=true; clearTimeout(t); this.off('event',on); err?rej(err):res();}; const t=setTimeout(()=>finish(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); const on=(e:BridgeEvent)=>{if(e.type==='ready')finish();}; this.on('event',on); this.proc.once('exit',()=>finish(new Error(`Bridge exited before ready. stderr: ${this.stderr}`)));});} - waitForFinalEvent(runId:string,timeoutMs=0){return new Promise((res,rej)=>{const existing=this.finals.get(runId); if(existing)return res(existing); const on=(e:BridgeEvent)=>{if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id===runId){cleanup();res(e);}}; const cleanup=()=>{this.off('event',on); if(t)clearTimeout(t);}; const t=timeoutMs?setTimeout(()=>{cleanup();rej(new Error('Timed out waiting for Villani final event'));},timeoutMs):undefined as any; this.on('event',on); this.once('error',(e)=>{cleanup();rej(e);});});} - waitForExit(){return new Promise(r=>this.proc.once('exit',r));}} + waitForAnyEvent(types:string[],runId?:string,timeoutMs=1000){return new Promise(r=>{const wanted=new Set(types); const t=setTimeout(()=>{this.off('event',on);r(undefined);},timeoutMs); const on=(e:BridgeEvent)=>{if(wanted.has(e.type)&&(!runId||!e.id||e.id===runId)){clearTimeout(t);this.off('event',on);r(e);}}; this.on('event',on);});} + waitUntilReady(timeoutMs=this.startupTimeoutMs){return new Promise((res,rej)=>{if(this.ready)return res(); let done=false; const finish=(err?:Error)=>{if(done)return; done=true; clearTimeout(t); this.off('event',on); this.off('error',onError); this.proc.off('exit',onExit); err?rej(err):res();}; const t=setTimeout(()=>finish(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); const on=(e:BridgeEvent)=>{if(e.type==='ready')finish();}; const onError=(e:Error)=>finish(e); const onExit=()=>finish(new Error(`Bridge exited before ready. stderr: ${this.stderr}`)); this.on('event',on); this.once('error',onError); this.proc.once('exit',onExit);});} + waitForFinalEvent(runId:string,timeoutMs=0){return new Promise((res,rej)=>{const existing=this.finals.get(runId); if(existing)return res(existing); if(this.exitInfo)return rej(new Error(`Villani bridge exited before final event with code ${this.exitInfo.code}. ${this.stderr}`)); let done=false; const cleanup=()=>{if(done)return; done=true; this.off('event',on); this.off('error',onError); this.proc.off('exit',onExit); if(t)clearTimeout(t);}; const on=(e:BridgeEvent)=>{if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id===runId){cleanup();res(e);}}; const onError=(e:Error)=>{cleanup();rej(e);}; const onExit=(code:number|null)=>{cleanup();rej(new Error(`Villani bridge exited before final event with code ${code}. ${this.stderr}`));}; const t=timeoutMs?setTimeout(()=>{cleanup();rej(new Error('Timed out waiting for Villani final event'));},timeoutMs):undefined as any; this.on('event',on); this.once('error',onError); this.proc.once('exit',onExit);});} + waitForExit(){return new Promise(r=>{if(this.exitInfo)return r(this.exitInfo.code); this.proc.once('exit',r);});}} diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts index 9f4e349b..ce7f099f 100644 --- a/integrations/pi-villani/src/proxy.test.ts +++ b/integrations/pi-villani/src/proxy.test.ts @@ -12,3 +12,6 @@ test('streaming mode emits one SSE chunk plus DONE',async()=>{const proxy=await test('upstream errors are sanitized and credentials are not exposed',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>{throw new Error('failed Bearer abc sk-secret OPENAI_API_KEY=abc Authorization: xyz');}}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.doesNotMatch(txt,/Bearer abc|sk-secret|OPENAI_API_KEY=abc|Authorization: xyz/);}finally{await proxy.close();}}); test('abort signal is passed into Pi complete',async()=>{const ac=new AbortController(); let saw:AbortSignal|undefined; const proxy=await startModelProxy({model:{id:'m'},signal:ac.signal,completeFn:(async(_m:any,_ctx:any,o:any)=>{saw=o.signal; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); assert.equal(saw,ac.signal); assert.deepEqual(zeroUsage(),{prompt_tokens:0,completion_tokens:0,total_tokens:0});}finally{await proxy.close();}}); test('/villani-proxy-test sends real request using mocked complete function and hides credentials',async()=>{const notes:string[]=[]; const model={id:'m'}; await proxyTest({model,modelRegistry:{getApiKeyAndHeaders:async()=>({ok:true,apiKey:'sk-secret',headers:{Authorization:'Bearer abc'}})},ui:{notify:(m:string)=>notes.push(m)}, completeFn:async()=>({content:'READY'})}); assert.match(notes.join('\n'),/Villani proxy test succeeded/); assert.doesNotMatch(notes.join('\n'),/sk-secret|Bearer abc/);}); +import { readFileSync } from 'node:fs'; + +test('modelProxy imports primary @earendil-works/pi-ai complete and not compat',()=>{const src=readFileSync(new URL('../src/modelProxy.ts',import.meta.url),'utf8'); assert.match(src,/from '@earendil-works\/pi-ai';/); assert.doesNotMatch(src,/@earendil-works\/pi-ai\/compat/);}); diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index a700ca9d..74085a56 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -16,4 +16,4 @@ export async function confirm(ctx: any, title: string, message: string): Promise } export function visibleChangedFiles(files:string[]=[]){return files.filter(f=>!/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/.test(f));} export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; return [head,event.summary||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:''].filter(Boolean).join('\n\n');} -export async function renderBridgeEvent(event:any, pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']); if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.message||'unknown error'}`, 'error'); if(['run_completed','run_failed','run_aborted'].includes(event.type)) await sendMessage(pi, finalMessage(event)); } +export async function renderBridgeEvent(event:any, pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']); if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.message||'unknown error'}`, 'error'); if(['run_completed','run_failed','run_aborted'].includes(event.type)) await sendMessage(pi, finalMessage(event)); } From 07e031ce1a8da010bbe718ea6b47b00298085097 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 19:56:17 +1000 Subject: [PATCH 14/36] Restore Pi proxy context conversion --- integrations/pi-villani/package-lock.json | 4 ++-- integrations/pi-villani/package.json | 8 ++++---- integrations/pi-villani/src/modelProxy.ts | 15 +++++++++------ integrations/pi-villani/src/proxy.test.ts | 8 +++++--- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/integrations/pi-villani/package-lock.json b/integrations/pi-villani/package-lock.json index 7e0297e9..78bf35de 100644 --- a/integrations/pi-villani/package-lock.json +++ b/integrations/pi-villani/package-lock.json @@ -22,8 +22,8 @@ "node": ">=22.19.0" }, "peerDependencies": { - "@earendil-works/pi-ai": "0.80.2", - "@earendil-works/pi-coding-agent": "0.80.2" + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*" } }, "node_modules/@anthropic-ai/sdk": { diff --git a/integrations/pi-villani/package.json b/integrations/pi-villani/package.json index f7b0d86d..063be795 100644 --- a/integrations/pi-villani/package.json +++ b/integrations/pi-villani/package.json @@ -30,16 +30,16 @@ "devDependencies": { "@earendil-works/pi-ai": "0.80.2", "@earendil-works/pi-coding-agent": "0.80.2", - "typescript": "5.9.2", + "@types/adm-zip": "0.5.7", "@types/node": "24.3.0", - "@types/adm-zip": "0.5.7" + "typescript": "5.9.2" }, "dependencies": { "adm-zip": "0.5.16", "tar": "7.4.3" }, "peerDependencies": { - "@earendil-works/pi-ai": "0.80.2", - "@earendil-works/pi-coding-agent": "0.80.2" + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*" } } diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index ff1ba7e5..ce58aaf2 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -9,19 +9,22 @@ export function sanitizeError(e:unknown){return String((e as Error)?.message||e) function debug(message:string){if(process.env.VILLANI_PI_DEBUG==='1') console.error(`[pi-villani proxy] ${message}`);} export function resolvePiModel(ctx:any):any{ if(process.env.VILLANI_USE_PI_MODEL==='false') return {id:process.env.VILLANI_MODEL||'villani-env-model', bypass:true}; const m=ctx?.model; if(!m) throw new Error('No active Pi model available. Select or launch Pi with a model, then retry.'); return m; } function textOf(content:any):string{if(content==null)return ''; if(typeof content==='string')return content; if(Array.isArray(content))return content.map(p=>typeof p==='string'?p:(p?.text??p?.content??'')).join(''); return String(content);} -function toPiContext(messages:OpenAIMessage[]=[], tools:any[]|undefined){const ctx:any={messages:[],tools:toPiTools(tools)}; const system:string[]=[]; for(const m of messages){if(m.role==='system'){system.push(textOf(m.content)); continue;} if(m.role==='user') ctx.messages.push({role:'user',content:textOf(m.content)}); else if(m.role==='assistant'){const msg:any={role:'assistant',content:textOf(m.content)}; if(m.tool_calls?.length) msg.toolCalls=m.tool_calls.map(fromOpenAIToolCall); ctx.messages.push(msg);} else if(m.role==='tool') ctx.messages.push({role:'toolResult',toolCallId:m.tool_call_id,content:textOf(m.content)});} if(system.length) ctx.systemPrompt=system.join('\n'); return ctx;} -function toPiTools(tools:any[]|undefined){return tools?.map(t=>t?.function?{name:t.function.name,description:t.function.description,inputSchema:t.function.parameters}:t);} +function timestampOf(m:any){return m?.timestamp??Date.now();} +function contentBlocksOf(m:any){const blocks:any[]=[]; const text=textOf(m.content); if(text) blocks.push({type:'text',text}); for(const c of m.tool_calls||[]) blocks.push({type:'toolCall',...fromOpenAIToolCall(c)}); return blocks;} +function toPiContext(messages:OpenAIMessage[]=[], tools:any[]|undefined){const ctx:any={messages:[],tools:toPiTools(tools)}; const system:string[]=[]; for(const m of messages){if(m.role==='system'){system.push(textOf(m.content)); continue;} if(m.role==='user') ctx.messages.push({role:'user',content:textOf(m.content),timestamp:timestampOf(m)}); else if(m.role==='assistant') ctx.messages.push({role:'assistant',content:contentBlocksOf(m),api:'openai-completions',provider:'pi',model:m.model,usage:m.usage,stopReason:m.stopReason,timestamp:timestampOf(m)}); else if(m.role==='tool') ctx.messages.push({role:'toolResult',toolCallId:m.tool_call_id,toolName:m.name??m.toolName,content:[{type:'text',text:textOf(m.content)}],isError:false,timestamp:timestampOf(m)});} if(system.length) ctx.systemPrompt=system.join('\n'); return ctx;} +function toPiTools(tools:any[]|undefined){return tools?.map(t=>t?.function?{name:t.function.name,description:t.function.description,parameters:t.function.parameters}:t);} function fromOpenAIToolCall(c:any){return {id:c.id,name:c.name??c.function?.name,arguments:parseArgs(c.arguments??c.function?.arguments)};} function parseArgs(a:any){if(typeof a==='string'){try{return JSON.parse(a);}catch{return a;}} return a??{};} function normCalls(calls:any[]|undefined){return (calls||[]).map(c=>({id:c.id||`call_${Math.random().toString(36).slice(2)}`,type:'function',function:{name:c.name??c.function?.name,arguments:typeof (c.arguments??c.function?.arguments)==='string'?(c.arguments??c.function?.arguments):JSON.stringify(c.arguments??c.function?.arguments??{})}}));} -function assistantFromPi(r:any){const content=Array.isArray(r?.content)?r.content:r?.message?.content??r?.content; let text=''; const calls:any[]=[]; if(Array.isArray(content)){for(const b of content){if(typeof b==='string') text+=b; else if((b?.type==='text'||b?.text)&&!b?.toolCall) text+=b.text??''; else if(b?.type==='toolCall'||b?.toolCall) calls.push(b.toolCall??b);}} else text=content??r?.text??''; calls.push(...(r?.toolCalls??r?.tool_calls??r?.message?.tool_calls??[])); return {text:String(text??''), tool_calls:normCalls(calls), stopReason:r?.stopReason??r?.finishReason};} -function usage(r:any){const u=r?.usage??{}; const p=u.prompt_tokens??u.promptTokens??u.inputTokens??0; const c=u.completion_tokens??u.completionTokens??u.outputTokens??0; return {prompt_tokens:p,completion_tokens:c,total_tokens:u.total_tokens??u.totalTokens??p+c};} -function finishReason(a:any){return a.tool_calls.length||a.stopReason==='toolUse'?'tool_calls':'stop';} +function assistantFromPi(r:any){const content=Array.isArray(r?.content)?r.content:r?.message?.content??r?.content; let text=''; const calls:any[]=[]; if(Array.isArray(content)){for(const b of content){if(typeof b==='string') text+=b; else if(b?.type==='text') text+=b.text??''; else if(b?.type==='toolCall') calls.push(b);}} else text=content??r?.text??''; return {text:String(text??''), tool_calls:normCalls(calls), stopReason:r?.stopReason??r?.finishReason};} +function usage(r:any){const u=r?.usage??{}; const p=u.prompt_tokens??u.promptTokens??u.inputTokens??u.input??0; const c=u.completion_tokens??u.completionTokens??u.outputTokens??u.output??0; return {prompt_tokens:p,completion_tokens:c,total_tokens:u.total_tokens??u.totalTokens??u.total_tokens??p+c};} +function finishReason(a:any){return a.stopReason??(a.tool_calls.length?'tool_calls':'stop');} +function throwForStopReason(a:any){if(a.stopReason==='error') throw new Error('Pi completion stopped with error.'); if(a.stopReason==='aborted'){const e=new Error('Pi completion aborted.'); (e as any).name='AbortError'; throw e;}} export class PiModelProxy { private server?:http.Server; constructor(private readonly options:PiModelProxyOptions){} async start():Promise{this.server=http.createServer((req,res)=>void this.handle(req,res)); if(this.options.signal) this.options.signal.addEventListener('abort',()=>void this.stop(),{once:true}); await new Promise(r=>this.server!.listen(0,'127.0.0.1',r)); const addr=this.server.address(); if(!addr||typeof addr==='string') throw new Error('Proxy bind failed'); const url=`http://127.0.0.1:${addr.port}`; debug(`listening on ${url}`); return url;} async stop():Promise{const s=this.server; if(!s)return; this.server=undefined; await new Promise(r=>s.close(()=>r()));} - private async handle(req:http.IncomingMessage,res:http.ServerResponse){if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} debug('POST /v1/chat/completions'); try{const body=await new Promise((resolve,reject)=>{let b=''; req.on('data',d=>b+=d); req.on('end',()=>resolve(b)); req.on('error',reject);}); const payload=JSON.parse(body||'{}'); const context=toPiContext(payload.messages||[],payload.tools); const raw=await this.complete(context,payload); const a=assistantFromPi(raw); const msg:any={role:'assistant',content:a.text}; if(a.tool_calls.length) msg.tool_calls=a.tool_calls; const base:any={id:'pi-villani',created:Math.floor(Date.now()/1000),model:payload.model||this.options.model?.id||'pi-current-model'}; const choice={index:0,finish_reason:finishReason(a)}; if(payload.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...base,object:'chat.completion.chunk',choices:[{...choice,delta:msg}]})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify({...base,object:'chat.completion',choices:[{...choice,message:msg}],usage:usage(raw)}));}}catch(e){debug(`Pi complete failed: ${sanitizeError(e)}`); res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitizeError(e),type:'upstream_error',code:'pi_completion_failed'}}));}} + private async handle(req:http.IncomingMessage,res:http.ServerResponse){if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} debug('POST /v1/chat/completions'); try{const body=await new Promise((resolve,reject)=>{let b=''; req.on('data',d=>b+=d); req.on('end',()=>resolve(b)); req.on('error',reject);}); const payload=JSON.parse(body||'{}'); const context=toPiContext(payload.messages||[],payload.tools); const raw=await this.complete(context,payload); const a=assistantFromPi(raw); throwForStopReason(a); const msg:any={role:'assistant',content:a.text}; if(a.tool_calls.length) msg.tool_calls=a.tool_calls; const base:any={id:'pi-villani',created:Math.floor(Date.now()/1000),model:payload.model||this.options.model?.id||'pi-current-model'}; const choice={index:0,finish_reason:finishReason(a)}; if(payload.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...base,object:'chat.completion.chunk',choices:[{...choice,delta:msg}]})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify({...base,object:'chat.completion',choices:[{...choice,message:msg}],usage:usage(raw)}));}}catch(e){debug(`Pi complete failed: ${sanitizeError(e)}`); res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitizeError(e),type:'upstream_error',code:'pi_completion_failed'}}));}} private async complete(context:any,payload:any){debug('calling Pi complete'); const completeFn=this.options.completeFn??complete; if(typeof completeFn!=='function') throw new Error('PiAI.complete is unavailable.'); const r=await completeFn(this.options.model,context,{apiKey:this.options.apiKey,headers:this.options.headers,maxTokens:payload.max_tokens,temperature:payload.temperature,signal:this.options.signal,timeoutMs:this.options.timeoutMs}); debug('Pi complete returned'); return r;} } export async function startModelProxyFromPiModel(options:PiModelProxyOptions){const p=new PiModelProxy(options); const url=await p.start(); return {url,close:()=>p.stop(),proxy:p};} diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts index ce7f099f..6786fde9 100644 --- a/integrations/pi-villani/src/proxy.test.ts +++ b/integrations/pi-villani/src/proxy.test.ts @@ -4,11 +4,13 @@ import { resolveModelAuth, proxyTest } from './index.js'; test('model resolution uses ctx.model and fails clearly without one',()=>{assert.throws(()=>resolvePiModel({}),/No active Pi model/); assert.equal(resolvePiModel({model:{id:'m'} as any}).id,'m');}); test('resolveModelAuth calls ctx.modelRegistry.getApiKeyAndHeaders(model)',async()=>{const model={id:'m'}; let seen; const auth=await resolveModelAuth({modelRegistry:{getApiKeyAndHeaders:async(m:any)=>(seen=m,{ok:true,apiKey:'k',headers:{h:'v'}})}},model); assert.equal(seen,model); assert.deepEqual(auth,{apiKey:'k',headers:{h:'v'}});}); test('auth failure throws clear sanitized error',async()=>{await assert.rejects(()=>resolveModelAuth({modelRegistry:{getApiKeyAndHeaders:async()=>({ok:false,error:'bad Bearer abc sk-secret OPENAI_API_KEY=abc'})}},{id:'m'}),/Villani could not resolve Pi model authentication: bad Bearer \[redacted\] \[redacted\] OPENAI_API_KEY=\[redacted\]/);}); -test('proxy uses PiAI.complete path and passes auth',async()=>{let opts:any; const proxy=await startModelProxy({model:{id:'m',api:{streamSimple:async function*(){throw new Error('not preferred');}}},apiKey:'key',headers:{Authorization:'Bearer secret'},completeFn:(async(_m:any,ctx:any,o:any)=>{opts=o; assert.equal(ctx.systemPrompt,'sys'); assert.deepEqual(ctx.messages[0],{role:'user',content:'hi'}); return {content:'ok',usage:{promptTokens:2,completionTokens:1}};}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'system',content:'sys'},{role:'user',content:'hi'}]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'ok'); assert.deepEqual(j.usage,{prompt_tokens:2,completion_tokens:1,total_tokens:3}); assert.equal(opts.apiKey,'key'); assert.deepEqual(opts.headers,{Authorization:'Bearer secret'});}finally{await proxy.close();}}); +test('proxy uses PiAI.complete path and passes auth',async()=>{let opts:any; const proxy=await startModelProxy({model:{id:'m',api:{streamSimple:async function*(){throw new Error('not preferred');}}},apiKey:'key',headers:{Authorization:'Bearer secret'},completeFn:(async(_m:any,ctx:any,o:any)=>{opts=o; assert.equal(ctx.systemPrompt,'sys'); assert.equal(ctx.messages[0].role,'user'); assert.equal(ctx.messages[0].content,'hi'); assert.equal(typeof ctx.messages[0].timestamp,'number'); return {content:'ok',usage:{input:2,output:1,totalTokens:3}};}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'system',content:'sys'},{role:'user',content:'hi'}]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'ok'); assert.deepEqual(j.usage,{prompt_tokens:2,completion_tokens:1,total_tokens:3}); assert.equal(opts.apiKey,'key'); assert.deepEqual(opts.headers,{Authorization:'Bearer secret'});}finally{await proxy.close();}}); test('unsupported paths return 404',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:''}) as any}); try{const r=await fetch(proxy.url+'/nope',{method:'POST'}); assert.equal(r.status,404);}finally{await proxy.close();}}); -test('OpenAI tool result messages and tools converted into Pi context',async()=>{let seen:any; const proxy=await startModelProxy({model:{id:'m'},completeFn:(async(_m:any,ctx:any)=>{seen=ctx; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'assistant',content:'use',tool_calls:[{id:'c1',function:{name:'doit',arguments:'{"x":1}'}}]},{role:'tool',tool_call_id:'c1',content:'result'}],tools:[{type:'function',function:{name:'doit',description:'d',parameters:{type:'object'}}}]})}); assert.deepEqual(seen.messages[0],{role:'assistant',content:'use',toolCalls:[{id:'c1',name:'doit',arguments:{x:1}}]}); assert.deepEqual(seen.messages[1],{role:'toolResult',toolCallId:'c1',content:'result'}); assert.deepEqual(seen.tools[0],{name:'doit',description:'d',inputSchema:{type:'object'}});}finally{await proxy.close();}}); -test('Pi AssistantMessage toolCall converts to OpenAI tool_calls',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'tool_calls'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{await proxy.close();}}); +test('OpenAI messages and tools convert into old Pi Context shape',async()=>{let seen:any; const assistantUsage={input:4,output:2,totalTokens:6}; const proxy=await startModelProxy({model:{id:'m'},completeFn:(async(_m:any,ctx:any)=>{seen=ctx; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'user',content:'hi'},{role:'assistant',content:'use',model:'gpt-test',usage:assistantUsage,stopReason:'toolUse',tool_calls:[{id:'c1',function:{name:'doit',arguments:'{"x":1}'}}]},{role:'tool',tool_call_id:'c1',name:'doit',content:'result'}],tools:[{type:'function',function:{name:'doit',description:'d',parameters:{type:'object'}}}]})}); assert.equal(seen.messages[0].role,'user'); assert.equal(seen.messages[0].content,'hi'); assert.equal(typeof seen.messages[0].timestamp,'number'); assert.equal(seen.messages[1].role,'assistant'); assert.deepEqual(seen.messages[1].content,[{type:'text',text:'use'},{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}]); assert.equal(seen.messages[1].api,'openai-completions'); assert.equal(seen.messages[1].provider,'pi'); assert.equal(seen.messages[1].model,'gpt-test'); assert.deepEqual(seen.messages[1].usage,assistantUsage); assert.equal(seen.messages[1].stopReason,'toolUse'); assert.equal(typeof seen.messages[1].timestamp,'number'); assert.deepEqual(seen.messages[2].content,[{type:'text',text:'result'}]); assert.equal(seen.messages[2].role,'toolResult'); assert.equal(seen.messages[2].toolCallId,'c1'); assert.equal(seen.messages[2].toolName,'doit'); assert.equal(seen.messages[2].isError,false); assert.equal(typeof seen.messages[2].timestamp,'number'); assert.deepEqual(seen.tools[0],{name:'doit',description:'d',parameters:{type:'object'}}); assert.equal('toolCalls' in seen.messages[1],false); assert.equal('inputSchema' in seen.tools[0],false);}finally{await proxy.close();}}); +test('Pi AssistantMessage toolCall converts to OpenAI tool_calls',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'toolUse'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{await proxy.close();}}); test('streaming mode emits one SSE chunk plus DONE',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:'hi'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({stream:true})}); const txt=await r.text(); assert.match(txt,/data: .*hi/); assert.match(txt,/data: \[DONE\]/);}finally{await proxy.close();}}); + +test('Pi stopReason error and aborted become upstream errors',async()=>{for(const stopReason of ['error','aborted']){const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'text',text:'bad'}],stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(r.status,500); assert.equal(j.error.type,'upstream_error'); assert.match(j.error.message,stopReason==='aborted'?/aborted/:/error/);}finally{await proxy.close();}}}); test('upstream errors are sanitized and credentials are not exposed',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>{throw new Error('failed Bearer abc sk-secret OPENAI_API_KEY=abc Authorization: xyz');}}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.doesNotMatch(txt,/Bearer abc|sk-secret|OPENAI_API_KEY=abc|Authorization: xyz/);}finally{await proxy.close();}}); test('abort signal is passed into Pi complete',async()=>{const ac=new AbortController(); let saw:AbortSignal|undefined; const proxy=await startModelProxy({model:{id:'m'},signal:ac.signal,completeFn:(async(_m:any,_ctx:any,o:any)=>{saw=o.signal; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); assert.equal(saw,ac.signal); assert.deepEqual(zeroUsage(),{prompt_tokens:0,completion_tokens:0,total_tokens:0});}finally{await proxy.close();}}); test('/villani-proxy-test sends real request using mocked complete function and hides credentials',async()=>{const notes:string[]=[]; const model={id:'m'}; await proxyTest({model,modelRegistry:{getApiKeyAndHeaders:async()=>({ok:true,apiKey:'sk-secret',headers:{Authorization:'Bearer abc'}})},ui:{notify:(m:string)=>notes.push(m)}, completeFn:async()=>({content:'READY'})}); assert.match(notes.join('\n'),/Villani proxy test succeeded/); assert.doesNotMatch(notes.join('\n'),/sk-secret|Bearer abc/);}); From 090d19b1eff1f1b8b5053dfc89e0921d2f40d43d Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 20:23:15 +1000 Subject: [PATCH 15/36] Fix Pi lifecycle timeout cleanup --- integrations/pi-villani/src/extension.test.ts | 14 +++++++++++++- integrations/pi-villani/src/index.ts | 10 +++++----- integrations/pi-villani/src/modelProxy.ts | 2 +- integrations/pi-villani/src/process.ts | 11 ++++++----- integrations/pi-villani/src/proxy.test.ts | 4 +++- 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 4eeed54a..fe74f81a 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -4,10 +4,22 @@ test('registers Villani Pi commands',()=>{const a=api(); activate(a); assert.dee test('/villani-ping only notifies OK',async()=>{const a=api(); activate(a); const notes:any[]=[]; await a.commands['villani-ping'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani ping OK']);}); test('/villani-confirm-test accepted, rejected, and missing UI are visible',async()=>{for(const value of [true,false]){const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m),confirm:async()=>value}}); assert.equal(notes[0],'Villani confirmation test starting...'); assert.equal(notes.at(-1),value?'Villani confirmation accepted':'Villani confirmation rejected');} const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani confirmation test starting...','Villani confirmation UI unavailable']);}); test('/villani-doctor prints diagnostics without secrets',async()=>{const a=api(); activate(a); const notes:string[]=[]; process.env.VILLANI_API_KEY='secret'; try{await a.commands['villani-doctor'].handler('',{cwd:'/tmp/repo',model:{id:'m'},modelRegistry:{getApiKeyAndHeaders(){}} ,ui:{notify:(m:string)=>notes.push(m)}}); const msg=notes.join('\n'); assert.match(msg,/package version: 0.1.4/); assert.match(msg,/runtime version: 0.1.3/); assert.match(msg,/ctx.model exists: true/); assert.doesNotMatch(msg,/secret|VILLANI_API_KEY/);} finally{delete process.env.VILLANI_API_KEY;}}); -import { chmodSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runVillani } from './index.js'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runVillani } from './index.js'; function bridgeScript(body:string){const d=mkdtempSync(join(tmpdir(),'pi-villani-run-')); const p=join(d,'bridge.mjs'); writeFileSync(p,`#!/usr/bin/env node\n${body}`); chmodSync(p,0o755); return p;} const readyPrelude="process.stdout.write(JSON.stringify({type:'ready'})+'\\n');\n"; test('/villani sends command notification and only reports run started after run_started event',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{const msg=JSON.parse(chunk.toString().trim()); process.stdout.write(JSON.stringify({type:'run_started',id:msg.id})+'\\n'); process.stdout.write(JSON.stringify({type:'phase',id:msg.id,name:'x'})+'\\n'); process.stdout.write(JSON.stringify({type:'run_completed',id:msg.id,summary:'done'})+'\\n');}); setTimeout(()=>{},500);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:string)=>notes.push(m)},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp',model:{id:'m'},});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} const joined=notes.join('\n'); assert.match(joined,/Villani bridge ready/); assert.match(joined,/Villani run command sent/); assert.match(joined,/Villani run started\./); assert.ok(notes.indexOf('Villani run command sent.'){const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.resume(); console.error('no ack'); setTimeout(()=>{},15000);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/did not acknowledge run command within 10 seconds.*no ack/s);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} assert.doesNotMatch(notes.join('\n'),/Villani run started\./);}); + + +async function waitForPidGone(pid:number,timeoutMs=3000){const deadline=Date.now()+timeoutMs; while(Date.now()setTimeout(r,50));} assert.fail(`fake bridge process ${pid} remained alive`);} + +test('/villani run_started timeout cleans up fake bridge process',async()=>{const oldCommand=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const d=mkdtempSync(join(tmpdir(),'pi-villani-cleanup-')); const pidFile=join(d,'pid'); const p=join(d,'bridge.mjs'); writeFileSync(p,`#!/usr/bin/env node +import { writeFileSync } from 'node:fs'; +writeFileSync(${JSON.stringify(pidFile)},String(process.pid)); +process.stdout.write(JSON.stringify({type:'ready'})+'\\n'); +process.stdin.resume(); +console.error('cleanup no ack'); +setInterval(()=>{},1000); +`); chmodSync(p,0o755); process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; const notes:string[]=[]; try{await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/did not acknowledge run command within 10 seconds.*cleanup no ack/s); assert.ok(existsSync(pidFile)); await waitForPidGone(Number(readFileSync(pidFile,'utf8'))); assert.doesNotMatch(notes.join('\n'),/Villani run started\./);} finally{if(oldCommand===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=oldCommand; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;}}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 79c6640e..2d3d37f7 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -6,7 +6,7 @@ import { VillaniBridgeProcess } from './process.js'; import { resolvePiModel, sanitizeError, startModelProxyFromPiModel } from './modelProxy.js'; import { confirm, finalMessage, notify, renderBridgeEvent, sendMessage, setStatus } from './render.js'; -type ActiveRun={id:string, abort:AbortController, bridge?:VillaniBridgeProcess, proxy?:{close:()=>void}, pending:Map}; +type ActiveRun={id:string, abort:AbortController, bridge?:VillaniBridgeProcess, proxy?:{close:()=>void|Promise}, pending:Map}; let activeRun:ActiveRun|null=null; export function buildChildEnvForProxy(proxyUrl:string, model:any):NodeJS.ProcessEnv{const env={...process.env}; for(const k of Object.keys(env)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k)) delete env[k];} env.VILLANI_PROVIDER='openai'; env.VILLANI_MODEL=model?.id??'pi-current-model'; env.VILLANI_BASE_URL=proxyUrl; return env;} function envConfig(){return {provider:process.env.VILLANI_PROVIDER,model:process.env.VILLANI_MODEL,base_url:process.env.VILLANI_BASE_URL,api_key:process.env.VILLANI_API_KEY};} @@ -16,11 +16,11 @@ function approvalSummary(e:any){const kind=e.kind||e.action||e.approval_type||'a async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; run.pending.set(requestId,false); let approved=false; try{approved=await confirm(ctx,'Approve Villani action?',approvalSummary(e));}catch(err){await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); run.bridge?.respondToApproval(run.id,requestId,approved);} function denyPending(run:ActiveRun){for(const [id,done] of run.pending){if(!done){run.pending.set(id,true); run.bridge?.respondToApproval(run.id,id,false);}}} function bridgeStderr(bridge:VillaniBridgeProcess){return bridge.getRecentStderr?.() ?? bridge.stderr ?? '';} -async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string){const started=await bridge.waitForEvent('run_started',10000); if(!started) throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`); return started;} -async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} -export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); bridge.on('event',(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }); const runStartedPromise=waitForRunAcknowledgement(bridge,runId); const progressPromise=waitForFirstProgressAfterStart(bridge,runId); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit().then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); activeRun=null;} } } +async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const started=await bridge.waitForEvent('run_started',10000,signal); if(!started) throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`); return started;} +async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000,signal); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} +export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn}); try{ const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); const text=await r.text(); if(!r.ok) throw new Error(`HTTP ${r.status}: ${text}`); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } -export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); run.abort.abort(); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); if(!aborted) run.bridge?.kill(); run.proxy?.close?.(); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} +export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); run.abort.abort(); if(!aborted) run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} async function doctor(ctx:any){const cached=(()=>{try{return existsSync(cacheRoot())}catch{return false}})(); const lines=[`package version: 0.1.4`,`runtime version: ${VILLANI_RUNTIME_VERSION}`,`cwd: ${ctx.cwd??process.cwd()}`,`ctx.model exists: ${!!ctx.model}`,`ctx.model.id: ${ctx.model?.id??'unavailable'}`,`ctx.modelRegistry exists: ${!!ctx.modelRegistry}`,`ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,`VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL!==undefined}`,`VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND!==undefined}`,`runtime tag: ${VILLANI_RUNTIME_TAG}`,`cached runtime executable exists: ${cached}`,`active run: ${activeRun?'yes':'no'}`]; await notify(ctx,lines.join('\n'),'info');} export default function activate(api:any){ const reg=(name:string,description:string,handler:(args:string,ctx:any)=>Promise)=>api.registerCommand(name,{description,handler}); reg('villani','Run Villani Code on a task',async(args,ctx)=>safeCommand(ctx,'Villani',()=>runVillani(args,ctx?.pi??api,ctx))); reg('villani-abort','Abort the active Villani run',async(_args,ctx)=>safeCommand(ctx,'Villani abort',async()=>{ await abortVillani(ctx); })); reg('villani-confirm-test','Test Villani approval UI',async(_args,ctx)=>safeCommand(ctx,'Villani confirmation test',async()=>{await notify(ctx,'Villani confirmation test starting...'); if(!ctx?.ui?.confirm){await notify(ctx,'Villani confirmation UI unavailable','warn'); return;} const ok=await confirm(ctx,'Villani confirmation test','Confirm Villani test?'); await notify(ctx,ok?'Villani confirmation accepted':'Villani confirmation rejected');})); reg('villani-ping','Check Villani extension loading',async(_args,ctx)=>safeCommand(ctx,'Villani ping',()=>notify(ctx,'Villani ping OK'))); reg('villani-doctor','Show Villani diagnostics',async(_args,ctx)=>safeCommand(ctx,'Villani doctor',()=>doctor(ctx))); reg('villani-proxy-test','Test Villani Pi model proxy',async(_args,ctx)=>safeCommand(ctx,'Villani proxy test',()=>proxyTest(ctx))); try{api.onSessionStart?.(async(ctx:any)=>notify(ctx,'Villani extension loaded')); api.on?.('sessionStart',async(ctx:any)=>notify(ctx,'Villani extension loaded'));}catch{} } export { notify, setStatus, sendMessage, confirm }; diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index ce58aaf2..b2b2ecce 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -18,7 +18,7 @@ function parseArgs(a:any){if(typeof a==='string'){try{return JSON.parse(a);}catc function normCalls(calls:any[]|undefined){return (calls||[]).map(c=>({id:c.id||`call_${Math.random().toString(36).slice(2)}`,type:'function',function:{name:c.name??c.function?.name,arguments:typeof (c.arguments??c.function?.arguments)==='string'?(c.arguments??c.function?.arguments):JSON.stringify(c.arguments??c.function?.arguments??{})}}));} function assistantFromPi(r:any){const content=Array.isArray(r?.content)?r.content:r?.message?.content??r?.content; let text=''; const calls:any[]=[]; if(Array.isArray(content)){for(const b of content){if(typeof b==='string') text+=b; else if(b?.type==='text') text+=b.text??''; else if(b?.type==='toolCall') calls.push(b);}} else text=content??r?.text??''; return {text:String(text??''), tool_calls:normCalls(calls), stopReason:r?.stopReason??r?.finishReason};} function usage(r:any){const u=r?.usage??{}; const p=u.prompt_tokens??u.promptTokens??u.inputTokens??u.input??0; const c=u.completion_tokens??u.completionTokens??u.outputTokens??u.output??0; return {prompt_tokens:p,completion_tokens:c,total_tokens:u.total_tokens??u.totalTokens??u.total_tokens??p+c};} -function finishReason(a:any){return a.stopReason??(a.tool_calls.length?'tool_calls':'stop');} +function finishReason(a:any){if(a.stopReason==='toolUse')return 'tool_calls'; if(a.stopReason==='length')return 'length'; if(a.stopReason==='stop')return 'stop'; return a.tool_calls.length?'tool_calls':'stop';} function throwForStopReason(a:any){if(a.stopReason==='error') throw new Error('Pi completion stopped with error.'); if(a.stopReason==='aborted'){const e=new Error('Pi completion aborted.'); (e as any).name='AbortError'; throw e;}} export class PiModelProxy { private server?:http.Server; constructor(private readonly options:PiModelProxyOptions){} diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts index 1d0ab05a..37fed61c 100644 --- a/integrations/pi-villani/src/process.ts +++ b/integrations/pi-villani/src/process.ts @@ -6,8 +6,9 @@ export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutN getRecentStderr(){return this.stderr;} onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id)this.finals.set(e.id,e); this.emit('event',e);}catch{const err=new Error('Malformed JSONL from bridge'); this.kill(); this.emit('error',err);}}} send(c:unknown){this.proc.stdin.write(JSON.stringify(c)+'\n');} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} - waitForEvent(type:string,timeoutMs=1000){return new Promise(r=>{const t=setTimeout(()=>{this.off('event',on);r(undefined);},timeoutMs); const on=(e:BridgeEvent)=>{if(e.type===type){clearTimeout(t);this.off('event',on);r(e);}}; this.on('event',on);});} - waitForAnyEvent(types:string[],runId?:string,timeoutMs=1000){return new Promise(r=>{const wanted=new Set(types); const t=setTimeout(()=>{this.off('event',on);r(undefined);},timeoutMs); const on=(e:BridgeEvent)=>{if(wanted.has(e.type)&&(!runId||!e.id||e.id===runId)){clearTimeout(t);this.off('event',on);r(e);}}; this.on('event',on);});} - waitUntilReady(timeoutMs=this.startupTimeoutMs){return new Promise((res,rej)=>{if(this.ready)return res(); let done=false; const finish=(err?:Error)=>{if(done)return; done=true; clearTimeout(t); this.off('event',on); this.off('error',onError); this.proc.off('exit',onExit); err?rej(err):res();}; const t=setTimeout(()=>finish(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); const on=(e:BridgeEvent)=>{if(e.type==='ready')finish();}; const onError=(e:Error)=>finish(e); const onExit=()=>finish(new Error(`Bridge exited before ready. stderr: ${this.stderr}`)); this.on('event',on); this.once('error',onError); this.proc.once('exit',onExit);});} - waitForFinalEvent(runId:string,timeoutMs=0){return new Promise((res,rej)=>{const existing=this.finals.get(runId); if(existing)return res(existing); if(this.exitInfo)return rej(new Error(`Villani bridge exited before final event with code ${this.exitInfo.code}. ${this.stderr}`)); let done=false; const cleanup=()=>{if(done)return; done=true; this.off('event',on); this.off('error',onError); this.proc.off('exit',onExit); if(t)clearTimeout(t);}; const on=(e:BridgeEvent)=>{if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id===runId){cleanup();res(e);}}; const onError=(e:Error)=>{cleanup();rej(e);}; const onExit=(code:number|null)=>{cleanup();rej(new Error(`Villani bridge exited before final event with code ${code}. ${this.stderr}`));}; const t=timeoutMs?setTimeout(()=>{cleanup();rej(new Error('Timed out waiting for Villani final event'));},timeoutMs):undefined as any; this.on('event',on); this.once('error',onError); this.proc.once('exit',onExit);});} - waitForExit(){return new Promise(r=>{if(this.exitInfo)return r(this.exitInfo.code); this.proc.once('exit',r);});}} + private timeout(timeoutMs:number|undefined, fn:()=>void){if(!timeoutMs)return undefined; const t=setTimeout(fn,timeoutMs); t.unref?.(); return t;} + waitForEvent(type:string,timeoutMs=1000,signal?:AbortSignal){return new Promise((res,rej)=>{if(this.exitInfo)return res(undefined); if(signal?.aborted)return res(undefined); let done=false; const cleanup=()=>{if(done)return false; done=true; this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const finish=(value?:BridgeEvent)=>{if(cleanup())res(value);}; const fail=(e:Error)=>{if(cleanup())rej(e);}; const on=(e:BridgeEvent)=>{if(e.type===type)finish(e);}; const onError=(e:Error)=>fail(e); const onExit=()=>finish(undefined); const onAbort=()=>finish(undefined); const t=this.timeout(timeoutMs,()=>finish(undefined)); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} + waitForAnyEvent(types:string[],runId?:string,timeoutMs=1000,signal?:AbortSignal){return new Promise((res,rej)=>{if(this.exitInfo)return res(undefined); if(signal?.aborted)return res(undefined); const wanted=new Set(types); let done=false; const cleanup=()=>{if(done)return false; done=true; this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const finish=(value?:BridgeEvent)=>{if(cleanup())res(value);}; const fail=(e:Error)=>{if(cleanup())rej(e);}; const on=(e:BridgeEvent)=>{if(wanted.has(e.type)&&(!runId||!e.id||e.id===runId))finish(e);}; const onError=(e:Error)=>fail(e); const onExit=()=>finish(undefined); const onAbort=()=>finish(undefined); const t=this.timeout(timeoutMs,()=>finish(undefined)); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} + waitUntilReady(timeoutMs=this.startupTimeoutMs,signal?:AbortSignal){return new Promise((res,rej)=>{if(this.ready)return res(); if(this.exitInfo)return rej(new Error(`Bridge exited before ready. stderr: ${this.stderr}`)); if(signal?.aborted)return rej(new Error('Bridge readiness cancelled.')); let done=false; const finish=(err?:Error)=>{if(done)return; done=true; clearTimeout(t); this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); err?rej(err):res();}; const t=setTimeout(()=>finish(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); t.unref?.(); const on=(e:BridgeEvent)=>{if(e.type==='ready')finish();}; const onError=(e:Error)=>finish(e); const onExit=()=>finish(new Error(`Bridge exited before ready. stderr: ${this.stderr}`)); const onAbort=()=>finish(new Error('Bridge readiness cancelled.')); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} + waitForFinalEvent(runId:string,timeoutMs=0,signal?:AbortSignal){return new Promise((res,rej)=>{const existing=this.finals.get(runId); if(existing)return res(existing); if(this.exitInfo)return rej(new Error(`Villani bridge exited before final event with code ${this.exitInfo.code}. ${this.stderr}`)); if(signal?.aborted)return rej(new Error('Cancelled waiting for Villani final event')); let done=false; const cleanup=()=>{if(done)return false; done=true; this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const ok=(e:BridgeEvent)=>{if(cleanup())res(e);}; const fail=(e:Error)=>{if(cleanup())rej(e);}; const on=(e:BridgeEvent)=>{if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id===runId)ok(e);}; const onError=(e:Error)=>fail(e); const onExit=(codeOrInfo:any)=>{const code=typeof codeOrInfo==='object'?codeOrInfo?.code:codeOrInfo; fail(new Error(`Villani bridge exited before final event with code ${code}. ${this.stderr}`));}; const onAbort=()=>fail(new Error('Cancelled waiting for Villani final event')); const t=this.timeout(timeoutMs,()=>fail(new Error('Timed out waiting for Villani final event'))); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} + waitForExit(timeoutMs=0,signal?:AbortSignal){return new Promise((res)=>{if(this.exitInfo)return res(this.exitInfo.code); if(signal?.aborted)return res(undefined); let done=false; const cleanup=()=>{if(done)return false; done=true; this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const finish=(code?:number|null)=>{if(cleanup())res(code);}; const onExit=(codeOrInfo:any)=>finish(typeof codeOrInfo==='object'?codeOrInfo?.code:codeOrInfo); const onAbort=()=>finish(undefined); const t=this.timeout(timeoutMs,()=>finish(undefined)); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});}} diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts index 6786fde9..9962e1bc 100644 --- a/integrations/pi-villani/src/proxy.test.ts +++ b/integrations/pi-villani/src/proxy.test.ts @@ -7,7 +7,9 @@ test('auth failure throws clear sanitized error',async()=>{await assert.rejects( test('proxy uses PiAI.complete path and passes auth',async()=>{let opts:any; const proxy=await startModelProxy({model:{id:'m',api:{streamSimple:async function*(){throw new Error('not preferred');}}},apiKey:'key',headers:{Authorization:'Bearer secret'},completeFn:(async(_m:any,ctx:any,o:any)=>{opts=o; assert.equal(ctx.systemPrompt,'sys'); assert.equal(ctx.messages[0].role,'user'); assert.equal(ctx.messages[0].content,'hi'); assert.equal(typeof ctx.messages[0].timestamp,'number'); return {content:'ok',usage:{input:2,output:1,totalTokens:3}};}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'system',content:'sys'},{role:'user',content:'hi'}]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'ok'); assert.deepEqual(j.usage,{prompt_tokens:2,completion_tokens:1,total_tokens:3}); assert.equal(opts.apiKey,'key'); assert.deepEqual(opts.headers,{Authorization:'Bearer secret'});}finally{await proxy.close();}}); test('unsupported paths return 404',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:''}) as any}); try{const r=await fetch(proxy.url+'/nope',{method:'POST'}); assert.equal(r.status,404);}finally{await proxy.close();}}); test('OpenAI messages and tools convert into old Pi Context shape',async()=>{let seen:any; const assistantUsage={input:4,output:2,totalTokens:6}; const proxy=await startModelProxy({model:{id:'m'},completeFn:(async(_m:any,ctx:any)=>{seen=ctx; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'user',content:'hi'},{role:'assistant',content:'use',model:'gpt-test',usage:assistantUsage,stopReason:'toolUse',tool_calls:[{id:'c1',function:{name:'doit',arguments:'{"x":1}'}}]},{role:'tool',tool_call_id:'c1',name:'doit',content:'result'}],tools:[{type:'function',function:{name:'doit',description:'d',parameters:{type:'object'}}}]})}); assert.equal(seen.messages[0].role,'user'); assert.equal(seen.messages[0].content,'hi'); assert.equal(typeof seen.messages[0].timestamp,'number'); assert.equal(seen.messages[1].role,'assistant'); assert.deepEqual(seen.messages[1].content,[{type:'text',text:'use'},{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}]); assert.equal(seen.messages[1].api,'openai-completions'); assert.equal(seen.messages[1].provider,'pi'); assert.equal(seen.messages[1].model,'gpt-test'); assert.deepEqual(seen.messages[1].usage,assistantUsage); assert.equal(seen.messages[1].stopReason,'toolUse'); assert.equal(typeof seen.messages[1].timestamp,'number'); assert.deepEqual(seen.messages[2].content,[{type:'text',text:'result'}]); assert.equal(seen.messages[2].role,'toolResult'); assert.equal(seen.messages[2].toolCallId,'c1'); assert.equal(seen.messages[2].toolName,'doit'); assert.equal(seen.messages[2].isError,false); assert.equal(typeof seen.messages[2].timestamp,'number'); assert.deepEqual(seen.tools[0],{name:'doit',description:'d',parameters:{type:'object'}}); assert.equal('toolCalls' in seen.messages[1],false); assert.equal('inputSchema' in seen.tools[0],false);}finally{await proxy.close();}}); -test('Pi AssistantMessage toolCall converts to OpenAI tool_calls',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'toolUse'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{await proxy.close();}}); +test('Pi AssistantMessage toolCall converts to OpenAI tool_calls',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'tool_calls'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{await proxy.close();}}); + +test('Pi finish reasons map to OpenAI values',async()=>{for(const [stopReason,toolCalls,expected] of [['toolUse',true,'tool_calls'],['length',false,'length'],['stop',false,'stop'],[undefined,true,'tool_calls'],[undefined,false,'stop']] as const){const content=toolCalls?[{type:'toolCall',id:'c1',name:'doit',arguments:{}}]:'ok'; const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content,stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,expected); assert.notEqual(j.choices[0].finish_reason,'toolUse');}finally{await proxy.close();}}}); test('streaming mode emits one SSE chunk plus DONE',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:'hi'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({stream:true})}); const txt=await r.text(); assert.match(txt,/data: .*hi/); assert.match(txt,/data: \[DONE\]/);}finally{await proxy.close();}}); test('Pi stopReason error and aborted become upstream errors',async()=>{for(const stopReason of ['error','aborted']){const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'text',text:'bad'}],stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(r.status,500); assert.equal(j.error.type,'upstream_error'); assert.match(j.error.message,stopReason==='aborted'?/aborted/:/error/);}finally{await proxy.close();}}}); From 861623a054da6867563eec721e08437f421fdf3f Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 20:38:02 +1000 Subject: [PATCH 16/36] Fix Pi Villani proxy and bridge diagnostics --- integrations/pi-villani/src/extension.test.ts | 20 ++++--- integrations/pi-villani/src/index.ts | 15 ++++-- integrations/pi-villani/src/modelProxy.ts | 52 ++++++++++++++++--- integrations/pi-villani/src/proxy.test.ts | 13 +++-- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index fe74f81a..3676e209 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -1,16 +1,17 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; import activate from './index.js'; +import test from 'node:test'; import assert from 'node:assert/strict'; import activate, { bridgePing } from './index.js'; function api(){const commands:any={}; return {commands, registerCommand:(name:string,opts:any)=>{commands[name]=opts; assert.equal(typeof opts.handler,'function');}};} -test('registers Villani Pi commands',()=>{const a=api(); activate(a); assert.deepEqual(Object.keys(a.commands),['villani','villani-abort','villani-confirm-test','villani-ping','villani-doctor','villani-proxy-test']);}); +test('registers Villani Pi commands',()=>{const a=api(); activate(a); assert.deepEqual(Object.keys(a.commands),['villani','villani-abort','villani-confirm-test','villani-ping','villani-doctor','villani-proxy-test','villani-bridge-ping']);}); test('/villani-ping only notifies OK',async()=>{const a=api(); activate(a); const notes:any[]=[]; await a.commands['villani-ping'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani ping OK']);}); test('/villani-confirm-test accepted, rejected, and missing UI are visible',async()=>{for(const value of [true,false]){const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m),confirm:async()=>value}}); assert.equal(notes[0],'Villani confirmation test starting...'); assert.equal(notes.at(-1),value?'Villani confirmation accepted':'Villani confirmation rejected');} const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani confirmation test starting...','Villani confirmation UI unavailable']);}); test('/villani-doctor prints diagnostics without secrets',async()=>{const a=api(); activate(a); const notes:string[]=[]; process.env.VILLANI_API_KEY='secret'; try{await a.commands['villani-doctor'].handler('',{cwd:'/tmp/repo',model:{id:'m'},modelRegistry:{getApiKeyAndHeaders(){}} ,ui:{notify:(m:string)=>notes.push(m)}}); const msg=notes.join('\n'); assert.match(msg,/package version: 0.1.4/); assert.match(msg,/runtime version: 0.1.3/); assert.match(msg,/ctx.model exists: true/); assert.doesNotMatch(msg,/secret|VILLANI_API_KEY/);} finally{delete process.env.VILLANI_API_KEY;}}); import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runVillani } from './index.js'; function bridgeScript(body:string){const d=mkdtempSync(join(tmpdir(),'pi-villani-run-')); const p=join(d,'bridge.mjs'); writeFileSync(p,`#!/usr/bin/env node\n${body}`); chmodSync(p,0o755); return p;} -const readyPrelude="process.stdout.write(JSON.stringify({type:'ready'})+'\\n');\n"; +const readyPrelude="process.stdout.write(JSON.stringify({type:'ready'})+'\\n');\nconst send=e=>process.stdout.write(JSON.stringify(e)+'\\n');\n"; -test('/villani sends command notification and only reports run started after run_started event',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{const msg=JSON.parse(chunk.toString().trim()); process.stdout.write(JSON.stringify({type:'run_started',id:msg.id})+'\\n'); process.stdout.write(JSON.stringify({type:'phase',id:msg.id,name:'x'})+'\\n'); process.stdout.write(JSON.stringify({type:'run_completed',id:msg.id,summary:'done'})+'\\n');}); setTimeout(()=>{},500);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:string)=>notes.push(m)},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp',model:{id:'m'},});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} const joined=notes.join('\n'); assert.match(joined,/Villani bridge ready/); assert.match(joined,/Villani run command sent/); assert.match(joined,/Villani run started\./); assert.ok(notes.indexOf('Villani run command sent.'){const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.resume(); console.error('no ack'); setTimeout(()=>{},15000);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/did not acknowledge run command within 10 seconds.*no ack/s);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} assert.doesNotMatch(notes.join('\n'),/Villani run started\./);}); +test('/villani sends command notification and only reports run started after run_started event',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'phase',id:msg.id,name:'x'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:string)=>notes.push(m)},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp',model:{id:'m'},});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} const joined=notes.join('\n'); assert.match(joined,/Villani bridge ready/); assert.match(joined,/Villani run command sent/); assert.match(joined,/Villani run started\./); assert.ok(notes.indexOf('Villani run command sent.'){const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')send({type:'pong'});}}); console.error('no ack'); setTimeout(()=>{},15000);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/did not acknowledge run command within 10 seconds.*no ack/s);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} assert.doesNotMatch(notes.join('\n'),/Villani run started\./);}); async function waitForPidGone(pid:number,timeoutMs=3000){const deadline=Date.now()+timeoutMs; while(Date.now()setTimeout(r,50));} assert.fail(`fake bridge process ${pid} remained alive`);} @@ -19,7 +20,14 @@ test('/villani run_started timeout cleans up fake bridge process',async()=>{cons import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(pidFile)},String(process.pid)); process.stdout.write(JSON.stringify({type:'ready'})+'\\n'); -process.stdin.resume(); +process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')process.stdout.write(JSON.stringify({type:'pong'})+'\\n');}}); console.error('cleanup no ack'); setInterval(()=>{},1000); `); chmodSync(p,0o755); process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; const notes:string[]=[]; try{await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/did not acknowledge run command within 10 seconds.*cleanup no ack/s); assert.ok(existsSync(pidFile)); await waitForPidGone(Number(readFileSync(pidFile,'utf8'))); assert.doesNotMatch(notes.join('\n'),/Villani run started\./);} finally{if(oldCommand===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=oldCommand; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;}}); + + +test('bridge exit before ready with ModuleNotFoundError produces pip install diagnostic',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript("console.error(\"ModuleNotFoundError: No module named 'villani_code'\"); process.exit(1);\n"); process.env.VILLANI_COMMAND=p; try{await assert.rejects(()=>bridgePing({ui:{notify:()=>{}}}),/pip install -e/);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old;} }); + +test('/villani-bridge-ping succeeds with fake bridge',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split(/\\n/)){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')send({type:'pong'});}}); setTimeout(()=>{},500);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{await bridgePing({ui:{notify:(m:string)=>notes.push(m)}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old;} assert.match(notes.join('\n'),/Villani bridge ping succeeded/);}); + +test('/villani-bridge-ping surfaces stderr on failure',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript("console.error('boom stderr'); process.exit(2);\n"); process.env.VILLANI_COMMAND=p; try{await assert.rejects(()=>bridgePing({ui:{notify:()=>{}}}),/boom stderr/);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old;} }); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 2d3d37f7..499677a1 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -16,11 +16,18 @@ function approvalSummary(e:any){const kind=e.kind||e.action||e.approval_type||'a async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; run.pending.set(requestId,false); let approved=false; try{approved=await confirm(ctx,'Approve Villani action?',approvalSummary(e));}catch(err){await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); run.bridge?.respondToApproval(run.id,requestId,approved);} function denyPending(run:ActiveRun){for(const [id,done] of run.pending){if(!done){run.pending.set(id,true); run.bridge?.respondToApproval(run.id,id,false);}}} function bridgeStderr(bridge:VillaniBridgeProcess){return bridge.getRecentStderr?.() ?? bridge.stderr ?? '';} +function bridgeDiagnosticMessage(e:unknown){const msg=sanitizeError(e); if(/ModuleNotFoundError: No module named ['"]villani_code['"]/.test(msg)) return `Villani bridge failed because the selected VILLANI_COMMAND could not import villani_code. +From repo root, run: +.\\.venv\\Scripts\\python.exe -m pip install -e . + +${msg}`; return msg;} +async function assertBridgePing(executable:string, ctx:any, env?:NodeJS.ProcessEnv, signal?:AbortSignal){const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000}); try{await bridge.waitUntilReady(undefined,signal); bridge.send({type:'ping'}); const pong=await bridge.waitForEvent('pong',5000,signal); if(!pong) throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); await notify(ctx,'Villani bridge ping succeeded.','info'); return true;}catch(e){throw new Error(bridgeDiagnosticMessage(e));}finally{bridge.kill(); await bridge.waitForExit(1500).catch(()=>{});}} async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const started=await bridge.waitForEvent('run_started',10000,signal); if(!started) throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`); return started;} async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000,signal); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} -export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } -export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn}); try{ const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); const text=await r.text(); if(!r.ok) throw new Error(`HTTP ${r.status}: ${text}`); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } +export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } +export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); await notify(ctx,`active model id: ${model?.id??'unavailable'}`,'info'); await notify(ctx,`modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,'info'); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'auth resolution succeeded: true','info'); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn,completeSource:ctx.completeSource,completeImporter:ctx.completeImporter,pi:ctx.pi}); try{ await notify(ctx,`proxy URL: ${proxy.url}`,'info'); await notify(ctx,'POST /v1/chat/completions reached proxy: sending','info'); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); await notify(ctx,'POST /v1/chat/completions reached proxy: yes','info'); await notify(ctx,`selected Pi completion helper source: ${proxy.completionSource??'unavailable'}`,'info'); const text=await r.text(); if(!r.ok){await notify(ctx,`complete returned or failed: failed (${r.status})`,'info'); throw new Error(`HTTP ${r.status}: ${text}`);} await notify(ctx,'complete returned or failed: returned','info'); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); run.abort.abort(); if(!aborted) run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} -async function doctor(ctx:any){const cached=(()=>{try{return existsSync(cacheRoot())}catch{return false}})(); const lines=[`package version: 0.1.4`,`runtime version: ${VILLANI_RUNTIME_VERSION}`,`cwd: ${ctx.cwd??process.cwd()}`,`ctx.model exists: ${!!ctx.model}`,`ctx.model.id: ${ctx.model?.id??'unavailable'}`,`ctx.modelRegistry exists: ${!!ctx.modelRegistry}`,`ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,`VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL!==undefined}`,`VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND!==undefined}`,`runtime tag: ${VILLANI_RUNTIME_TAG}`,`cached runtime executable exists: ${cached}`,`active run: ${activeRun?'yes':'no'}`]; await notify(ctx,lines.join('\n'),'info');} -export default function activate(api:any){ const reg=(name:string,description:string,handler:(args:string,ctx:any)=>Promise)=>api.registerCommand(name,{description,handler}); reg('villani','Run Villani Code on a task',async(args,ctx)=>safeCommand(ctx,'Villani',()=>runVillani(args,ctx?.pi??api,ctx))); reg('villani-abort','Abort the active Villani run',async(_args,ctx)=>safeCommand(ctx,'Villani abort',async()=>{ await abortVillani(ctx); })); reg('villani-confirm-test','Test Villani approval UI',async(_args,ctx)=>safeCommand(ctx,'Villani confirmation test',async()=>{await notify(ctx,'Villani confirmation test starting...'); if(!ctx?.ui?.confirm){await notify(ctx,'Villani confirmation UI unavailable','warn'); return;} const ok=await confirm(ctx,'Villani confirmation test','Confirm Villani test?'); await notify(ctx,ok?'Villani confirmation accepted':'Villani confirmation rejected');})); reg('villani-ping','Check Villani extension loading',async(_args,ctx)=>safeCommand(ctx,'Villani ping',()=>notify(ctx,'Villani ping OK'))); reg('villani-doctor','Show Villani diagnostics',async(_args,ctx)=>safeCommand(ctx,'Villani doctor',()=>doctor(ctx))); reg('villani-proxy-test','Test Villani Pi model proxy',async(_args,ctx)=>safeCommand(ctx,'Villani proxy test',()=>proxyTest(ctx))); try{api.onSessionStart?.(async(ctx:any)=>notify(ctx,'Villani extension loaded')); api.on?.('sessionStart',async(ctx:any)=>notify(ctx,'Villani extension loaded'));}catch{} } +export async function bridgePing(ctx:any):Promise{ await notify(ctx,'Villani bridge ping starting...','info'); const executable=await resolveVillaniRuntime({}); await notify(ctx,`runtime executable: ${executable}`,'info'); await assertBridgePing(executable,ctx); } +async function doctor(ctx:any){const cached=(()=>{try{return existsSync(cacheRoot())}catch{return false}})(); const lines=[`package version: 0.1.4`,`runtime version: ${VILLANI_RUNTIME_VERSION}`,`cwd: ${ctx.cwd??process.cwd()}`,`ctx.model exists: ${!!ctx.model}`,`ctx.model.id: ${ctx.model?.id??'unavailable'}`,`ctx.modelRegistry exists: ${!!ctx.modelRegistry}`,`ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,`VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL!==undefined}`,`VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND!==undefined}`,`runtime tag: ${VILLANI_RUNTIME_TAG}`,`cached runtime executable exists: ${cached}`,`active run: ${activeRun?'yes':'no'}`]; if(process.env.VILLANI_COMMAND){try{const executable=await resolveVillaniRuntime({}); await assertBridgePing(executable,ctx);}catch(e){lines.push(`VILLANI_COMMAND bridge ping failed: ${bridgeDiagnosticMessage(e)}`);}} await notify(ctx,lines.join('\n'),'info');} +export default function activate(api:any){ const reg=(name:string,description:string,handler:(args:string,ctx:any)=>Promise)=>api.registerCommand(name,{description,handler}); reg('villani','Run Villani Code on a task',async(args,ctx)=>safeCommand(ctx,'Villani',()=>runVillani(args,ctx?.pi??api,ctx))); reg('villani-abort','Abort the active Villani run',async(_args,ctx)=>safeCommand(ctx,'Villani abort',async()=>{ await abortVillani(ctx); })); reg('villani-confirm-test','Test Villani approval UI',async(_args,ctx)=>safeCommand(ctx,'Villani confirmation test',async()=>{await notify(ctx,'Villani confirmation test starting...'); if(!ctx?.ui?.confirm){await notify(ctx,'Villani confirmation UI unavailable','warn'); return;} const ok=await confirm(ctx,'Villani confirmation test','Confirm Villani test?'); await notify(ctx,ok?'Villani confirmation accepted':'Villani confirmation rejected');})); reg('villani-ping','Check Villani extension loading',async(_args,ctx)=>safeCommand(ctx,'Villani ping',()=>notify(ctx,'Villani ping OK'))); reg('villani-doctor','Show Villani diagnostics',async(_args,ctx)=>safeCommand(ctx,'Villani doctor',()=>doctor(ctx))); reg('villani-proxy-test','Test Villani Pi model proxy',async(_args,ctx)=>safeCommand(ctx,'Villani proxy test',()=>proxyTest(ctx))); reg('villani-bridge-ping','Ping Villani bridge stdio',async(_args,ctx)=>safeCommand(ctx,'Villani bridge ping',()=>bridgePing(ctx))); try{api.onSessionStart?.(async(ctx:any)=>notify(ctx,'Villani extension loaded')); api.on?.('sessionStart',async(ctx:any)=>notify(ctx,'Villani extension loaded'));}catch{} } export { notify, setStatus, sendMessage, confirm }; diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index b2b2ecce..1e5752d9 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -1,9 +1,21 @@ import http from 'node:http'; -import * as PiAI from '@earendil-works/pi-ai'; -const complete = (PiAI as any).complete; import type { OpenAIMessage } from './protocol.js'; -export interface PiModelProxyOptions { model:any; apiKey?:string; headers?:Record; signal?:AbortSignal; timeoutMs?:number; completeFn?:any; } + +export type PiCompleteResolver = (name:string)=>Promise; +export async function resolvePiComplete(importer:PiCompleteResolver=(name)=>import(name)):Promise<{fn:any;source:string}>{ + const candidates=['@earendil-works/pi-ai','@earendil-works/pi-ai/compat']; + for(const name of candidates){ + try{ + const mod=await importer(name); + const options:[string,any][]=[['complete',(mod as any).complete],['default.complete',(mod as any).default?.complete],['compat.complete',(mod as any).compat?.complete]]; + for(const [exportName,fn] of options) if(typeof fn==='function') return {fn,source:`${name}:${exportName}`}; + }catch{ /* try next candidate */ } + } + throw new Error('No compatible Pi completion helper found in @earendil-works/pi-ai or @earendil-works/pi-ai/compat.'); +} + +export interface PiModelProxyOptions { model:any; apiKey?:string; headers?:Record; signal?:AbortSignal; timeoutMs?:number; completeFn?:any; completeSource?:string; completeImporter?:PiCompleteResolver; pi?:any; } export function zeroUsage(){return {prompt_tokens:0,completion_tokens:0,total_tokens:0};} export function sanitizeError(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|VILLANI_API_KEY)=[^\s]+/ig,'$1=[redacted]').replace(/(authorization|api[-_]?key|x-api-key)["':= ]+[^,}\s]+/ig,'$1=[redacted]');} function debug(message:string){if(process.env.VILLANI_PI_DEBUG==='1') console.error(`[pi-villani proxy] ${message}`);} @@ -21,12 +33,40 @@ function usage(r:any){const u=r?.usage??{}; const p=u.prompt_tokens??u.promptTok function finishReason(a:any){if(a.stopReason==='toolUse')return 'tool_calls'; if(a.stopReason==='length')return 'length'; if(a.stopReason==='stop')return 'stop'; return a.tool_calls.length?'tool_calls':'stop';} function throwForStopReason(a:any){if(a.stopReason==='error') throw new Error('Pi completion stopped with error.'); if(a.stopReason==='aborted'){const e=new Error('Pi completion aborted.'); (e as any).name='AbortError'; throw e;}} -export class PiModelProxy { private server?:http.Server; constructor(private readonly options:PiModelProxyOptions){} +export class PiModelProxy { private server?:http.Server; completionSource?:string; constructor(private readonly options:PiModelProxyOptions){this.completionSource=options.completeSource;} async start():Promise{this.server=http.createServer((req,res)=>void this.handle(req,res)); if(this.options.signal) this.options.signal.addEventListener('abort',()=>void this.stop(),{once:true}); await new Promise(r=>this.server!.listen(0,'127.0.0.1',r)); const addr=this.server.address(); if(!addr||typeof addr==='string') throw new Error('Proxy bind failed'); const url=`http://127.0.0.1:${addr.port}`; debug(`listening on ${url}`); return url;} async stop():Promise{const s=this.server; if(!s)return; this.server=undefined; await new Promise(r=>s.close(()=>r()));} private async handle(req:http.IncomingMessage,res:http.ServerResponse){if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} debug('POST /v1/chat/completions'); try{const body=await new Promise((resolve,reject)=>{let b=''; req.on('data',d=>b+=d); req.on('end',()=>resolve(b)); req.on('error',reject);}); const payload=JSON.parse(body||'{}'); const context=toPiContext(payload.messages||[],payload.tools); const raw=await this.complete(context,payload); const a=assistantFromPi(raw); throwForStopReason(a); const msg:any={role:'assistant',content:a.text}; if(a.tool_calls.length) msg.tool_calls=a.tool_calls; const base:any={id:'pi-villani',created:Math.floor(Date.now()/1000),model:payload.model||this.options.model?.id||'pi-current-model'}; const choice={index:0,finish_reason:finishReason(a)}; if(payload.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...base,object:'chat.completion.chunk',choices:[{...choice,delta:msg}]})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify({...base,object:'chat.completion',choices:[{...choice,message:msg}],usage:usage(raw)}));}}catch(e){debug(`Pi complete failed: ${sanitizeError(e)}`); res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitizeError(e),type:'upstream_error',code:'pi_completion_failed'}}));}} - private async complete(context:any,payload:any){debug('calling Pi complete'); const completeFn=this.options.completeFn??complete; if(typeof completeFn!=='function') throw new Error('PiAI.complete is unavailable.'); const r=await completeFn(this.options.model,context,{apiKey:this.options.apiKey,headers:this.options.headers,maxTokens:payload.max_tokens,temperature:payload.temperature,signal:this.options.signal,timeoutMs:this.options.timeoutMs}); debug('Pi complete returned'); return r;} + private async complete(context:any,payload:any){ + debug('calling Pi complete'); + const opts={apiKey:this.options.apiKey,headers:this.options.headers,maxTokens:payload.max_tokens,temperature:payload.temperature,signal:this.options.signal,timeoutMs:this.options.timeoutMs}; + let completeFn=this.options.completeFn; + if(typeof completeFn==='function'){ + this.completionSource=this.options.completeSource??'injected:completeFn'; + const r=await completeFn(this.options.model,context,opts); debug('Pi complete returned'); return r; + } + let resolveError:unknown; + try{ + const resolved=await resolvePiComplete(this.options.completeImporter); + completeFn=resolved.fn; this.completionSource=resolved.source; + }catch(e){ + resolveError=e; + } + if(typeof completeFn==='function'){ + const r=await completeFn(this.options.model,context,opts); debug('Pi complete returned'); return r; + } + debug(`Pi completion helper unavailable: ${sanitizeError(resolveError)}`); + const model=this.options.model; + const fallbacks:[string,any][]=[['model.api.streamSimple',model?.api?.streamSimple],['model.complete',model?.complete],['ctx.pi.complete',this.options.pi?.complete]]; + for(const [source,fn] of fallbacks){ + if(typeof fn!=='function') continue; + this.completionSource=source; + const r=await fn.call(source==='ctx.pi.complete'?this.options.pi:(source==='model.complete'?model:model?.api),model,context,opts); + debug(`${source} returned`); return r; + } + throw new Error('Active Pi model cannot be proxied: no supported completion API found.'); + } } -export async function startModelProxyFromPiModel(options:PiModelProxyOptions){const p=new PiModelProxy(options); const url=await p.start(); return {url,close:()=>p.stop(),proxy:p};} +export async function startModelProxyFromPiModel(options:PiModelProxyOptions){const p=new PiModelProxy(options); const url=await p.start(); return {url,close:()=>p.stop(),proxy:p,get completionSource(){return p.completionSource;}};} export const startModelProxy=startModelProxyFromPiModel; export const _test={toPiContext,assistantFromPi,sanitizeError}; diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts index 9962e1bc..6e4e11a9 100644 --- a/integrations/pi-villani/src/proxy.test.ts +++ b/integrations/pi-villani/src/proxy.test.ts @@ -1,10 +1,10 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; import { resolvePiModel, startModelProxy, zeroUsage } from './modelProxy.js'; +import test from 'node:test'; import assert from 'node:assert/strict'; import { resolvePiComplete, resolvePiModel, startModelProxy, zeroUsage } from './modelProxy.js'; import { resolveModelAuth, proxyTest } from './index.js'; test('model resolution uses ctx.model and fails clearly without one',()=>{assert.throws(()=>resolvePiModel({}),/No active Pi model/); assert.equal(resolvePiModel({model:{id:'m'} as any}).id,'m');}); test('resolveModelAuth calls ctx.modelRegistry.getApiKeyAndHeaders(model)',async()=>{const model={id:'m'}; let seen; const auth=await resolveModelAuth({modelRegistry:{getApiKeyAndHeaders:async(m:any)=>(seen=m,{ok:true,apiKey:'k',headers:{h:'v'}})}},model); assert.equal(seen,model); assert.deepEqual(auth,{apiKey:'k',headers:{h:'v'}});}); test('auth failure throws clear sanitized error',async()=>{await assert.rejects(()=>resolveModelAuth({modelRegistry:{getApiKeyAndHeaders:async()=>({ok:false,error:'bad Bearer abc sk-secret OPENAI_API_KEY=abc'})}},{id:'m'}),/Villani could not resolve Pi model authentication: bad Bearer \[redacted\] \[redacted\] OPENAI_API_KEY=\[redacted\]/);}); -test('proxy uses PiAI.complete path and passes auth',async()=>{let opts:any; const proxy=await startModelProxy({model:{id:'m',api:{streamSimple:async function*(){throw new Error('not preferred');}}},apiKey:'key',headers:{Authorization:'Bearer secret'},completeFn:(async(_m:any,ctx:any,o:any)=>{opts=o; assert.equal(ctx.systemPrompt,'sys'); assert.equal(ctx.messages[0].role,'user'); assert.equal(ctx.messages[0].content,'hi'); assert.equal(typeof ctx.messages[0].timestamp,'number'); return {content:'ok',usage:{input:2,output:1,totalTokens:3}};}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'system',content:'sys'},{role:'user',content:'hi'}]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'ok'); assert.deepEqual(j.usage,{prompt_tokens:2,completion_tokens:1,total_tokens:3}); assert.equal(opts.apiKey,'key'); assert.deepEqual(opts.headers,{Authorization:'Bearer secret'});}finally{await proxy.close();}}); +test('proxy uses resolved complete path and passes auth',async()=>{let opts:any; const proxy=await startModelProxy({model:{id:'m',api:{streamSimple:async function*(){throw new Error('not preferred');}}},apiKey:'key',headers:{Authorization:'Bearer secret'},completeFn:(async(_m:any,ctx:any,o:any)=>{opts=o; assert.equal(ctx.systemPrompt,'sys'); assert.equal(ctx.messages[0].role,'user'); assert.equal(ctx.messages[0].content,'hi'); assert.equal(typeof ctx.messages[0].timestamp,'number'); return {content:'ok',usage:{input:2,output:1,totalTokens:3}};}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'system',content:'sys'},{role:'user',content:'hi'}]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'ok'); assert.deepEqual(j.usage,{prompt_tokens:2,completion_tokens:1,total_tokens:3}); assert.equal(opts.apiKey,'key'); assert.deepEqual(opts.headers,{Authorization:'Bearer secret'});}finally{await proxy.close();}}); test('unsupported paths return 404',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:''}) as any}); try{const r=await fetch(proxy.url+'/nope',{method:'POST'}); assert.equal(r.status,404);}finally{await proxy.close();}}); test('OpenAI messages and tools convert into old Pi Context shape',async()=>{let seen:any; const assistantUsage={input:4,output:2,totalTokens:6}; const proxy=await startModelProxy({model:{id:'m'},completeFn:(async(_m:any,ctx:any)=>{seen=ctx; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'user',content:'hi'},{role:'assistant',content:'use',model:'gpt-test',usage:assistantUsage,stopReason:'toolUse',tool_calls:[{id:'c1',function:{name:'doit',arguments:'{"x":1}'}}]},{role:'tool',tool_call_id:'c1',name:'doit',content:'result'}],tools:[{type:'function',function:{name:'doit',description:'d',parameters:{type:'object'}}}]})}); assert.equal(seen.messages[0].role,'user'); assert.equal(seen.messages[0].content,'hi'); assert.equal(typeof seen.messages[0].timestamp,'number'); assert.equal(seen.messages[1].role,'assistant'); assert.deepEqual(seen.messages[1].content,[{type:'text',text:'use'},{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}]); assert.equal(seen.messages[1].api,'openai-completions'); assert.equal(seen.messages[1].provider,'pi'); assert.equal(seen.messages[1].model,'gpt-test'); assert.deepEqual(seen.messages[1].usage,assistantUsage); assert.equal(seen.messages[1].stopReason,'toolUse'); assert.equal(typeof seen.messages[1].timestamp,'number'); assert.deepEqual(seen.messages[2].content,[{type:'text',text:'result'}]); assert.equal(seen.messages[2].role,'toolResult'); assert.equal(seen.messages[2].toolCallId,'c1'); assert.equal(seen.messages[2].toolName,'doit'); assert.equal(seen.messages[2].isError,false); assert.equal(typeof seen.messages[2].timestamp,'number'); assert.deepEqual(seen.tools[0],{name:'doit',description:'d',parameters:{type:'object'}}); assert.equal('toolCalls' in seen.messages[1],false); assert.equal('inputSchema' in seen.tools[0],false);}finally{await proxy.close();}}); test('Pi AssistantMessage toolCall converts to OpenAI tool_calls',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'tool_calls'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{await proxy.close();}}); @@ -15,7 +15,10 @@ test('streaming mode emits one SSE chunk plus DONE',async()=>{const proxy=await test('Pi stopReason error and aborted become upstream errors',async()=>{for(const stopReason of ['error','aborted']){const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'text',text:'bad'}],stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(r.status,500); assert.equal(j.error.type,'upstream_error'); assert.match(j.error.message,stopReason==='aborted'?/aborted/:/error/);}finally{await proxy.close();}}}); test('upstream errors are sanitized and credentials are not exposed',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>{throw new Error('failed Bearer abc sk-secret OPENAI_API_KEY=abc Authorization: xyz');}}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.doesNotMatch(txt,/Bearer abc|sk-secret|OPENAI_API_KEY=abc|Authorization: xyz/);}finally{await proxy.close();}}); test('abort signal is passed into Pi complete',async()=>{const ac=new AbortController(); let saw:AbortSignal|undefined; const proxy=await startModelProxy({model:{id:'m'},signal:ac.signal,completeFn:(async(_m:any,_ctx:any,o:any)=>{saw=o.signal; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); assert.equal(saw,ac.signal); assert.deepEqual(zeroUsage(),{prompt_tokens:0,completion_tokens:0,total_tokens:0});}finally{await proxy.close();}}); -test('/villani-proxy-test sends real request using mocked complete function and hides credentials',async()=>{const notes:string[]=[]; const model={id:'m'}; await proxyTest({model,modelRegistry:{getApiKeyAndHeaders:async()=>({ok:true,apiKey:'sk-secret',headers:{Authorization:'Bearer abc'}})},ui:{notify:(m:string)=>notes.push(m)}, completeFn:async()=>({content:'READY'})}); assert.match(notes.join('\n'),/Villani proxy test succeeded/); assert.doesNotMatch(notes.join('\n'),/sk-secret|Bearer abc/);}); -import { readFileSync } from 'node:fs'; +test('/villani-proxy-test sends real request using mocked complete function, reports selected helper, and hides credentials',async()=>{const notes:string[]=[]; const model={id:'m'}; await proxyTest({model,modelRegistry:{getApiKeyAndHeaders:async()=>({ok:true,apiKey:'sk-secret',headers:{Authorization:'Bearer abc'}})},ui:{notify:(m:string)=>notes.push(m)}, completeFn:async()=>({content:'READY'}),completeSource:'test:complete'}); const msg=notes.join('\n'); assert.match(msg,/Villani proxy test succeeded/); assert.match(msg,/selected Pi completion helper source: test:complete/); assert.match(msg,/active model id: m/); assert.match(msg,/auth resolution succeeded: true/); assert.doesNotMatch(msg,/sk-secret|Bearer abc/);}); -test('modelProxy imports primary @earendil-works/pi-ai complete and not compat',()=>{const src=readFileSync(new URL('../src/modelProxy.ts',import.meta.url),'utf8'); assert.match(src,/from '@earendil-works\/pi-ai';/); assert.doesNotMatch(src,/@earendil-works\/pi-ai\/compat/);}); +test('resolvePiComplete finds top-level complete',async()=>{const fn=()=>{}; const r=await resolvePiComplete(async()=>({complete:fn})); assert.equal(r.fn,fn); assert.equal(r.source,'@earendil-works/pi-ai:complete');}); +test('resolvePiComplete finds default.complete',async()=>{const fn=()=>{}; const r=await resolvePiComplete(async()=>({default:{complete:fn}})); assert.equal(r.fn,fn); assert.equal(r.source,'@earendil-works/pi-ai:default.complete');}); +test('resolvePiComplete finds compat.complete',async()=>{const fn=()=>{}; const r=await resolvePiComplete(async()=>({compat:{complete:fn}})); assert.equal(r.fn,fn); assert.equal(r.source,'@earendil-works/pi-ai:compat.complete');}); +test('resolvePiComplete tries compat package if primary lacks complete',async()=>{const fn=()=>{}; const seen:string[]=[]; const r=await resolvePiComplete(async(name)=>{seen.push(name); return name.endsWith('/compat')?{complete:fn}:{};}); assert.deepEqual(seen,['@earendil-works/pi-ai','@earendil-works/pi-ai/compat']); assert.equal(r.source,'@earendil-works/pi-ai/compat:complete');}); +test('no compatible helper produces a clear visible error',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeImporter:async()=>({})}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.match(txt,/Active Pi model cannot be proxied: no supported completion API found/); assert.doesNotMatch(txt,/PiAI\.complete is unavailable/);}finally{await proxy.close();}}); From afc58ff81b6221cfafeb7ef5ce4e8f124e75ae9c Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 20:50:18 +1000 Subject: [PATCH 17/36] Fix Pi bridge stdio run acknowledgement --- integrations/pi-villani/src/extension.test.ts | 2 + integrations/pi-villani/src/index.ts | 2 +- integrations/pi-villani/src/render.ts | 2 +- tests/integrations/test_pi_bridge.py | 55 +++++++- villani_code/integrations/pi_bridge.py | 127 ++++++++++++++---- 5 files changed, 156 insertions(+), 32 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 3676e209..60ec2bcf 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -31,3 +31,5 @@ test('bridge exit before ready with ModuleNotFoundError produces pip install dia test('/villani-bridge-ping succeeds with fake bridge',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split(/\\n/)){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')send({type:'pong'});}}); setTimeout(()=>{},500);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{await bridgePing({ui:{notify:(m:string)=>notes.push(m)}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old;} assert.match(notes.join('\n'),/Villani bridge ping succeeded/);}); test('/villani-bridge-ping surfaces stderr on failure',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript("console.error('boom stderr'); process.exit(2);\n"); process.env.VILLANI_COMMAND=p; try{await assert.rejects(()=>bridgePing({ui:{notify:()=>{}}}),/boom stderr/);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old;} }); + +test('/villani surfaces bridge error before run_started without waiting 10 seconds',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='run')send({type:'error',id:msg.id,error:'bad run command'});}}); setTimeout(()=>{},15000);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; const started=Date.now(); try{process.env.VILLANI_USE_PI_MODEL='false'; await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/bad run command/); assert.ok(Date.now()-started<5000);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} assert.doesNotMatch(notes.join('\n'),/Villani run started\./);}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 499677a1..944339c7 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -22,7 +22,7 @@ From repo root, run: ${msg}`; return msg;} async function assertBridgePing(executable:string, ctx:any, env?:NodeJS.ProcessEnv, signal?:AbortSignal){const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000}); try{await bridge.waitUntilReady(undefined,signal); bridge.send({type:'ping'}); const pong=await bridge.waitForEvent('pong',5000,signal); if(!pong) throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); await notify(ctx,'Villani bridge ping succeeded.','info'); return true;}catch(e){throw new Error(bridgeDiagnosticMessage(e));}finally{bridge.kill(); await bridge.waitForExit(1500).catch(()=>{});}} -async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const started=await bridge.waitForEvent('run_started',10000,signal); if(!started) throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`); return started;} +async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['run_started','error','run_failed','run_aborted'],runId,10000,signal); if(event?.type==='run_started') return event; if(event){const msg=event.error||event.message||event.summary||event.type; throw new Error(`Villani bridge rejected run command: ${msg}`);} throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`);} async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000,signal); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); await notify(ctx,`active model id: ${model?.id??'unavailable'}`,'info'); await notify(ctx,`modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,'info'); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'auth resolution succeeded: true','info'); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn,completeSource:ctx.completeSource,completeImporter:ctx.completeImporter,pi:ctx.pi}); try{ await notify(ctx,`proxy URL: ${proxy.url}`,'info'); await notify(ctx,'POST /v1/chat/completions reached proxy: sending','info'); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); await notify(ctx,'POST /v1/chat/completions reached proxy: yes','info'); await notify(ctx,`selected Pi completion helper source: ${proxy.completionSource??'unavailable'}`,'info'); const text=await r.text(); if(!r.ok){await notify(ctx,`complete returned or failed: failed (${r.status})`,'info'); throw new Error(`HTTP ${r.status}: ${text}`);} await notify(ctx,'complete returned or failed: returned','info'); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 74085a56..017e0eaf 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -16,4 +16,4 @@ export async function confirm(ctx: any, title: string, message: string): Promise } export function visibleChangedFiles(files:string[]=[]){return files.filter(f=>!/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/.test(f));} export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; return [head,event.summary||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:''].filter(Boolean).join('\n\n');} -export async function renderBridgeEvent(event:any, pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']); if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.message||'unknown error'}`, 'error'); if(['run_completed','run_failed','run_aborted'].includes(event.type)) await sendMessage(pi, finalMessage(event)); } +export async function renderBridgeEvent(event:any, pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']); if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); if(['run_completed','run_failed','run_aborted'].includes(event.type)) await sendMessage(pi, finalMessage(event)); } diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py index ea683b26..450d4db4 100644 --- a/tests/integrations/test_pi_bridge.py +++ b/tests/integrations/test_pi_bridge.py @@ -1,6 +1,6 @@ from __future__ import annotations -import io, json, subprocess, tempfile, time +import io, json, os, subprocess, tempfile, time, threading from pathlib import Path from villani_code.execution import ExecutionBudget @@ -87,3 +87,56 @@ def test_changed_file_attribution_cases(tmp_path): for path in ['.villani/a','.villani_code/b','__pycache__/c.pyc','d.pyc']: q=tmp_path/path; q.parent.mkdir(parents=True,exist_ok=True); q.write_text('x') ch,pre=attributed_changed_files(tmp_path,before,hashes,set()); assert not any(x.startswith(('.villani','.villani_code','__pycache__')) or x.endswith('.pyc') for x in ch+pre) + +class BlockingRunner(DummyRunner): + def __init__(self, started=None, release=None, approval_callback=None, event_callback=None): + super().__init__(approval_callback,event_callback); self.started=started or threading.Event(); self.release=release or threading.Event() + def run(self, task, execution_budget=None): + self.started.set(); self.release.wait(2); return {'response':'ok','execution':{'completed':True}} + +def run_stdio_in_thread(bridge): + th=threading.Thread(target=bridge.run_stdio,daemon=True); th.start(); return th + +def test_run_stdio_emits_ready(): + b,out,_,_=make_bridge(); b.stdin=io.StringIO(''); b.run_stdio(); assert events(out)[0]['type']=='ready' + +def test_run_stdio_accepts_ping_and_returns_pong_without_stdin_eof(): + r,w=os.pipe(); inp=os.fdopen(r,'r'); out=io.StringIO(); b=PiBridge(stdin=inp,stdout=out,runner_factory=lambda *a: DummyRunner()) + th=run_stdio_in_thread(b); wait_for(out,lambda e:e['type']=='ready') + os.write(w,b'{"type":"ping","id":"p1"}\n'); wait_for(out,lambda e:e['type']=='pong' and e.get('id')=='p1') + assert th.is_alive(); os.close(w); th.join(2) + +def test_run_stdio_accepts_run_command_and_emits_run_started(tmp_path): + b,out,_,_=make_bridge(); b.stdin=io.StringIO(json.dumps(run_cmd(tmp_path))+"\n"); b.run_stdio(); assert any(e['type']=='run_started' for e in events(out)) + +def test_run_stdio_keeps_running_after_stdin_closes_while_active_run_is_running(tmp_path): + started=threading.Event(); release=threading.Event(); runner=BlockingRunner(started,release) + b,out,_,_=make_bridge(runner); b.stdin=io.StringIO(json.dumps(run_cmd(tmp_path))+"\n") + th=run_stdio_in_thread(b); wait_for(out,lambda e:e['type']=='run_started'); assert started.wait(1); time.sleep(0.05); assert th.is_alive(); release.set(); th.join(2); assert not th.is_alive() + +def test_run_started_is_emitted_before_runner_factory_is_called(tmp_path): + out=io.StringIO(); order=[] + def factory(cmd,event_callback,approval_callback): + order.append([e['type'] for e in events(out)]); return DummyRunner(approval_callback,event_callback) + b=PiBridge(stdin=io.StringIO(json.dumps(run_cmd(tmp_path))+"\n"),stdout=out,runner_factory=factory); b.run_stdio() + assert 'run_started' in order[0] + +def test_bridge_error_before_run_started_is_surfaced_by_stdio_loop(): + b,out,_,_=make_bridge(); b.stdin=io.StringIO('{"type":"run","id":"r"}\n'); b.run_stdio(); es=events(out); assert es[0]['type']=='ready'; assert any(e['type']=='error' and 'task is required' in e['error'] for e in es) + +def test_worker_events_are_drained_from_queue(tmp_path): + b,out,_,_=make_bridge(); b.stdin=io.StringIO(json.dumps(run_cmd(tmp_path,'stream'))+"\n"); b.run_stdio(); assert any(e['type']=='run_completed' for e in events(out)); assert b._events.empty() + +def test_approval_response_works_through_queued_stdin_command(tmp_path): + r,w=os.pipe(); inp=os.fdopen(r,'r'); out=io.StringIO(); b=PiBridge(stdin=inp,stdout=out,runner_factory=lambda *a: DummyRunner(a[2],a[1])) + th=run_stdio_in_thread(b); wait_for(out,lambda e:e['type']=='ready') + os.write(w,(json.dumps(run_cmd(tmp_path,'write'))+'\n').encode()); wait_for(out,lambda e:e['type']=='approval_required') + os.write(w,(json.dumps({'type':'approval_response','id':'r1','request_id':'r1:1','approved':False})+'\n').encode()); os.close(w) + th.join(2); assert any(e['type']=='approval_resolved' and e['approved'] is False for e in events(out)); assert any(e['type']=='run_completed' for e in events(out)) + +def test_abort_command_denies_pending_approvals_through_stdio(tmp_path): + r,w=os.pipe(); inp=os.fdopen(r,'r'); out=io.StringIO(); b=PiBridge(stdin=inp,stdout=out,runner_factory=lambda *a: DummyRunner(a[2],a[1])) + th=run_stdio_in_thread(b); wait_for(out,lambda e:e['type']=='ready') + os.write(w,(json.dumps(run_cmd(tmp_path,'approve-twice'))+'\n').encode()); wait_for(out,lambda e:e['type']=='approval_required') + os.write(w,(json.dumps({'type':'abort','id':'r1'})+'\n').encode()); os.close(w) + th.join(2); assert any(e['type']=='approval_resolved' and e['approved'] is False for e in events(out)); assert any(e['type']=='run_aborted' for e in events(out)) diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index 52115d89..90b8922f 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -1,5 +1,5 @@ from __future__ import annotations -import hashlib,json,os,subprocess,sys,threading,traceback +import hashlib,json,io,os,queue,subprocess,sys,threading,traceback from dataclasses import dataclass,field from pathlib import Path from typing import Any,Callable,TextIO @@ -89,15 +89,67 @@ class ActiveRun: command:RunCommand; abort_requested:threading.Event=field(default_factory=threading.Event); thread:threading.Thread|None=None; pending_approvals:dict[str,PendingApproval]=field(default_factory=dict); touched_files:set[str]=field(default_factory=set); approval_seq:int=0 class PiBridge: def __init__(self,*,stdin:TextIO|None=None,stdout:TextIO|None=None,stderr:TextIO|None=None,runner_factory:Callable[...,Any]|None=None)->None: - self.stdin=stdin or sys.stdin; self.stdout=stdout or sys.stdout; self.stderr=stderr or sys.stderr; self.runner_factory=runner_factory or build_default_runner; self.runs={}; self.lock=threading.Lock() + self.stdin=stdin or sys.stdin; self.stdout=stdout or sys.stdout; self.stderr=stderr or sys.stderr; self.runner_factory=runner_factory or build_default_runner + self._events: queue.Queue[dict[str, Any] | None]=queue.Queue(); self._active: dict[str, ActiveRun]={}; self._pending_approvals: dict[str, PendingApproval]={}; self._lock=threading.Lock() + self.runs=self._active; self.lock=self._lock; self._stdio_running=False def emit(self,e): self.stdout.write(to_json_line(e)); self.stdout.flush() + def _queue_event(self,e:dict[str,Any]|None): + if self._stdio_running: self._events.put(e) + elif e is not None: self.emit(e) + def _diagnostic(self,run_id:str|None,message:str,**extra:Any)->None: + ev={'type':'bridge_diagnostic','message':message} + if run_id: ev['id']=run_id + ev.update({k:v for k,v in extra.items() if k!='api_key'}) + self._queue_event(ev) + def _drain_events(self)->None: + while True: + try: ev=self._events.get_nowait() + except queue.Empty: return + if ev is not None: self.emit(ev) + def _stdin_reader(self,commands:queue.Queue[dict[str,Any]|None])->None: + try: + try: fd=self.stdin.fileno() + except (AttributeError,io.UnsupportedOperation,OSError,ValueError,TypeError): fd=None # type: ignore[name-defined] + if fd is not None: + buf=b'' + while True: + chunk=os.read(fd,4096) + if not chunk: break + buf += chunk + while b'\n' in buf: + raw,buf=buf.split(b'\n',1); line=raw.decode('utf-8',errors='replace') + if line.strip(): + try: commands.put(parse_json_line(line)) + except Exception as exc: self._queue_event({'type':'error','error':str(exc)}) + if buf.strip(): + try: commands.put(parse_json_line(buf.decode('utf-8',errors='replace'))) + except Exception as exc: self._queue_event({'type':'error','error':str(exc)}) + else: + for raw_line in self.stdin: + if not str(raw_line).strip(): continue + try: commands.put(parse_json_line(str(raw_line))) + except Exception as exc: self._queue_event({'type':'error','error':str(exc)}) + finally: + commands.put(None) + def run_stdio(self)->None: + self._stdio_running=True; self.emit(ready_event()); commands: queue.Queue[dict[str,Any]|None]=queue.Queue(); stdin_closed=False + threading.Thread(target=self._stdin_reader,args=(commands,),daemon=True).start() + while True: + self._drain_events() + if stdin_closed: + with self._lock: active=bool(self._active) + if not active: break + try: cmd=commands.get(timeout=0.02) + except queue.Empty: continue + else: + try: cmd=commands.get(timeout=0.02) + except queue.Empty: continue + if cmd is None: + stdin_closed=True; continue + self.handle(cmd) def run_forever(self): - self.emit(ready_event()) - for line in self.stdin: - if not line.strip(): continue - try: self.handle(parse_json_line(line)) - except Exception as exc: self.emit({'type':'error','error':str(exc)}) + self.run_stdio() def handle(self,p): try: t=p.get('type') @@ -109,49 +161,66 @@ def handle(self,p): except Exception as exc: self.emit({'type':'error','error':str(exc)}) def start_run(self,cmd): - with self.lock: - if cmd.id in self.runs: self.emit({'type':'error','id':cmd.id,'error':'Duplicate active run id'}); return - ar=ActiveRun(cmd); self.runs[cmd.id]=ar + repo=str(Path(cmd.repo)) + with self._lock: + if cmd.id in self._active: self.emit({'type':'error','id':cmd.id,'error':'Duplicate active run id'}); return + ar=ActiveRun(cmd); self._active[cmd.id]=ar + self._queue_event({'type':'bridge_diagnostic','id':cmd.id,'message':'run command received'}) + self._queue_event({'type':'run_started','id':cmd.id,'run_id':cmd.id,'task':cmd.task,'repo':repo,'mode':cmd.mode}) th=threading.Thread(target=self._worker,args=(ar,),daemon=True); ar.thread=th; th.start() def abort(self,run_id): - ar=self.runs.get(run_id) + with self._lock: ar=self._active.get(run_id) if not ar: self.emit({'type':'error','id':run_id,'error':'Unknown run id'}); return ar.abort_requested.set(); for req,p in list(ar.pending_approvals.items()): - ar.pending_approvals.pop(req,None); p.approved=False; p.ready.set() + ar.pending_approvals.pop(req,None) + with self._lock: self._pending_approvals.pop(req,None) + p.approved=False; p.ready.set() self.emit({'type':'abort_requested','id':run_id}) def approval(self,cmd): - ar=self.runs.get(cmd.id) + with self._lock: + p=self._pending_approvals.pop(cmd.request_id,None) + ar=self._active.get(cmd.id) + if p and ar: ar.pending_approvals.pop(cmd.request_id,None) if not ar: self.emit({'type':'error','id':cmd.id,'error':'Unknown run id'}); return - p=ar.pending_approvals.pop(cmd.request_id,None) if not p: self.emit({'type':'error','id':cmd.id,'error':'Unknown approval request id'}); return p.approved=cmd.approved; p.ready.set() def _worker(self,ar): cmd=ar.command; repo=Path(cmd.repo); before=git_changed_files(repo); before_hash=hash_files(repo,before) - self.emit({'type':'run_started','id':cmd.id}) + self._diagnostic(cmd.id,'run worker started') def ev(e): if e.get('type')=='approval_required': return - for m in map_runner_event(cmd.id,e): self.emit(m) + for m in map_runner_event(cmd.id,e): self._queue_event(m) def appr(tool,inp): if ar.abort_requested.is_set(): return False title,summary=summarize_approval_request(tool,inp); ar.approval_seq += 1; req=f'{cmd.id}:{ar.approval_seq}' - p=PendingApproval(cmd.id,req,tool); ar.pending_approvals[req]=p; self.emit({'type':'approval_required','id':cmd.id,'request_id':req,'tool':tool,'title':title,'summary':summary}) - p.ready.wait(); self.emit({'type':'approval_resolved','id':cmd.id,'request_id':req,'approved':bool(p.approved)}); return bool(p.approved) and not ar.abort_requested.is_set() + p=PendingApproval(cmd.id,req,tool) + with self._lock: ar.pending_approvals[req]=p; self._pending_approvals[req]=p + self._queue_event({'type':'approval_required','id':cmd.id,'request_id':req,'tool':tool,'title':title,'summary':summary}) + p.ready.wait(); self._queue_event({'type':'approval_resolved','id':cmd.id,'request_id':req,'approved':bool(p.approved)}); return bool(p.approved) and not ar.abort_requested.is_set() try: - if ar.abort_requested.is_set(): self.emit({'type':'run_aborted','id':cmd.id}); return - runner=self.runner_factory(cmd,ev,appr); result=run_existing_runner(runner,cmd) + if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return + source='pi-proxy' if cmd.config.pi_model_proxy else 'direct-config'; self._diagnostic(cmd.id,f'model configuration source={source} provider={cmd.config.provider} model={cmd.config.model} base_url={cmd.config.base_url}') + self._diagnostic(cmd.id,'creating runner') + runner=self.runner_factory(cmd,ev,appr) + self._diagnostic(cmd.id,'runner created; entering execution') + result=run_existing_runner(runner,cmd) + self._diagnostic(cmd.id,'runner returned result') changed,pre=attributed_changed_files(repo,before,before_hash,ar.touched_files) - if ar.abort_requested.is_set(): self.emit({'type':'run_aborted','id':cmd.id}); return + if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return ex=result.get('execution') if isinstance(result.get('execution'),dict) else {}; reason=ex.get('terminated_reason') or ex.get('reason') base={'id':cmd.id,'changed_files':changed,'preexisting_dirty_files':pre,'summary':extract_summary(result),'transcript_path':result.get('transcript_path'),'verification_passed':result.get('verification_passed'),'terminated_reason':reason} - if ex.get('completed') is False: self.emit({'type':'run_failed','success':False,'error':str(reason or 'Runner stopped before completion.'),**base}) - else: self.emit({'type':'run_completed','success':True,**base}) + if ex.get('completed') is False: self._queue_event({'type':'run_failed','success':False,'error':str(reason or 'Runner stopped before completion.'),**base}) + else: self._queue_event({'type':'run_completed','success':True,**base}) except Exception as exc: - if ar.abort_requested.is_set(): - self.emit({'type':'run_aborted','id':cmd.id}); return - print(traceback.format_exc(),file=self.stderr); self.emit({'type':'run_failed','id':cmd.id,'success':False,'error':str(exc),'summary':str(exc)}) + self._diagnostic(cmd.id,f'exception: {exc}') + if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return + print(traceback.format_exc(),file=self.stderr); self._queue_event({'type':'run_failed','id':cmd.id,'success':False,'error':str(exc),'summary':str(exc)}) finally: - for req,p in list(ar.pending_approvals.items()): ar.pending_approvals.pop(req,None); p.approved=False; p.ready.set() - self.runs.pop(cmd.id,None) -def main_stdio(): PiBridge().run_forever() + for req,p in list(ar.pending_approvals.items()): + ar.pending_approvals.pop(req,None) + with self._lock: self._pending_approvals.pop(req,None) + p.approved=False; p.ready.set() + with self._lock: self._active.pop(cmd.id,None) +def main_stdio(): PiBridge().run_stdio() if __name__=='__main__': main_stdio() From 4848e1a215fae376e0e2833c429fca3db0ce04b6 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 21:16:48 +1000 Subject: [PATCH 18/36] Fix Pi bridge subprocess stdin isolation --- integrations/pi-villani/src/extension.test.ts | 4 +++ integrations/pi-villani/src/index.ts | 4 +-- integrations/pi-villani/src/render.ts | 2 +- tests/integrations/test_pi_bridge.py | 35 +++++++++++++++++++ tests/test_state_runtime.py | 18 ++++++++++ villani_code/hooks.py | 2 ++ villani_code/integrations/pi_bridge.py | 18 ++++++++-- villani_code/state_runtime.py | 24 ++++++++++--- villani_code/tools.py | 8 ++--- villani_code/validation_loop.py | 2 +- 10 files changed, 102 insertions(+), 15 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 60ec2bcf..452b8d71 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -33,3 +33,7 @@ test('/villani-bridge-ping succeeds with fake bridge',async()=>{const old=proces test('/villani-bridge-ping surfaces stderr on failure',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript("console.error('boom stderr'); process.exit(2);\n"); process.env.VILLANI_COMMAND=p; try{await assert.rejects(()=>bridgePing({ui:{notify:()=>{}}}),/boom stderr/);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old;} }); test('/villani surfaces bridge error before run_started without waiting 10 seconds',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='run')send({type:'error',id:msg.id,error:'bad run command'});}}); setTimeout(()=>{},15000);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; const started=Date.now(); try{process.env.VILLANI_USE_PI_MODEL='false'; await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/bad run command/); assert.ok(Date.now()-started<5000);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} assert.doesNotMatch(notes.join('\n'),/Villani run started\./);}); + +test('/villani launches bridge with ctx cwd',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const d=mkdtempSync(join(tmpdir(),'pi-villani-cwd-')); const cwdFile=join(d,'cwd.txt'); const p=bridgeScript(readyPrelude+`import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(cwdFile)}, process.cwd()); process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'phase',id:msg.id,name:'x'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n`); process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; try{await runVillani('task',{sendMessage:()=>{}},{ui:{notify:()=>{}},cwd:d,model:{id:'m'}}); assert.equal(readFileSync(cwdFile,'utf8'),d);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;}}); + +test('VILLANI_PI_DEBUG renders bridge diagnostics visibly',async()=>{const old=process.env.VILLANI_PI_DEBUG; const notes:string[]=[]; try{process.env.VILLANI_PI_DEBUG='1'; const { renderBridgeEvent } = await import('./render.js'); await renderBridgeEvent({type:'bridge_diagnostic',message:'capturing initial git status'},{},{ui:{notify:(m:string)=>notes.push(m)}}); assert.match(notes.join('\n'),/capturing initial git status/);} finally{if(old===undefined) delete process.env.VILLANI_PI_DEBUG; else process.env.VILLANI_PI_DEBUG=old;}}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 944339c7..ff2ca6bb 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -21,10 +21,10 @@ From repo root, run: .\\.venv\\Scripts\\python.exe -m pip install -e . ${msg}`; return msg;} -async function assertBridgePing(executable:string, ctx:any, env?:NodeJS.ProcessEnv, signal?:AbortSignal){const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000}); try{await bridge.waitUntilReady(undefined,signal); bridge.send({type:'ping'}); const pong=await bridge.waitForEvent('pong',5000,signal); if(!pong) throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); await notify(ctx,'Villani bridge ping succeeded.','info'); return true;}catch(e){throw new Error(bridgeDiagnosticMessage(e));}finally{bridge.kill(); await bridge.waitForExit(1500).catch(()=>{});}} +async function assertBridgePing(executable:string, ctx:any, env?:NodeJS.ProcessEnv, signal?:AbortSignal){const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,cwd:ctx.cwd??process.cwd()}); try{await bridge.waitUntilReady(undefined,signal); bridge.send({type:'ping'}); const pong=await bridge.waitForEvent('pong',5000,signal); if(!pong) throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); await notify(ctx,'Villani bridge ping succeeded.','info'); return true;}catch(e){throw new Error(bridgeDiagnosticMessage(e));}finally{bridge.kill(); await bridge.waitForExit(1500).catch(()=>{});}} async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['run_started','error','run_failed','run_aborted'],runId,10000,signal); if(event?.type==='run_started') return event; if(event){const msg=event.error||event.message||event.summary||event.type; throw new Error(`Villani bridge rejected run command: ${msg}`);} throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`);} async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000,signal); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} -export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } +export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi,cwd:ctx.cwd??process.cwd()}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); await notify(ctx,`active model id: ${model?.id??'unavailable'}`,'info'); await notify(ctx,`modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,'info'); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'auth resolution succeeded: true','info'); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn,completeSource:ctx.completeSource,completeImporter:ctx.completeImporter,pi:ctx.pi}); try{ await notify(ctx,`proxy URL: ${proxy.url}`,'info'); await notify(ctx,'POST /v1/chat/completions reached proxy: sending','info'); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); await notify(ctx,'POST /v1/chat/completions reached proxy: yes','info'); await notify(ctx,`selected Pi completion helper source: ${proxy.completionSource??'unavailable'}`,'info'); const text=await r.text(); if(!r.ok){await notify(ctx,`complete returned or failed: failed (${r.status})`,'info'); throw new Error(`HTTP ${r.status}: ${text}`);} await notify(ctx,'complete returned or failed: returned','info'); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); run.abort.abort(); if(!aborted) run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} export async function bridgePing(ctx:any):Promise{ await notify(ctx,'Villani bridge ping starting...','info'); const executable=await resolveVillaniRuntime({}); await notify(ctx,`runtime executable: ${executable}`,'info'); await assertBridgePing(executable,ctx); } diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 017e0eaf..197c5274 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -16,4 +16,4 @@ export async function confirm(ctx: any, title: string, message: string): Promise } export function visibleChangedFiles(files:string[]=[]){return files.filter(f=>!/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/.test(f));} export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; return [head,event.summary||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:''].filter(Boolean).join('\n\n');} -export async function renderBridgeEvent(event:any, pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']); if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); if(['run_completed','run_failed','run_aborted'].includes(event.type)) await sendMessage(pi, finalMessage(event)); } +export async function renderBridgeEvent(event:any, pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']); if(event.type==='bridge_diagnostic'&&debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); else if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); if(['run_completed','run_failed','run_aborted'].includes(event.type)) await sendMessage(pi, finalMessage(event)); } diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py index 450d4db4..94a997c5 100644 --- a/tests/integrations/test_pi_bridge.py +++ b/tests/integrations/test_pi_bridge.py @@ -140,3 +140,38 @@ def test_abort_command_denies_pending_approvals_through_stdio(tmp_path): os.write(w,(json.dumps(run_cmd(tmp_path,'approve-twice'))+'\n').encode()); wait_for(out,lambda e:e['type']=='approval_required') os.write(w,(json.dumps({'type':'abort','id':'r1'})+'\n').encode()); os.close(w) th.join(2); assert any(e['type']=='approval_resolved' and e['approved'] is False for e in events(out)); assert any(e['type']=='run_aborted' for e in events(out)) + +def test_git_changed_files_uses_devnull_stdin(monkeypatch, tmp_path): + calls=[] + class Proc: + returncode=0; stdout='?? x.py\n'; stderr='' + def fake_run(*args, **kwargs): + calls.append(kwargs); return Proc() + monkeypatch.setattr('villani_code.integrations.pi_bridge.subprocess.run', fake_run) + assert git_changed_files(tmp_path)==['x.py'] + assert calls[0]['stdin'] is subprocess.DEVNULL + assert calls[0]['check'] is False + assert calls[0]['timeout']==10 + +def test_worker_diagnostics_order_before_runner(tmp_path): + out=io.StringIO(); order=[] + def factory(cmd,event_callback,approval_callback): + order.extend([e.get('message', e['type']) for e in events(out)]) + return DummyRunner(approval_callback,event_callback) + b=PiBridge(stdin=io.StringIO(),stdout=out,runner_factory=factory) + b.handle(run_cmd(tmp_path)) + wait_for(out,lambda e:e['type']=='run_completed') + assert 'run_started' in [e['type'] for e in events(out)] + assert 'capturing initial git status' in order + assert 'captured initial git status' in order + assert 'creating runner' in order + assert order.index('capturing initial git status') < order.index('captured initial git status') < order.index('creating runner') + +def test_worker_continues_when_git_status_times_out(monkeypatch, tmp_path): + def boom(_repo): + raise subprocess.TimeoutExpired(['git'], 10) + monkeypatch.setattr('villani_code.integrations.pi_bridge.git_changed_files', boom) + b,out,_,_=make_bridge() + b.handle(run_cmd(tmp_path)) + wait_for(out,lambda e:e['type']=='run_failed') + assert any(e['type']=='run_failed' for e in events(out)) diff --git a/tests/test_state_runtime.py b/tests/test_state_runtime.py index 417f52b1..695c3260 100644 --- a/tests/test_state_runtime.py +++ b/tests/test_state_runtime.py @@ -703,3 +703,21 @@ def test_diagnosis_confidence_weak_without_file_evidence(tmp_path: Path) -> None } confidence = state_runtime.classify_diagnosis_target_confidence(runner, diagnosis, failure_evidence=None) assert confidence == "weak" + +def test_git_changed_files_uses_devnull_stdin_and_is_safe(monkeypatch, tmp_path): + import subprocess + from villani_code import state_runtime + calls=[] + class Proc: + returncode=0; stdout=' M a.py\n'; stderr='' + def fake_run(*args, **kwargs): + calls.append(kwargs); return Proc() + monkeypatch.setattr(state_runtime.subprocess, 'run', fake_run) + assert state_runtime.git_changed_files(tmp_path)==['a.py'] + assert calls[0]['stdin'] is subprocess.DEVNULL + assert calls[0]['check'] is False + assert calls[0]['timeout']==10 + def timeout(*args, **kwargs): + raise subprocess.TimeoutExpired(args[0], 10) + monkeypatch.setattr(state_runtime.subprocess, 'run', timeout) + assert state_runtime.git_changed_files(tmp_path)==[] diff --git a/villani_code/hooks.py b/villani_code/hooks.py index 1cbd0f35..97c087d1 100644 --- a/villani_code/hooks.py +++ b/villani_code/hooks.py @@ -39,6 +39,8 @@ def run_event(self, event: str, payload: dict[str, Any]) -> HookResult: def _run_shell(self, command: str, payload: dict[str, Any]) -> HookResult: proc = subprocess.run( command, + # Hooks intentionally receive the payload on stdin; this uses a private pipe, + # not the bridge JSONL stdin. input=json.dumps(payload), capture_output=True, text=True, diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index 90b8922f..c5461d47 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -23,7 +23,15 @@ def summarize_approval_request(tool_name:str, tool_input:dict[str,Any])->tuple[s def git_changed_files(repo:Path)->list[str]: try: - r=subprocess.run(['git','status','--porcelain=v1','--untracked-files=all'],cwd=repo,text=True,capture_output=True,timeout=10) + r=subprocess.run( + ['git','status','--porcelain=v1','--untracked-files=all'], + cwd=repo, + text=True, + capture_output=True, + stdin=subprocess.DEVNULL, + timeout=10, + check=False, + ) if r.returncode: return [] files=[] for line in r.stdout.splitlines(): @@ -186,7 +194,7 @@ def approval(self,cmd): if not p: self.emit({'type':'error','id':cmd.id,'error':'Unknown approval request id'}); return p.approved=cmd.approved; p.ready.set() def _worker(self,ar): - cmd=ar.command; repo=Path(cmd.repo); before=git_changed_files(repo); before_hash=hash_files(repo,before) + cmd=ar.command; repo=Path(cmd.repo); before=[]; before_hash={} self._diagnostic(cmd.id,'run worker started') def ev(e): if e.get('type')=='approval_required': return @@ -200,12 +208,16 @@ def appr(tool,inp): p.ready.wait(); self._queue_event({'type':'approval_resolved','id':cmd.id,'request_id':req,'approved':bool(p.approved)}); return bool(p.approved) and not ar.abort_requested.is_set() try: if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return + self._diagnostic(cmd.id,'capturing initial git status') + before=git_changed_files(repo) + before_hash=hash_files(repo,before) + self._diagnostic(cmd.id,'captured initial git status') source='pi-proxy' if cmd.config.pi_model_proxy else 'direct-config'; self._diagnostic(cmd.id,f'model configuration source={source} provider={cmd.config.provider} model={cmd.config.model} base_url={cmd.config.base_url}') self._diagnostic(cmd.id,'creating runner') runner=self.runner_factory(cmd,ev,appr) self._diagnostic(cmd.id,'runner created; entering execution') result=run_existing_runner(runner,cmd) - self._diagnostic(cmd.id,'runner returned result') + self._diagnostic(cmd.id,'runner.run returned') changed,pre=attributed_changed_files(repo,before,before_hash,ar.touched_files) if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return ex=result.get('execution') if isinstance(result.get('execution'),dict) else {}; reason=ex.get('terminated_reason') or ex.get('reason') diff --git a/villani_code/state_runtime.py b/villani_code/state_runtime.py index be5af5bd..a60d3091 100644 --- a/villani_code/state_runtime.py +++ b/villani_code/state_runtime.py @@ -193,6 +193,9 @@ def run_pre_edit_failure_localization(runner: Any) -> dict[str, Any] | None: cwd=isolated_repo, capture_output=True, text=True, + stdin=subprocess.DEVNULL, + timeout=getattr(runner, "verification_timeout_sec", 60), + check=False, ) except Exception as exc: # pragma: no cover - defensive path runner.event_callback( @@ -607,7 +610,20 @@ def truncate_tool_result(tool_name: str, result: dict[str, Any]) -> dict[str, An def git_changed_files(repo: Any) -> list[str]: - proc = subprocess.run(["git", "status", "--short"], cwd=repo, capture_output=True, text=True) + try: + proc = subprocess.run( + ["git", "status", "--short"], + cwd=repo, + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + timeout=10, + check=False, + ) + except Exception: + return [] + if proc.returncode != 0: + return [] return [line[3:].strip() for line in proc.stdout.splitlines() if line.strip()] @@ -710,7 +726,7 @@ def _run_patch_sanity_check(runner: Any) -> dict[str, Any]: } cmd = [sys.executable, "-m", "py_compile", *checked_files] - proc = subprocess.run(cmd, cwd=runner.repo, capture_output=True, text=True) + proc = subprocess.run(cmd, cwd=runner.repo, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=60, check=False) stdout = proc.stdout.strip() stderr = proc.stderr.strip() if proc.returncode != 0: @@ -769,7 +785,7 @@ def _run_patch_sanity_check(runner: Any) -> dict[str, Any]: } collect_cmd = [sys.executable, "-m", "pytest", "--collect-only", "-q"] - collect_proc = subprocess.run(collect_cmd, cwd=runner.repo, capture_output=True, text=True) + collect_proc = subprocess.run(collect_cmd, cwd=runner.repo, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=60, check=False) collect_stdout = collect_proc.stdout.strip() collect_stderr = collect_proc.stderr.strip() telemetry["collection_sanity_ran"] = True @@ -988,7 +1004,7 @@ def run_verification(runner: Any, trigger: str = "edit") -> str: lines.append("next: inspect locked file, produce one bounded patch, or stop") cmd_results: list[dict[str, Any]] = [] for cmd in commands: - proc = subprocess.run(cmd, cwd=runner.repo, capture_output=True, text=True) + proc = subprocess.run(cmd, cwd=runner.repo, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=60, check=False) stderr_lines = "\n".join([ln for ln in proc.stderr.splitlines() if ln][:5]) stdout = proc.stdout[:1500] cmd_results.append( diff --git a/villani_code/tools.py b/villani_code/tools.py index 54a8c090..f6c79be6 100644 --- a/villani_code/tools.py +++ b/villani_code/tools.py @@ -272,7 +272,7 @@ def _run_grep( cmd = [rg_bin, "-n", data.pattern, str(base)] if data.include_hidden: cmd.append("--hidden") - proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env) + proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL) return "\n".join(proc.stdout.splitlines()[: data.max_results]) return "" @@ -295,7 +295,7 @@ def _run_search( return "" base = _safe_path(repo, data.path) cmd = [rg_bin, "-n", "-C", str(data.context_lines), data.query, str(base)] - proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env) + proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL) return proc.stdout @@ -309,7 +309,7 @@ def _run_bash(data: BashInput, repo: Path, unsafe: bool, debug_callback: Any | N if callable(debug_callback): debug_callback("command_started", {"command": data.command, "cwd": data.cwd, "tool_call_id": tool_call_id}) env = _command_environment(repo, data.command, cwd, debug_callback, tool_call_id, private_roots, shell=True) - proc = subprocess.run(data.command, shell=True, cwd=str(cwd), capture_output=True, text=True, timeout=data.timeout_sec, env=env) + proc = subprocess.run(data.command, shell=True, cwd=str(cwd), capture_output=True, text=True, timeout=data.timeout_sec, env=env, stdin=subprocess.DEVNULL) if callable(debug_callback): debug_callback( "command_finished", @@ -414,5 +414,5 @@ def _run_git(name: str, data: GitSimpleInput, repo: Path, debug_callback: Any | } cmd = ["git", *mapping[name], *data.args] env = _command_environment(repo, cmd, repo, debug_callback, tool_call_id, private_roots) - proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env) + proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL) return proc.stdout or proc.stderr diff --git a/villani_code/validation_loop.py b/villani_code/validation_loop.py index 5661b150..bc702696 100644 --- a/villani_code/validation_loop.py +++ b/villani_code/validation_loop.py @@ -315,7 +315,7 @@ def run_validation(repo: Path, changed_files: list[str], event_callback: Any | N if event_callback: event_callback({"type": "validation_step_started", "name": step.name, "command": command, "reasons": planned_step.reasons}) started = time.monotonic() - proc = subprocess.run(command, shell=True, cwd=repo, text=True, capture_output=True) + proc = subprocess.run(command, shell=True, cwd=repo, text=True, capture_output=True, stdin=subprocess.DEVNULL) result = ValidationStepResult(step, command, proc.returncode, proc.stdout, proc.stderr, time.monotonic() - started, planned_step.reasons) results.append(result) if event_callback: From 9e63fa62eaf4e9b3ac3f38ffc2d8377b7e278a2e Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 21:27:21 +1000 Subject: [PATCH 19/36] Fix Pi final message shape --- integrations/pi-villani/src/extension.test.ts | 8 ++++ integrations/pi-villani/src/index.ts | 6 +-- integrations/pi-villani/src/render.ts | 45 +++++++++++++++---- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 452b8d71..a78e7a0a 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -37,3 +37,11 @@ test('/villani surfaces bridge error before run_started without waiting 10 secon test('/villani launches bridge with ctx cwd',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const d=mkdtempSync(join(tmpdir(),'pi-villani-cwd-')); const cwdFile=join(d,'cwd.txt'); const p=bridgeScript(readyPrelude+`import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(cwdFile)}, process.cwd()); process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'phase',id:msg.id,name:'x'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n`); process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; try{await runVillani('task',{sendMessage:()=>{}},{ui:{notify:()=>{}},cwd:d,model:{id:'m'}}); assert.equal(readFileSync(cwdFile,'utf8'),d);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;}}); test('VILLANI_PI_DEBUG renders bridge diagnostics visibly',async()=>{const old=process.env.VILLANI_PI_DEBUG; const notes:string[]=[]; try{process.env.VILLANI_PI_DEBUG='1'; const { renderBridgeEvent } = await import('./render.js'); await renderBridgeEvent({type:'bridge_diagnostic',message:'capturing initial git status'},{},{ui:{notify:(m:string)=>notes.push(m)}}); assert.match(notes.join('\n'),/capturing initial git status/);} finally{if(old===undefined) delete process.env.VILLANI_PI_DEBUG; else process.env.VILLANI_PI_DEBUG=old;}}); + +test('sendDurableVillaniMessage sends a structured iterable custom message',async()=>{const { sendDurableVillaniMessage } = await import('./render.js'); const sent:any[]=[]; await sendDurableVillaniMessage({sendMessage:(m:any)=>sent.push(m)},{ui:{notify:()=>assert.fail('notify should not be used')}} ,'hello',{type:'run_completed',authorization:'Bearer secret',headers:{authorization:'Bearer secret'},apiKey:'secret',token:'secret'}); assert.equal(sent.length,1); assert.notEqual(typeof sent[0],'string'); assert.equal(sent[0].customType,'villani-result'); assert.equal(sent[0].display,true); assert.deepEqual(sent[0].content,[{type:'text',text:'hello'}]); assert.ok(Symbol.iterator in Object(sent[0].content)); assert.doesNotMatch(JSON.stringify(sent[0].details),/secret|authorization|apiKey|token/i);}); + +test('sendDurableVillaniMessage falls back to ctx.ui.notify if Pi sendMessage throws',async()=>{const { sendDurableVillaniMessage } = await import('./render.js'); const notes:string[]=[]; await sendDurableVillaniMessage({sendMessage:async()=>{throw new Error('boom');}},{ui:{notify:(m:string,level:string)=>notes.push(`${level}:${m}`)}} ,'fallback text'); assert.deepEqual(notes,['info:fallback text']);}); + +test('renderBridgeEvent does not send durable final messages',async()=>{const { renderBridgeEvent } = await import('./render.js'); let sent=0; const notes:string[]=[]; for(const type of ['run_completed','run_failed','run_aborted']) await renderBridgeEvent({type,summary:'done'},{sendMessage:()=>sent++},{ui:{notify:(m:string)=>notes.push(m)}}); assert.equal(sent,0); assert.deepEqual(notes,[]);}); + +test('/villani sends exactly one iterable final durable message with summary metadata',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'model_request_started',id:msg.id}); send({type:'run_completed',id:msg.id,summary:'done',changed_files:['src/a.ts','.villani/secret'],transcript_path:'/tmp/transcript.jsonl',verification_status:'passed',authorization:'Bearer secret',headers:{authorization:'Bearer secret'}});}}); setTimeout(()=>{},500);\n"); const sent:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:any)=>sent.push(m)},{ui:{notify:()=>{}},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.equal(sent.length,1); const payload=sent[0]; assert.equal(payload.customType,'villani-result'); assert.deepEqual(payload.content,[{type:'text',text:payload.content[0].text}]); assert.ok(Array.isArray(payload.content)); assert.match(payload.content[0].text,/Villani completed/); assert.match(payload.content[0].text,/done/); assert.match(payload.content[0].text,/src\/a\.ts/); assert.doesNotMatch(payload.content[0].text,/\.villani\/secret/); assert.match(payload.content[0].text,/Transcript: \/tmp\/transcript\.jsonl/); assert.match(payload.content[0].text,/Verification: passed/); assert.doesNotMatch(JSON.stringify(payload.details),/authorization|Bearer secret/i);}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index ff2ca6bb..b8c41f43 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -4,7 +4,7 @@ import { resolveVillaniRuntime, cacheRoot } from './runtime.js'; import { VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; import { VillaniBridgeProcess } from './process.js'; import { resolvePiModel, sanitizeError, startModelProxyFromPiModel } from './modelProxy.js'; -import { confirm, finalMessage, notify, renderBridgeEvent, sendMessage, setStatus } from './render.js'; +import { confirm, finalMessage, notify, renderBridgeEvent, sendDurableVillaniMessage, setStatus } from './render.js'; type ActiveRun={id:string, abort:AbortController, bridge?:VillaniBridgeProcess, proxy?:{close:()=>void|Promise}, pending:Map}; let activeRun:ActiveRun|null=null; @@ -24,10 +24,10 @@ ${msg}`; return msg;} async function assertBridgePing(executable:string, ctx:any, env?:NodeJS.ProcessEnv, signal?:AbortSignal){const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,cwd:ctx.cwd??process.cwd()}); try{await bridge.waitUntilReady(undefined,signal); bridge.send({type:'ping'}); const pong=await bridge.waitForEvent('pong',5000,signal); if(!pong) throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); await notify(ctx,'Villani bridge ping succeeded.','info'); return true;}catch(e){throw new Error(bridgeDiagnosticMessage(e));}finally{bridge.kill(); await bridge.waitForExit(1500).catch(()=>{});}} async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['run_started','error','run_failed','run_aborted'],runId,10000,signal); if(event?.type==='run_started') return event; if(event){const msg=event.error||event.message||event.summary||event.type; throw new Error(`Villani bridge rejected run command: ${msg}`);} throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`);} async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000,signal); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} -export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi,cwd:ctx.cwd??process.cwd()}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendMessage(pi, finalMessage(final)); } finally { if(activeRun===run){denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } +export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi,cwd:ctx.cwd??process.cwd()}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendDurableVillaniMessage(pi, ctx, finalMessage(final), final); } finally { if(activeRun===run){denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); await notify(ctx,`active model id: ${model?.id??'unavailable'}`,'info'); await notify(ctx,`modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,'info'); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'auth resolution succeeded: true','info'); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn,completeSource:ctx.completeSource,completeImporter:ctx.completeImporter,pi:ctx.pi}); try{ await notify(ctx,`proxy URL: ${proxy.url}`,'info'); await notify(ctx,'POST /v1/chat/completions reached proxy: sending','info'); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); await notify(ctx,'POST /v1/chat/completions reached proxy: yes','info'); await notify(ctx,`selected Pi completion helper source: ${proxy.completionSource??'unavailable'}`,'info'); const text=await r.text(); if(!r.ok){await notify(ctx,`complete returned or failed: failed (${r.status})`,'info'); throw new Error(`HTTP ${r.status}: ${text}`);} await notify(ctx,'complete returned or failed: returned','info'); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); run.abort.abort(); if(!aborted) run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} export async function bridgePing(ctx:any):Promise{ await notify(ctx,'Villani bridge ping starting...','info'); const executable=await resolveVillaniRuntime({}); await notify(ctx,`runtime executable: ${executable}`,'info'); await assertBridgePing(executable,ctx); } async function doctor(ctx:any){const cached=(()=>{try{return existsSync(cacheRoot())}catch{return false}})(); const lines=[`package version: 0.1.4`,`runtime version: ${VILLANI_RUNTIME_VERSION}`,`cwd: ${ctx.cwd??process.cwd()}`,`ctx.model exists: ${!!ctx.model}`,`ctx.model.id: ${ctx.model?.id??'unavailable'}`,`ctx.modelRegistry exists: ${!!ctx.modelRegistry}`,`ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,`VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL!==undefined}`,`VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND!==undefined}`,`runtime tag: ${VILLANI_RUNTIME_TAG}`,`cached runtime executable exists: ${cached}`,`active run: ${activeRun?'yes':'no'}`]; if(process.env.VILLANI_COMMAND){try{const executable=await resolveVillaniRuntime({}); await assertBridgePing(executable,ctx);}catch(e){lines.push(`VILLANI_COMMAND bridge ping failed: ${bridgeDiagnosticMessage(e)}`);}} await notify(ctx,lines.join('\n'),'info');} export default function activate(api:any){ const reg=(name:string,description:string,handler:(args:string,ctx:any)=>Promise)=>api.registerCommand(name,{description,handler}); reg('villani','Run Villani Code on a task',async(args,ctx)=>safeCommand(ctx,'Villani',()=>runVillani(args,ctx?.pi??api,ctx))); reg('villani-abort','Abort the active Villani run',async(_args,ctx)=>safeCommand(ctx,'Villani abort',async()=>{ await abortVillani(ctx); })); reg('villani-confirm-test','Test Villani approval UI',async(_args,ctx)=>safeCommand(ctx,'Villani confirmation test',async()=>{await notify(ctx,'Villani confirmation test starting...'); if(!ctx?.ui?.confirm){await notify(ctx,'Villani confirmation UI unavailable','warn'); return;} const ok=await confirm(ctx,'Villani confirmation test','Confirm Villani test?'); await notify(ctx,ok?'Villani confirmation accepted':'Villani confirmation rejected');})); reg('villani-ping','Check Villani extension loading',async(_args,ctx)=>safeCommand(ctx,'Villani ping',()=>notify(ctx,'Villani ping OK'))); reg('villani-doctor','Show Villani diagnostics',async(_args,ctx)=>safeCommand(ctx,'Villani doctor',()=>doctor(ctx))); reg('villani-proxy-test','Test Villani Pi model proxy',async(_args,ctx)=>safeCommand(ctx,'Villani proxy test',()=>proxyTest(ctx))); reg('villani-bridge-ping','Ping Villani bridge stdio',async(_args,ctx)=>safeCommand(ctx,'Villani bridge ping',()=>bridgePing(ctx))); try{api.onSessionStart?.(async(ctx:any)=>notify(ctx,'Villani extension loaded')); api.on?.('sessionStart',async(ctx:any)=>notify(ctx,'Villani extension loaded'));}catch{} } -export { notify, setStatus, sendMessage, confirm }; +export { notify, setStatus, sendDurableVillaniMessage, confirm }; diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 197c5274..cd488c6a 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -2,18 +2,47 @@ export async function notify(ctx: any, message: string, level: 'info'|'warn'|'er try { if (ctx?.ui?.notify) await ctx.ui.notify(message, level); else (level === 'error' ? console.error : console.log)(message); } catch { try { console.error(message); } catch {} } } export async function setStatus(ctx: any, message: string | undefined): Promise { try { if (ctx?.ui?.setStatus) await ctx.ui.setStatus(message); } catch {} } -export async function sendMessage(pi: any, message: string): Promise { +export async function sendDurableVillaniMessage(pi: any, ctx: any, message: string, details?: any): Promise { + const payload = { + customType: 'villani-result', + content: [{ type: 'text', text: message }], + display: true, + details: sanitizeDetails(details), + }; + try { - if (pi?.sendMessage) await pi.sendMessage(message); - else if (pi?.ui?.sendMessage) await pi.ui.sendMessage(message); - else if (pi?.ctx?.ui?.notify) await pi.ctx.ui.notify(message, 'info'); - else console.log(message); - } catch { try { console.log(message); } catch {} } + if (typeof pi?.sendMessage === 'function') { + await pi.sendMessage(payload); + return; + } + } catch { + // Fall back to notify below when Pi rejects the custom message. + } + + await notify(ctx, message, 'info'); } export async function confirm(ctx: any, title: string, message: string): Promise { try { if (ctx?.ui?.confirm) return !!(await ctx.ui.confirm(title, message)); } catch (e) { throw e; } return false; } export function visibleChangedFiles(files:string[]=[]){return files.filter(f=>!/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/.test(f));} -export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; return [head,event.summary||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:''].filter(Boolean).join('\n\n');} -export async function renderBridgeEvent(event:any, pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']); if(event.type==='bridge_diagnostic'&&debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); else if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); if(['run_completed','run_failed','run_aborted'].includes(event.type)) await sendMessage(pi, finalMessage(event)); } +function sanitizeDetails(value:any, seen=new WeakSet()):any{ + if(value===undefined||value===null) return value; + if(typeof value==='string'||typeof value==='number'||typeof value==='boolean') return value; + if(Array.isArray(value)) return value.map(v=>sanitizeDetails(v,seen)); + if(typeof value==='object'){ + if(seen.has(value)) return '[Circular]'; + seen.add(value); + const out:any={}; + for(const [key,entry] of Object.entries(value)){ + if(/(api[_-]?key|authorization|auth|bearer|token|secret|cookie|headers?)$/i.test(key)||/^(authorization|cookie)$/i.test(key)) continue; + out[key]=sanitizeDetails(entry,seen); + } + seen.delete(value); + return out; + } + return String(value); +} +function verificationStatus(event:any):string|undefined{const verification=event.verification_status??event.verificationStatus??event.verification; if(!verification)return undefined; if(typeof verification==='string') return `Verification: ${verification}`; if(typeof verification==='object'){const status=verification.status??verification.result??verification.outcome; return status?`Verification: ${status}`:undefined;} return `Verification: ${String(verification)}`;} +export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||event.changedFiles||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; const transcript=event.transcript_path||event.transcriptPath; const verification=verificationStatus(event); return [head,event.summary||event.error||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:'',transcript?`Transcript: ${transcript}`:'',verification].filter(Boolean).join('\n\n');} +export async function renderBridgeEvent(event:any, _pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved','model_request_started']); if(event.type==='bridge_diagnostic'&&debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); else if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); } From 0e5f7575ecf7e1c5a6011b0599f2b4a26a4572a1 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Sun, 28 Jun 2026 22:03:05 +1000 Subject: [PATCH 20/36] Restore Pi model proxy response conversion --- integrations/pi-villani/src/modelProxy.ts | 61 ++++++++++--------- integrations/pi-villani/src/proxy.test.ts | 18 ++++-- integrations/pi-villani/src/smoke.test.ts | 2 +- .../test_openai_stream_to_anthropic_events.py | 61 +++++++++++++++++++ 4 files changed, 108 insertions(+), 34 deletions(-) diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index 1e5752d9..34d2363d 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -1,7 +1,6 @@ -import http from 'node:http'; +import http, { type ServerResponse } from 'node:http'; import type { OpenAIMessage } from './protocol.js'; - export type PiCompleteResolver = (name:string)=>Promise; export async function resolvePiComplete(importer:PiCompleteResolver=(name)=>import(name)):Promise<{fn:any;source:string}>{ const candidates=['@earendil-works/pi-ai','@earendil-works/pi-ai/compat']; @@ -15,28 +14,46 @@ export async function resolvePiComplete(importer:PiCompleteResolver=(name)=>impo throw new Error('No compatible Pi completion helper found in @earendil-works/pi-ai or @earendil-works/pi-ai/compat.'); } +type Usage={input:number;output:number;totalTokens?:number}; +type AssistantMessage={role?:'assistant';content?:any[];responseId?:string;timestamp:number;responseModel?:string;model?:string;usage?:Usage;stopReason?:'stop'|'toolUse'|'length'|'error'|'aborted'|string;errorMessage?:string}; export interface PiModelProxyOptions { model:any; apiKey?:string; headers?:Record; signal?:AbortSignal; timeoutMs?:number; completeFn?:any; completeSource?:string; completeImporter?:PiCompleteResolver; pi?:any; } -export function zeroUsage(){return {prompt_tokens:0,completion_tokens:0,total_tokens:0};} -export function sanitizeError(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|VILLANI_API_KEY)=[^\s]+/ig,'$1=[redacted]').replace(/(authorization|api[-_]?key|x-api-key)["':= ]+[^,}\s]+/ig,'$1=[redacted]');} +export class UpstreamModelError extends Error{constructor(message:string){super(message);this.name='UpstreamModelError';}} +export class AbortRequestError extends Error{constructor(message='Pi model request aborted'){super(message);this.name='AbortRequestError';}} +export function abortError(){return new AbortRequestError();} +export function zeroUsage():Usage{return {input:0,output:0,totalTokens:0};} +export function sanitizeErrorMessage(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|VILLANI_API_KEY)=[^\s]+/ig,'$1=[redacted]').replace(/(authorization|api[-_]?key|x-api-key)["':= ]+[^,}\s]+/ig,'$1=[redacted]');} +export const sanitizeError=sanitizeErrorMessage; function debug(message:string){if(process.env.VILLANI_PI_DEBUG==='1') console.error(`[pi-villani proxy] ${message}`);} export function resolvePiModel(ctx:any):any{ if(process.env.VILLANI_USE_PI_MODEL==='false') return {id:process.env.VILLANI_MODEL||'villani-env-model', bypass:true}; const m=ctx?.model; if(!m) throw new Error('No active Pi model available. Select or launch Pi with a model, then retry.'); return m; } function textOf(content:any):string{if(content==null)return ''; if(typeof content==='string')return content; if(Array.isArray(content))return content.map(p=>typeof p==='string'?p:(p?.text??p?.content??'')).join(''); return String(content);} function timestampOf(m:any){return m?.timestamp??Date.now();} function contentBlocksOf(m:any){const blocks:any[]=[]; const text=textOf(m.content); if(text) blocks.push({type:'text',text}); for(const c of m.tool_calls||[]) blocks.push({type:'toolCall',...fromOpenAIToolCall(c)}); return blocks;} -function toPiContext(messages:OpenAIMessage[]=[], tools:any[]|undefined){const ctx:any={messages:[],tools:toPiTools(tools)}; const system:string[]=[]; for(const m of messages){if(m.role==='system'){system.push(textOf(m.content)); continue;} if(m.role==='user') ctx.messages.push({role:'user',content:textOf(m.content),timestamp:timestampOf(m)}); else if(m.role==='assistant') ctx.messages.push({role:'assistant',content:contentBlocksOf(m),api:'openai-completions',provider:'pi',model:m.model,usage:m.usage,stopReason:m.stopReason,timestamp:timestampOf(m)}); else if(m.role==='tool') ctx.messages.push({role:'toolResult',toolCallId:m.tool_call_id,toolName:m.name??m.toolName,content:[{type:'text',text:textOf(m.content)}],isError:false,timestamp:timestampOf(m)});} if(system.length) ctx.systemPrompt=system.join('\n'); return ctx;} +function openAIChatToPiContext(request:any){const messages:OpenAIMessage[]=request?.messages||[]; const ctx:any={messages:[],tools:toPiTools(request?.tools)}; const system:string[]=[]; for(const m of messages){if(m.role==='system'){system.push(textOf(m.content)); continue;} if(m.role==='user') ctx.messages.push({role:'user',content:textOf(m.content),timestamp:timestampOf(m)}); else if(m.role==='assistant'){const content=contentBlocksOf(m); ctx.messages.push({role:'assistant',content,api:'openai-completions',provider:'pi',model:m.model,usage:zeroUsage(),stopReason:content.some((block)=>block.type==='toolCall')?'toolUse':'stop',timestamp:timestampOf(m)});} else if(m.role==='tool') ctx.messages.push({role:'toolResult',toolCallId:m.tool_call_id,toolName:m.name??m.toolName,content:[{type:'text',text:textOf(m.content)}],isError:false,timestamp:timestampOf(m)});} if(system.length) ctx.systemPrompt=system.join('\n'); return ctx;} +function toPiContext(messages:OpenAIMessage[]=[], tools:any[]|undefined){return openAIChatToPiContext({messages,tools});} function toPiTools(tools:any[]|undefined){return tools?.map(t=>t?.function?{name:t.function.name,description:t.function.description,parameters:t.function.parameters}:t);} function fromOpenAIToolCall(c:any){return {id:c.id,name:c.name??c.function?.name,arguments:parseArgs(c.arguments??c.function?.arguments)};} function parseArgs(a:any){if(typeof a==='string'){try{return JSON.parse(a);}catch{return a;}} return a??{};} -function normCalls(calls:any[]|undefined){return (calls||[]).map(c=>({id:c.id||`call_${Math.random().toString(36).slice(2)}`,type:'function',function:{name:c.name??c.function?.name,arguments:typeof (c.arguments??c.function?.arguments)==='string'?(c.arguments??c.function?.arguments):JSON.stringify(c.arguments??c.function?.arguments??{})}}));} -function assistantFromPi(r:any){const content=Array.isArray(r?.content)?r.content:r?.message?.content??r?.content; let text=''; const calls:any[]=[]; if(Array.isArray(content)){for(const b of content){if(typeof b==='string') text+=b; else if(b?.type==='text') text+=b.text??''; else if(b?.type==='toolCall') calls.push(b);}} else text=content??r?.text??''; return {text:String(text??''), tool_calls:normCalls(calls), stopReason:r?.stopReason??r?.finishReason};} -function usage(r:any){const u=r?.usage??{}; const p=u.prompt_tokens??u.promptTokens??u.inputTokens??u.input??0; const c=u.completion_tokens??u.completionTokens??u.outputTokens??u.output??0; return {prompt_tokens:p,completion_tokens:c,total_tokens:u.total_tokens??u.totalTokens??u.total_tokens??p+c};} -function finishReason(a:any){if(a.stopReason==='toolUse')return 'tool_calls'; if(a.stopReason==='length')return 'length'; if(a.stopReason==='stop')return 'stop'; return a.tool_calls.length?'tool_calls':'stop';} -function throwForStopReason(a:any){if(a.stopReason==='error') throw new Error('Pi completion stopped with error.'); if(a.stopReason==='aborted'){const e=new Error('Pi completion aborted.'); (e as any).name='AbortError'; throw e;}} +function toOpenAIToolCalls(blocks:any[]){return blocks.filter((block)=>block?.type==='toolCall').map((block,index)=>({id:block.id||`call_${index}`,type:'function',function:{name:block.name??block.function?.name,arguments:typeof (block.arguments??block.function?.arguments)==='string'?(block.arguments??block.function?.arguments):JSON.stringify(block.arguments??block.function?.arguments??{})}}));} +export function toOpenAIFinishReason(reason:AssistantMessage['stopReason']):string{if(reason==='toolUse')return 'tool_calls'; if(reason==='length')return 'length'; return 'stop';} +export function toOpenAIUsage(usage:Usage):Record{return {prompt_tokens:usage.input,completion_tokens:usage.output,total_tokens:usage.totalTokens||usage.input+usage.output};} +export function piAssistantToOpenAIResponse(message:AssistantMessage):Record{ + if(message.stopReason==='error') throw new UpstreamModelError(message.errorMessage??'Pi model request failed'); + if(message.stopReason==='aborted') throw abortError(); + const content=Array.isArray(message.content)?message.content:[]; + const text=content.filter((block)=>block?.type==='text').map((block)=>String(block.text??'')).join('\n\n'); + const toolCalls=toOpenAIToolCalls(content); + const response={id:message.responseId??`pi-villani-${message.timestamp}`,object:'chat.completion',created:Math.floor(message.timestamp/1000),model:message.responseModel??message.model,choices:[{index:0,message:{role:'assistant',content:text||null,...(toolCalls.length?{tool_calls:toolCalls}:{})},finish_reason:toOpenAIFinishReason(message.stopReason)}],usage:toOpenAIUsage(message.usage??zeroUsage())}; + return response; +} +export function writeOpenAIStream(res:ServerResponse,message:AssistantMessage):void{const response=piAssistantToOpenAIResponse(message) as any; const choice=response.choices[0]; const fullMessage=choice.message; res.writeHead(200,{"content-type":"text/event-stream; charset=utf-8","cache-control":"no-cache",connection:"keep-alive"}); res.write(`data: ${JSON.stringify({id:response.id,object:"chat.completion.chunk",created:response.created,model:response.model,choices:[{index:0,delta:fullMessage,finish_reason:choice.finish_reason}],usage:response.usage})}\n\n`); res.write("data: [DONE]\n\n"); res.end();} +export function writeJson(res:ServerResponse,status:number,body:unknown){res.writeHead(status,{'content-type':'application/json'}); res.end(JSON.stringify(body));} +export function writeProxyError(res:ServerResponse,error:unknown){writeJson(res,500,{error:{message:sanitizeErrorMessage(error),type:'upstream_error',code:error instanceof AbortRequestError?'pi_completion_aborted':'pi_completion_failed'}});} +function normalizeAssistant(raw:any,payload:any,model:any):AssistantMessage{const now=Date.now(); if(Array.isArray(raw?.content)) return {...raw,timestamp:raw.timestamp??now,model:raw.model??payload.model??model?.id??'pi-current-model',usage:raw.usage??zeroUsage()}; const text=raw?.content??raw?.message?.content??raw?.text??''; return {...raw,content:text?[{type:'text',text:String(text)}]:[],timestamp:raw?.timestamp??now,model:raw?.model??payload.model??model?.id??'pi-current-model',usage:raw?.usage??zeroUsage()};} export class PiModelProxy { private server?:http.Server; completionSource?:string; constructor(private readonly options:PiModelProxyOptions){this.completionSource=options.completeSource;} async start():Promise{this.server=http.createServer((req,res)=>void this.handle(req,res)); if(this.options.signal) this.options.signal.addEventListener('abort',()=>void this.stop(),{once:true}); await new Promise(r=>this.server!.listen(0,'127.0.0.1',r)); const addr=this.server.address(); if(!addr||typeof addr==='string') throw new Error('Proxy bind failed'); const url=`http://127.0.0.1:${addr.port}`; debug(`listening on ${url}`); return url;} async stop():Promise{const s=this.server; if(!s)return; this.server=undefined; await new Promise(r=>s.close(()=>r()));} - private async handle(req:http.IncomingMessage,res:http.ServerResponse){if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} debug('POST /v1/chat/completions'); try{const body=await new Promise((resolve,reject)=>{let b=''; req.on('data',d=>b+=d); req.on('end',()=>resolve(b)); req.on('error',reject);}); const payload=JSON.parse(body||'{}'); const context=toPiContext(payload.messages||[],payload.tools); const raw=await this.complete(context,payload); const a=assistantFromPi(raw); throwForStopReason(a); const msg:any={role:'assistant',content:a.text}; if(a.tool_calls.length) msg.tool_calls=a.tool_calls; const base:any={id:'pi-villani',created:Math.floor(Date.now()/1000),model:payload.model||this.options.model?.id||'pi-current-model'}; const choice={index:0,finish_reason:finishReason(a)}; if(payload.stream){res.writeHead(200,{'content-type':'text/event-stream'}); res.write(`data: ${JSON.stringify({...base,object:'chat.completion.chunk',choices:[{...choice,delta:msg}]})}\n\n`); res.end('data: [DONE]\n\n');} else {res.writeHead(200,{'content-type':'application/json'}); res.end(JSON.stringify({...base,object:'chat.completion',choices:[{...choice,message:msg}],usage:usage(raw)}));}}catch(e){debug(`Pi complete failed: ${sanitizeError(e)}`); res.writeHead(500,{'content-type':'application/json'}); res.end(JSON.stringify({error:{message:sanitizeError(e),type:'upstream_error',code:'pi_completion_failed'}}));}} + private async handle(req:http.IncomingMessage,res:http.ServerResponse){if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} debug('POST /v1/chat/completions'); try{const body=await new Promise((resolve,reject)=>{let b=''; req.on('data',d=>b+=d); req.on('end',()=>resolve(b)); req.on('error',reject);}); const payload=JSON.parse(body||'{}'); const context=openAIChatToPiContext(payload); const assistant=normalizeAssistant(await this.complete(context,payload),payload,this.options.model); if(payload.stream) writeOpenAIStream(res,assistant); else writeJson(res,200,piAssistantToOpenAIResponse(assistant));}catch(e){debug(`Pi complete failed: ${sanitizeErrorMessage(e)}`); writeProxyError(res,e);}} private async complete(context:any,payload:any){ debug('calling Pi complete'); const opts={apiKey:this.options.apiKey,headers:this.options.headers,maxTokens:payload.max_tokens,temperature:payload.temperature,signal:this.options.signal,timeoutMs:this.options.timeoutMs}; @@ -46,27 +63,15 @@ export class PiModelProxy { private server?:http.Server; completionSource?:strin const r=await completeFn(this.options.model,context,opts); debug('Pi complete returned'); return r; } let resolveError:unknown; - try{ - const resolved=await resolvePiComplete(this.options.completeImporter); - completeFn=resolved.fn; this.completionSource=resolved.source; - }catch(e){ - resolveError=e; - } - if(typeof completeFn==='function'){ - const r=await completeFn(this.options.model,context,opts); debug('Pi complete returned'); return r; - } - debug(`Pi completion helper unavailable: ${sanitizeError(resolveError)}`); + try{const resolved=await resolvePiComplete(this.options.completeImporter); completeFn=resolved.fn; this.completionSource=resolved.source;}catch(e){resolveError=e;} + if(typeof completeFn==='function'){const r=await completeFn(this.options.model,context,opts); debug('Pi complete returned'); return r;} + debug(`Pi completion helper unavailable: ${sanitizeErrorMessage(resolveError)}`); const model=this.options.model; const fallbacks:[string,any][]=[['model.api.streamSimple',model?.api?.streamSimple],['model.complete',model?.complete],['ctx.pi.complete',this.options.pi?.complete]]; - for(const [source,fn] of fallbacks){ - if(typeof fn!=='function') continue; - this.completionSource=source; - const r=await fn.call(source==='ctx.pi.complete'?this.options.pi:(source==='model.complete'?model:model?.api),model,context,opts); - debug(`${source} returned`); return r; - } + for(const [source,fn] of fallbacks){if(typeof fn!=='function') continue; this.completionSource=source; const r=await fn.call(source==='ctx.pi.complete'?this.options.pi:(source==='model.complete'?model:model?.api),model,context,opts); debug(`${source} returned`); return r;} throw new Error('Active Pi model cannot be proxied: no supported completion API found.'); } } export async function startModelProxyFromPiModel(options:PiModelProxyOptions){const p=new PiModelProxy(options); const url=await p.start(); return {url,close:()=>p.stop(),proxy:p,get completionSource(){return p.completionSource;}};} export const startModelProxy=startModelProxyFromPiModel; -export const _test={toPiContext,assistantFromPi,sanitizeError}; +export const _test={toPiContext,openAIChatToPiContext,piAssistantToOpenAIResponse,writeOpenAIStream,sanitizeErrorMessage}; diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts index 6e4e11a9..3de8a1b7 100644 --- a/integrations/pi-villani/src/proxy.test.ts +++ b/integrations/pi-villani/src/proxy.test.ts @@ -1,4 +1,4 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; import { resolvePiComplete, resolvePiModel, startModelProxy, zeroUsage } from './modelProxy.js'; +import test from 'node:test'; import assert from 'node:assert/strict'; import { resolvePiComplete, resolvePiModel, startModelProxy, zeroUsage, _test } from './modelProxy.js'; import { resolveModelAuth, proxyTest } from './index.js'; test('model resolution uses ctx.model and fails clearly without one',()=>{assert.throws(()=>resolvePiModel({}),/No active Pi model/); assert.equal(resolvePiModel({model:{id:'m'} as any}).id,'m');}); @@ -6,15 +6,23 @@ test('resolveModelAuth calls ctx.modelRegistry.getApiKeyAndHeaders(model)',async test('auth failure throws clear sanitized error',async()=>{await assert.rejects(()=>resolveModelAuth({modelRegistry:{getApiKeyAndHeaders:async()=>({ok:false,error:'bad Bearer abc sk-secret OPENAI_API_KEY=abc'})}},{id:'m'}),/Villani could not resolve Pi model authentication: bad Bearer \[redacted\] \[redacted\] OPENAI_API_KEY=\[redacted\]/);}); test('proxy uses resolved complete path and passes auth',async()=>{let opts:any; const proxy=await startModelProxy({model:{id:'m',api:{streamSimple:async function*(){throw new Error('not preferred');}}},apiKey:'key',headers:{Authorization:'Bearer secret'},completeFn:(async(_m:any,ctx:any,o:any)=>{opts=o; assert.equal(ctx.systemPrompt,'sys'); assert.equal(ctx.messages[0].role,'user'); assert.equal(ctx.messages[0].content,'hi'); assert.equal(typeof ctx.messages[0].timestamp,'number'); return {content:'ok',usage:{input:2,output:1,totalTokens:3}};}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'system',content:'sys'},{role:'user',content:'hi'}]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'ok'); assert.deepEqual(j.usage,{prompt_tokens:2,completion_tokens:1,total_tokens:3}); assert.equal(opts.apiKey,'key'); assert.deepEqual(opts.headers,{Authorization:'Bearer secret'});}finally{await proxy.close();}}); test('unsupported paths return 404',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:''}) as any}); try{const r=await fetch(proxy.url+'/nope',{method:'POST'}); assert.equal(r.status,404);}finally{await proxy.close();}}); -test('OpenAI messages and tools convert into old Pi Context shape',async()=>{let seen:any; const assistantUsage={input:4,output:2,totalTokens:6}; const proxy=await startModelProxy({model:{id:'m'},completeFn:(async(_m:any,ctx:any)=>{seen=ctx; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'user',content:'hi'},{role:'assistant',content:'use',model:'gpt-test',usage:assistantUsage,stopReason:'toolUse',tool_calls:[{id:'c1',function:{name:'doit',arguments:'{"x":1}'}}]},{role:'tool',tool_call_id:'c1',name:'doit',content:'result'}],tools:[{type:'function',function:{name:'doit',description:'d',parameters:{type:'object'}}}]})}); assert.equal(seen.messages[0].role,'user'); assert.equal(seen.messages[0].content,'hi'); assert.equal(typeof seen.messages[0].timestamp,'number'); assert.equal(seen.messages[1].role,'assistant'); assert.deepEqual(seen.messages[1].content,[{type:'text',text:'use'},{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}]); assert.equal(seen.messages[1].api,'openai-completions'); assert.equal(seen.messages[1].provider,'pi'); assert.equal(seen.messages[1].model,'gpt-test'); assert.deepEqual(seen.messages[1].usage,assistantUsage); assert.equal(seen.messages[1].stopReason,'toolUse'); assert.equal(typeof seen.messages[1].timestamp,'number'); assert.deepEqual(seen.messages[2].content,[{type:'text',text:'result'}]); assert.equal(seen.messages[2].role,'toolResult'); assert.equal(seen.messages[2].toolCallId,'c1'); assert.equal(seen.messages[2].toolName,'doit'); assert.equal(seen.messages[2].isError,false); assert.equal(typeof seen.messages[2].timestamp,'number'); assert.deepEqual(seen.tools[0],{name:'doit',description:'d',parameters:{type:'object'}}); assert.equal('toolCalls' in seen.messages[1],false); assert.equal('inputSchema' in seen.tools[0],false);}finally{await proxy.close();}}); +test('OpenAI messages and tools convert into old Pi Context shape',async()=>{let seen:any; const assistantUsage={input:4,output:2,totalTokens:6}; const proxy=await startModelProxy({model:{id:'m'},completeFn:(async(_m:any,ctx:any)=>{seen=ctx; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'user',content:'hi'},{role:'assistant',content:'use',model:'gpt-test',usage:assistantUsage,stopReason:'toolUse',tool_calls:[{id:'c1',function:{name:'doit',arguments:'{"x":1}'}}]},{role:'tool',tool_call_id:'c1',name:'doit',content:'result'}],tools:[{type:'function',function:{name:'doit',description:'d',parameters:{type:'object'}}}]})}); assert.equal(seen.messages[0].role,'user'); assert.equal(seen.messages[0].content,'hi'); assert.equal(typeof seen.messages[0].timestamp,'number'); assert.equal(seen.messages[1].role,'assistant'); assert.deepEqual(seen.messages[1].content,[{type:'text',text:'use'},{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}]); assert.equal(seen.messages[1].api,'openai-completions'); assert.equal(seen.messages[1].provider,'pi'); assert.equal(seen.messages[1].model,'gpt-test'); assert.deepEqual(seen.messages[1].usage,zeroUsage()); assert.equal(seen.messages[1].stopReason,'toolUse'); assert.equal(typeof seen.messages[1].timestamp,'number'); assert.deepEqual(seen.messages[2].content,[{type:'text',text:'result'}]); assert.equal(seen.messages[2].role,'toolResult'); assert.equal(seen.messages[2].toolCallId,'c1'); assert.equal(seen.messages[2].toolName,'doit'); assert.equal(seen.messages[2].isError,false); assert.equal(typeof seen.messages[2].timestamp,'number'); assert.deepEqual(seen.tools[0],{name:'doit',description:'d',parameters:{type:'object'}}); assert.equal('toolCalls' in seen.messages[1],false); assert.equal('inputSchema' in seen.tools[0],false);}finally{await proxy.close();}}); test('Pi AssistantMessage toolCall converts to OpenAI tool_calls',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'tool_calls'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{await proxy.close();}}); -test('Pi finish reasons map to OpenAI values',async()=>{for(const [stopReason,toolCalls,expected] of [['toolUse',true,'tool_calls'],['length',false,'length'],['stop',false,'stop'],[undefined,true,'tool_calls'],[undefined,false,'stop']] as const){const content=toolCalls?[{type:'toolCall',id:'c1',name:'doit',arguments:{}}]:'ok'; const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content,stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,expected); assert.notEqual(j.choices[0].finish_reason,'toolUse');}finally{await proxy.close();}}}); +test('Pi finish reasons map to OpenAI values',async()=>{for(const [stopReason,toolCalls,expected] of [['toolUse',true,'tool_calls'],['length',false,'length'],['stop',false,'stop'],[undefined,true,'stop'],[undefined,false,'stop']] as const){const content=toolCalls?[{type:'toolCall',id:'c1',name:'doit',arguments:{}}]:'ok'; const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content,stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,expected); assert.notEqual(j.choices[0].finish_reason,'toolUse');}finally{await proxy.close();}}}); + + +test('non-streaming proxy response exactly matches Pi AssistantMessage conversion shape',async()=>{const assistant:any={content:[{type:'text',text:'hello'},{type:'text',text:'world'}],timestamp:1700000000000,responseId:'resp_1',responseModel:'pi-response-model',model:'pi-model',usage:{input:2,output:3,totalTokens:5},stopReason:'stop'}; const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>assistant}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.deepEqual(j,_test.piAssistantToOpenAIResponse(assistant) as any); assert.equal(j.choices[0].message.content,'hello\n\nworld');}finally{await proxy.close();}}); + +test('streaming proxy response preserves old OpenAI chunk shape',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'text',text:'hello'}],timestamp:1700000000000,model:'m',usage:{input:1,output:1,totalTokens:2},stopReason:'stop'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({stream:true})}); const txt=await r.text(); const chunk=JSON.parse(txt.split('\n').find((line)=>line.startsWith('data: {'))!.slice('data: '.length)); assert.equal(chunk.object,'chat.completion.chunk'); assert.equal(chunk.choices[0].delta.role,'assistant'); assert.equal(typeof chunk.choices[0].delta.content,'string'); assert.ok(['stop','length','tool_calls'].includes(chunk.choices[0].finish_reason)); assert.deepEqual(chunk.usage,{prompt_tokens:1,completion_tokens:1,total_tokens:2}); assert.match(txt,/data: \[DONE\]/);}finally{await proxy.close();}}); + +test('response conversion does not expose simplified assistantFromPi path',()=>{assert.equal('assistantFromPi' in _test,false);}); + test('streaming mode emits one SSE chunk plus DONE',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:'hi'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({stream:true})}); const txt=await r.text(); assert.match(txt,/data: .*hi/); assert.match(txt,/data: \[DONE\]/);}finally{await proxy.close();}}); -test('Pi stopReason error and aborted become upstream errors',async()=>{for(const stopReason of ['error','aborted']){const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'text',text:'bad'}],stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(r.status,500); assert.equal(j.error.type,'upstream_error'); assert.match(j.error.message,stopReason==='aborted'?/aborted/:/error/);}finally{await proxy.close();}}}); +test('Pi stopReason error and aborted become upstream errors',async()=>{for(const stopReason of ['error','aborted']){const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'text',text:'bad'}],stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(r.status,500); assert.equal(j.error.type,'upstream_error'); assert.match(j.error.message,stopReason==='aborted'?/aborted/:/Pi model request failed/);}finally{await proxy.close();}}}); test('upstream errors are sanitized and credentials are not exposed',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>{throw new Error('failed Bearer abc sk-secret OPENAI_API_KEY=abc Authorization: xyz');}}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.doesNotMatch(txt,/Bearer abc|sk-secret|OPENAI_API_KEY=abc|Authorization: xyz/);}finally{await proxy.close();}}); -test('abort signal is passed into Pi complete',async()=>{const ac=new AbortController(); let saw:AbortSignal|undefined; const proxy=await startModelProxy({model:{id:'m'},signal:ac.signal,completeFn:(async(_m:any,_ctx:any,o:any)=>{saw=o.signal; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); assert.equal(saw,ac.signal); assert.deepEqual(zeroUsage(),{prompt_tokens:0,completion_tokens:0,total_tokens:0});}finally{await proxy.close();}}); +test('abort signal is passed into Pi complete',async()=>{const ac=new AbortController(); let saw:AbortSignal|undefined; const proxy=await startModelProxy({model:{id:'m'},signal:ac.signal,completeFn:(async(_m:any,_ctx:any,o:any)=>{saw=o.signal; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); assert.equal(saw,ac.signal); assert.deepEqual(zeroUsage(),{input:0,output:0,totalTokens:0});}finally{await proxy.close();}}); test('/villani-proxy-test sends real request using mocked complete function, reports selected helper, and hides credentials',async()=>{const notes:string[]=[]; const model={id:'m'}; await proxyTest({model,modelRegistry:{getApiKeyAndHeaders:async()=>({ok:true,apiKey:'sk-secret',headers:{Authorization:'Bearer abc'}})},ui:{notify:(m:string)=>notes.push(m)}, completeFn:async()=>({content:'READY'}),completeSource:'test:complete'}); const msg=notes.join('\n'); assert.match(msg,/Villani proxy test succeeded/); assert.match(msg,/selected Pi completion helper source: test:complete/); assert.match(msg,/active model id: m/); assert.match(msg,/auth resolution succeeded: true/); assert.doesNotMatch(msg,/sk-secret|Bearer abc/);}); test('resolvePiComplete finds top-level complete',async()=>{const fn=()=>{}; const r=await resolvePiComplete(async()=>({complete:fn})); assert.equal(r.fn,fn); assert.equal(r.source,'@earendil-works/pi-ai:complete');}); diff --git a/integrations/pi-villani/src/smoke.test.ts b/integrations/pi-villani/src/smoke.test.ts index c9d9d97f..11578281 100644 --- a/integrations/pi-villani/src/smoke.test.ts +++ b/integrations/pi-villani/src/smoke.test.ts @@ -6,4 +6,4 @@ import { zeroUsage } from './modelProxy.js'; import { visibleChangedFiles } from './render.js'; test('runtime asset helpers',()=>{assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); assert.equal(executableRelativePath('win32-x64'),'villani-code/villani-code.exe');}); test('checksum parsing and env sanitization',()=>{assert.equal(parseChecksums('abc file.tgz').get('file.tgz'),'abc'); const e=sanitizedEnv({proxyMode:true,env:{OPENAI_API_KEY:'x',PATH:'p'}}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.PATH,'p');}); -test('usage and render filters',()=>{assert.deepEqual(zeroUsage(),{prompt_tokens:0,completion_tokens:0,total_tokens:0}); assert.deepEqual(visibleChangedFiles(['a.py','.villani/x','x.pyc']),['a.py']);}); +test('usage and render filters',()=>{assert.deepEqual(zeroUsage(),{input:0,output:0,totalTokens:0}); assert.deepEqual(visibleChangedFiles(['a.py','.villani/x','x.pyc']),['a.py']);}); diff --git a/tests/test_openai_stream_to_anthropic_events.py b/tests/test_openai_stream_to_anthropic_events.py index 50ca9b3e..7fa36554 100644 --- a/tests/test_openai_stream_to_anthropic_events.py +++ b/tests/test_openai_stream_to_anthropic_events.py @@ -1,4 +1,7 @@ +import json + from villani_code.openai_client import openai_stream_to_anthropic_events +from villani_code.streaming import assemble_anthropic_stream def test_openai_stream_text_events_to_anthropic(): @@ -40,3 +43,61 @@ def test_openai_stream_tool_call_arguments_emit_input_json_delta(): assert events[3]["delta"]["partial_json"] == '.txt","content":"x"}' assert events[-2] == {"type": "content_block_stop", "index": 1} assert events[-1] == {"type": "message_stop"} + + + +def test_pi_proxy_single_chunk_stream_assembles_text(): + chunk = { + "id": "pi-villani-1700000000000", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "pi-test", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + lines = [f"data: {json.dumps(chunk)}", "data: [DONE]"] + + response = assemble_anthropic_stream(openai_stream_to_anthropic_events(lines, model="pi-test")) + + assert response["content"][0]["text"] == "hello" + + +def test_pi_proxy_single_chunk_stream_assembles_tool_calls(): + chunk = { + "id": "pi-villani-1700000000000", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "pi-test", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "Write", "arguments": '{"file_path":"a.txt"}'}, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + lines = [f"data: {json.dumps(chunk)}", "data: [DONE]"] + + response = assemble_anthropic_stream(openai_stream_to_anthropic_events(lines, model="pi-test")) + + assert response["content"][1]["type"] == "tool_use" + assert response["content"][1]["name"] == "Write" + assert response["content"][1]["input"] == {"file_path": "a.txt"} From e56406dd4652fdb67c702108838af496b37ec9d6 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 07:46:39 +1000 Subject: [PATCH 21/36] Harden Pi tool call arguments --- integrations/pi-villani/src/modelProxy.ts | 5 ++-- integrations/pi-villani/src/proxy.test.ts | 12 ++++++++++ integrations/pi-villani/src/render.ts | 2 +- tests/test_bounded_execution.py | 12 ++++++++++ tests/test_openai_client.py | 6 +++++ .../test_openai_stream_to_anthropic_events.py | 23 +++++++++++++++++++ tests/test_streaming.py | 4 ++-- villani_code/integrations/pi_bridge.py | 8 ++++--- villani_code/openai_client.py | 17 ++++++++++---- villani_code/repair.py | 2 +- villani_code/state.py | 10 ++++++-- villani_code/streaming.py | 4 ++-- 12 files changed, 88 insertions(+), 17 deletions(-) diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index 34d2363d..3a5eca93 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -33,7 +33,8 @@ function toPiContext(messages:OpenAIMessage[]=[], tools:any[]|undefined){return function toPiTools(tools:any[]|undefined){return tools?.map(t=>t?.function?{name:t.function.name,description:t.function.description,parameters:t.function.parameters}:t);} function fromOpenAIToolCall(c:any){return {id:c.id,name:c.name??c.function?.name,arguments:parseArgs(c.arguments??c.function?.arguments)};} function parseArgs(a:any){if(typeof a==='string'){try{return JSON.parse(a);}catch{return a;}} return a??{};} -function toOpenAIToolCalls(blocks:any[]){return blocks.filter((block)=>block?.type==='toolCall').map((block,index)=>({id:block.id||`call_${index}`,type:'function',function:{name:block.name??block.function?.name,arguments:typeof (block.arguments??block.function?.arguments)==='string'?(block.arguments??block.function?.arguments):JSON.stringify(block.arguments??block.function?.arguments??{})}}));} +function normalizeToolArguments(value:unknown):Record{ if(value==null) return {}; if(typeof value==='string'){ const trimmed=value.trim(); if(!trimmed) return {}; try{ const parsed=JSON.parse(trimmed); if(parsed&&typeof parsed==='object'&&!Array.isArray(parsed)) return parsed as Record; return {}; }catch{return {};} } if(typeof value==='object'&&!Array.isArray(value)) return value as Record; return {}; } +function toOpenAIToolCalls(blocks:any[]){return blocks.filter((block)=>block?.type==='toolCall').map((block,index)=>{const rawArguments=block.arguments??block.function?.arguments; return {id:block.id||`call_${index}`,type:'function',function:{name:String(block.name??block.function?.name??''),arguments:JSON.stringify(normalizeToolArguments(rawArguments))}};});} export function toOpenAIFinishReason(reason:AssistantMessage['stopReason']):string{if(reason==='toolUse')return 'tool_calls'; if(reason==='length')return 'length'; return 'stop';} export function toOpenAIUsage(usage:Usage):Record{return {prompt_tokens:usage.input,completion_tokens:usage.output,total_tokens:usage.totalTokens||usage.input+usage.output};} export function piAssistantToOpenAIResponse(message:AssistantMessage):Record{ @@ -74,4 +75,4 @@ export class PiModelProxy { private server?:http.Server; completionSource?:strin } export async function startModelProxyFromPiModel(options:PiModelProxyOptions){const p=new PiModelProxy(options); const url=await p.start(); return {url,close:()=>p.stop(),proxy:p,get completionSource(){return p.completionSource;}};} export const startModelProxy=startModelProxyFromPiModel; -export const _test={toPiContext,openAIChatToPiContext,piAssistantToOpenAIResponse,writeOpenAIStream,sanitizeErrorMessage}; +export const _test={toPiContext,openAIChatToPiContext,piAssistantToOpenAIResponse,writeOpenAIStream,sanitizeErrorMessage,toOpenAIToolCalls,normalizeToolArguments}; diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts index 3de8a1b7..7d326fa7 100644 --- a/integrations/pi-villani/src/proxy.test.ts +++ b/integrations/pi-villani/src/proxy.test.ts @@ -30,3 +30,15 @@ test('resolvePiComplete finds default.complete',async()=>{const fn=()=>{}; const test('resolvePiComplete finds compat.complete',async()=>{const fn=()=>{}; const r=await resolvePiComplete(async()=>({compat:{complete:fn}})); assert.equal(r.fn,fn); assert.equal(r.source,'@earendil-works/pi-ai:compat.complete');}); test('resolvePiComplete tries compat package if primary lacks complete',async()=>{const fn=()=>{}; const seen:string[]=[]; const r=await resolvePiComplete(async(name)=>{seen.push(name); return name.endsWith('/compat')?{complete:fn}:{};}); assert.deepEqual(seen,['@earendil-works/pi-ai','@earendil-works/pi-ai/compat']); assert.equal(r.source,'@earendil-works/pi-ai/compat:complete');}); test('no compatible helper produces a clear visible error',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeImporter:async()=>({})}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.match(txt,/Active Pi model cannot be proxied: no supported completion API found/); assert.doesNotMatch(txt,/PiAI\.complete is unavailable/);}finally{await proxy.close();}}); + +test('toOpenAIToolCalls converts object arguments to JSON object string',()=>{const calls=(_test as any).toOpenAIToolCalls([{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}]); assert.equal(calls[0].function.arguments,'{"x":1}');}); + +test('toOpenAIToolCalls converts JSON string object arguments to JSON object string',()=>{const calls=(_test as any).toOpenAIToolCalls([{type:'toolCall',id:'c1',name:'doit',arguments:'{"path":"x"}'}]); assert.equal(calls[0].function.arguments,'{"path":"x"}');}); + +test('toOpenAIToolCalls converts invalid string arguments to empty object string',()=>{const calls=(_test as any).toOpenAIToolCalls([{type:'toolCall',id:'c1',name:'doit',arguments:'repo'}]); assert.equal(calls[0].function.arguments,'{}');}); + +test('toOpenAIToolCalls converts array string and number arguments to empty object string',()=>{for(const value of [[1], '"repo"', 42]){const calls=(_test as any).toOpenAIToolCalls([{type:'toolCall',id:'c1',name:'doit',arguments:value}]); assert.equal(calls[0].function.arguments,'{}');}}); + +test('streaming tool call response never emits raw non-object arguments',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:'repo'}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({stream:true})}); const txt=await r.text(); assert.doesNotMatch(txt,/"arguments":"repo"/); assert.match(txt,/"arguments":"\{\}"/);}finally{await proxy.close();}}); + +test('proxy text response conversion remains unchanged',()=>{const assistant:any={content:[{type:'text',text:'hello'},{type:'text',text:'world'}],timestamp:1700000000000,responseId:'resp_1',responseModel:'pi-response-model',usage:{input:2,output:3,totalTokens:5},stopReason:'stop'}; const converted:any=_test.piAssistantToOpenAIResponse(assistant); assert.equal(converted.choices[0].message.content,'hello\n\nworld'); assert.equal(converted.choices[0].message.tool_calls,undefined); assert.deepEqual(converted.usage,{prompt_tokens:2,completion_tokens:3,total_tokens:5});}); diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index cd488c6a..5027d766 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -45,4 +45,4 @@ function sanitizeDetails(value:any, seen=new WeakSet()):any{ } function verificationStatus(event:any):string|undefined{const verification=event.verification_status??event.verificationStatus??event.verification; if(!verification)return undefined; if(typeof verification==='string') return `Verification: ${verification}`; if(typeof verification==='object'){const status=verification.status??verification.result??verification.outcome; return status?`Verification: ${status}`:undefined;} return `Verification: ${String(verification)}`;} export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||event.changedFiles||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; const transcript=event.transcript_path||event.transcriptPath; const verification=verificationStatus(event); return [head,event.summary||event.error||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:'',transcript?`Transcript: ${transcript}`:'',verification].filter(Boolean).join('\n\n');} -export async function renderBridgeEvent(event:any, _pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; const progress = new Set(['ready','run_started','phase','tool_started','tool_finished','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved','model_request_started']); if(event.type==='bridge_diagnostic'&&debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); else if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(progress.has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); } +export async function renderBridgeEvent(event:any, _pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; if(event.type==='bridge_diagnostic'&&debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); else if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(event.type==='model_request_started') await notify(ctx, 'Villani diagnostic: model request started', 'info'); else if(event.type==='model_request_completed') await notify(ctx, 'Villani diagnostic: model response received', 'info'); else if(event.type==='tool_started') await notify(ctx, `Villani tool started: ${String(event.tool??event.name??'unknown')}`, 'info'); else if(event.type==='tool_finished') await notify(ctx, `Villani tool finished: ${String(event.tool??event.name??'unknown')}`, 'info'); else if(event.type==='stream_text'&&debug) await notify(ctx, `Villani: ${String(event.text||'').slice(0,240)}`, 'info'); else if(new Set(['ready','phase','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']).has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); } diff --git a/tests/test_bounded_execution.py b/tests/test_bounded_execution.py index de89a806..54713cf6 100644 --- a/tests/test_bounded_execution.py +++ b/tests/test_bounded_execution.py @@ -209,3 +209,15 @@ def test_explicit_no_change_needed_allows_prose_only_completion(tmp_path: Path) runner = _runner(tmp_path, [{"role": "assistant", "content": [{"type": "text", "text": "No code change is needed because behavior matches spec."}]}]) result = runner.run("Fix the bug", execution_budget=_default_budget()) assert result["execution"]["terminated_reason"] == "completed" + + +def test_runner_treats_string_tool_input_as_empty_dict(tmp_path: Path) -> None: + runner = _runner(tmp_path, [{"role": "assistant", "content": [{"type": "tool_use", "id": "1", "name": "Ls", "input": "repo"}]}]) + result = runner.run("work", execution_budget=ExecutionBudget(max_turns=1, max_tool_calls=1, max_seconds=100.0, max_no_edit_turns=20, max_reconsecutive_recon_turns=20)) + assert result["transcript"]["tool_invocations"][0]["input"] == {} + + +def test_runner_treats_list_tool_input_as_empty_dict(tmp_path: Path) -> None: + runner = _runner(tmp_path, [{"role": "assistant", "content": [{"type": "tool_use", "id": "1", "name": "Ls", "input": ["repo"]}]}]) + result = runner.run("work", execution_budget=ExecutionBudget(max_turns=1, max_tool_calls=1, max_seconds=100.0, max_no_edit_turns=20, max_reconsecutive_recon_turns=20)) + assert result["transcript"]["tool_invocations"][0]["input"] == {} diff --git a/tests/test_openai_client.py b/tests/test_openai_client.py index 0e0926c9..f4702a62 100644 --- a/tests/test_openai_client.py +++ b/tests/test_openai_client.py @@ -62,3 +62,9 @@ def test_convert_openai_response_preserves_usage_id_model_and_stop_reason() -> N assert converted["model"] == "gpt-4o-mini" assert converted["usage"] == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} assert converted["stop_reason"] == "end_turn" + + +def test_convert_openai_response_non_object_tool_arguments_are_empty() -> None: + response = {"id": "r", "model": "m", "choices": [{"finish_reason": "tool_calls", "message": {"role": "assistant", "tool_calls": [{"id": "call_1", "function": {"name": "Read", "arguments": '"repo"'}}]}}]} + converted = convert_openai_response_to_anthropic(response) + assert converted["content"][0]["input"] == {} diff --git a/tests/test_openai_stream_to_anthropic_events.py b/tests/test_openai_stream_to_anthropic_events.py index 7fa36554..48d0d964 100644 --- a/tests/test_openai_stream_to_anthropic_events.py +++ b/tests/test_openai_stream_to_anthropic_events.py @@ -101,3 +101,26 @@ def test_pi_proxy_single_chunk_stream_assembles_tool_calls(): assert response["content"][1]["type"] == "tool_use" assert response["content"][1]["name"] == "Write" assert response["content"][1]["input"] == {"file_path": "a.txt"} + + +def _assembled_tool_input(arguments: str): + chunk = { + "id": "pi-villani-1700000000000", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "pi-test", + "choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "call_1", "function": {"name": "Read", "arguments": arguments}}]}, "finish_reason": "tool_calls"}], + } + return assemble_anthropic_stream(openai_stream_to_anthropic_events([f"data: {json.dumps(chunk)}", "data: [DONE]"], model="pi-test"))["content"][1]["input"] + + +def test_openai_streaming_tool_call_object_arguments_produces_dict(): + assert _assembled_tool_input('{"path":"x"}') == {"path": "x"} + + +def test_openai_streaming_tool_call_json_string_arguments_produces_empty_dict(): + assert _assembled_tool_input('"repo"') == {} + + +def test_openai_streaming_tool_call_invalid_string_arguments_produces_empty_dict(): + assert _assembled_tool_input('repo') == {} diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 2d9c4e6e..053be82a 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -49,7 +49,7 @@ def test_assemble_input_json_delta_chunks_into_dict(): assert msg["content"][0]["input"] == {"file_path": "a.txt", "content": "hello"} -def test_assemble_input_json_delta_malformed_keeps_raw_string(): +def test_assemble_input_json_delta_malformed_keeps_empty_dict(): events = [ {"type": "message_start", "message": {"id": "m", "role": "assistant"}}, { @@ -68,7 +68,7 @@ def test_assemble_input_json_delta_malformed_keeps_raw_string(): msg = assemble_anthropic_stream(events) - assert msg["content"][0]["input"] == '{"file_path":"a.txt"' + assert msg["content"][0]["input"] == {} def test_assemble_preserves_usage_from_message_stop(): diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index c5461d47..afcd7ea1 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -58,15 +58,17 @@ def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: t=event.get('type'); out=[] phase={'diagnosis_attempted','diagnosis_generated','planning_started','repair_attempt_started'} if t in phase: out.append({'type':'phase','id':run_id,'phase':t}) - elif t=='model_request_started': out += [{'type':'phase','id':run_id,'phase':t},{'type':'bridge_diagnostic','id':run_id,'message':'model request started'}] - elif t in {'model_request_completed','model_request_failed'}: out.append({'type':'bridge_diagnostic','id':run_id,'message':t}) - elif t=='tool_started': out += [{'type':'tool_started','id':run_id,'tool':event.get('name')},{'type':'bridge_diagnostic','id':run_id,'message':'tool started'}] + elif t=='model_request_started': out += [{'type':'phase','id':run_id,'phase':t},{'type':'model_request_started','id':run_id},{'type':'bridge_diagnostic','id':run_id,'message':'model request started'}] + elif t=='model_request_completed': out += [{'type':'model_request_completed','id':run_id},{'type':'bridge_diagnostic','id':run_id,'message':'model response received'}] + elif t=='model_request_failed': out.append({'type':'bridge_diagnostic','id':run_id,'message':t}) + elif t=='tool_started': out += [{'type':'tool_started','id':run_id,'tool':event.get('name')},{'type':'bridge_diagnostic','id':run_id,'message':f"tool started: {event.get('name') or ''}".strip()}] elif t=='tool_finished': out.append({'type':'tool_finished','id':run_id,'tool':event.get('name'),'is_error':event.get('is_error')}) if event.get('name') in {'Write','Patch','Edit'}: out.append({'type':'workspace_changed','id':run_id}) elif t=='validation_step_started': out.append({'type':'verification_started','id':run_id,'name':event.get('name')}) elif t in {'validation_step_finished','validation_completed'}: out.append({'type':'verification_finished','id':run_id,'passed':event.get('passed')}) elif t in {'command_wandering_detected','progress_governor_redirected','governor_redirect'}: out.append({'type':'governor_redirect','id':run_id,'reason':event.get('reason')}) + elif t=='stream_text' and os.environ.get('VILLANI_PI_DEBUG')=='1': out.append({'type':'stream_text','id':run_id,'text':_cap(event.get('text',''),240)}) return out def extract_summary(r:dict[str,Any])->str|None: diff --git a/villani_code/openai_client.py b/villani_code/openai_client.py index 5dc2449a..f34ffc58 100644 --- a/villani_code/openai_client.py +++ b/villani_code/openai_client.py @@ -86,6 +86,18 @@ def build_openai_payload(payload: dict[str, Any], stream: bool) -> dict[str, Any return out +def _coerce_tool_input(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + return {} + + def _map_openai_finish_reason_to_anthropic(finish_reason: str | None) -> str | None: mapping = { "stop": "end_turn", @@ -181,10 +193,7 @@ def convert_openai_response_to_anthropic(response: dict[str, Any]) -> dict[str, for tool_call in message.get("tool_calls", []) or []: function = tool_call.get("function", {}) arguments = function.get("arguments", "{}") - try: - parsed_arguments = json.loads(arguments) if isinstance(arguments, str) else dict(arguments) - except (json.JSONDecodeError, TypeError, ValueError): - parsed_arguments = {} + parsed_arguments = _coerce_tool_input(arguments) content.append( { "type": "tool_use", diff --git a/villani_code/repair.py b/villani_code/repair.py index 8951d801..e9266904 100644 --- a/villani_code/repair.py +++ b/villani_code/repair.py @@ -53,7 +53,7 @@ def _run_repair_prompt(runner: Any, context: RepairContext, prior_attempts: list raw = runner.client.create_message({"model": runner.model, "messages": call_messages, "system": build_system_blocks(runner.repo), "tools": tool_specs(), "max_tokens": runner.max_tokens, "stream": False}, stream=False) response = raw if isinstance(raw, dict) else {"content": []} for block in [b for b in response.get("content", []) if b.get("type") == "tool_use"]: - runner._execute_tool_with_policy(str(block.get("name", "")), dict(block.get("input", {})), str(block.get("id", "repair-tool")), len(call_messages)) + runner._execute_tool_with_policy(str(block.get("name", "")), block.get("input") if isinstance(block.get("input"), dict) else {}, str(block.get("id", "repair-tool")), len(call_messages)) text = "\n".join(b.get("text", "") for b in response.get("content", []) if isinstance(b, dict) and b.get("type") == "text") return text[:400] or "repair attempt executed" diff --git a/villani_code/state.py b/villani_code/state.py index 6f52b912..c3e555f5 100644 --- a/villani_code/state.py +++ b/villani_code/state.py @@ -59,6 +59,12 @@ ) +def _safe_tool_input(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + return {} + + def _dedupe_preserve(items: list[str]) -> list[str]: seen: set[str] = set() ordered: list[str] = [] @@ -1502,7 +1508,7 @@ def _budget_reason( tool_results: list[dict[str, Any]] = [] for block in tool_uses: tool_name = block.get("name", "") - tool_input = dict(block.get("input", {})) + tool_input = _safe_tool_input(block.get("input")) tool_use_id = str(block.get("id")) self.event_callback( { @@ -1662,7 +1668,7 @@ def _budget_reason( consecutive_no_edit_turns += 1 mutating_tools = any( - self._is_mutating_tool_call(b.get("name", ""), dict(b.get("input", {}))) + self._is_mutating_tool_call(b.get("name", ""), _safe_tool_input(b.get("input"))) for b in tool_uses ) recon_turn = bool(tool_uses) and not mutating_tools and not edited_this_turn diff --git a/villani_code/streaming.py b/villani_code/streaming.py index 4b557645..db04c503 100644 --- a/villani_code/streaming.py +++ b/villani_code/streaming.py @@ -78,9 +78,9 @@ def assemble_anthropic_stream(events: Iterable[dict[str, Any]]) -> dict[str, Any raw = partial_json.pop(index) try: parsed = json.loads(raw) - response["content"][index]["input"] = parsed + response["content"][index]["input"] = parsed if isinstance(parsed, dict) else {} except json.JSONDecodeError: - response["content"][index]["input"] = raw + response["content"][index]["input"] = {} elif etype == "message_delta": delta = event.get("delta", {}) if isinstance(delta, dict): From 127f9b57ea1f4c75351433f6b5102f252f46d565 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 08:00:47 +1000 Subject: [PATCH 22/36] Fix Pi approval and tool progress events --- integrations/pi-villani/src/extension.test.ts | 8 +++ integrations/pi-villani/src/index.ts | 7 +-- integrations/pi-villani/src/render.ts | 26 ++++++++-- tests/integrations/test_pi_bridge.py | 36 +++++++++++-- villani_code/integrations/pi_bridge.py | 52 ++++++++++++++++--- villani_code/state_tooling.py | 25 +++++++++ 6 files changed, 136 insertions(+), 18 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index a78e7a0a..5f8e4d15 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -45,3 +45,11 @@ test('sendDurableVillaniMessage falls back to ctx.ui.notify if Pi sendMessage th test('renderBridgeEvent does not send durable final messages',async()=>{const { renderBridgeEvent } = await import('./render.js'); let sent=0; const notes:string[]=[]; for(const type of ['run_completed','run_failed','run_aborted']) await renderBridgeEvent({type,summary:'done'},{sendMessage:()=>sent++},{ui:{notify:(m:string)=>notes.push(m)}}); assert.equal(sent,0); assert.deepEqual(notes,[]);}); test('/villani sends exactly one iterable final durable message with summary metadata',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'model_request_started',id:msg.id}); send({type:'run_completed',id:msg.id,summary:'done',changed_files:['src/a.ts','.villani/secret'],transcript_path:'/tmp/transcript.jsonl',verification_status:'passed',authorization:'Bearer secret',headers:{authorization:'Bearer secret'}});}}); setTimeout(()=>{},500);\n"); const sent:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:any)=>sent.push(m)},{ui:{notify:()=>{}},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.equal(sent.length,1); const payload=sent[0]; assert.equal(payload.customType,'villani-result'); assert.deepEqual(payload.content,[{type:'text',text:payload.content[0].text}]); assert.ok(Array.isArray(payload.content)); assert.match(payload.content[0].text,/Villani completed/); assert.match(payload.content[0].text,/done/); assert.match(payload.content[0].text,/src\/a\.ts/); assert.doesNotMatch(payload.content[0].text,/\.villani\/secret/); assert.match(payload.content[0].text,/Transcript: \/tmp\/transcript\.jsonl/); assert.match(payload.content[0].text,/Verification: passed/); assert.doesNotMatch(JSON.stringify(payload.details),/authorization|Bearer secret/i);}); + +test('approvalMessage never renders object and includes Bash command',async()=>{const { approvalMessage, approvalTitle } = await import('./index.js'); const msg=approvalMessage({tool:'Bash',summary:'Run command',input:{command:'echo hi'}}); assert.doesNotMatch(msg,/\[object Object\]/); assert.match(msg,/Command:\necho hi/); assert.equal(approvalTitle({tool:'Bash'}),'Villani wants to run a shell command');}); + +test('confirm passes signal option to ctx.ui.confirm',async()=>{const { confirm } = await import('./render.js'); const signal=new AbortController().signal; let args:any[]=[]; const ok=await confirm({ui:{confirm:async(...a:any[])=>{args=a; return true;}}},'t','m',{signal}); assert.equal(ok,true); assert.equal(args[2].signal,signal);}); + +test('/villani approval pending sets status/widget and accepted clears widget',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='approval_response'){send({type:'tool_progress',id:msg.id,tool:'Bash',message:'Running command: echo hi'}); send({type:'run_completed',id:msg.id,summary:'done'}); continue;} send({type:'run_started',id:msg.id}); send({type:'approval_required',id:msg.id,request_id:msg.id+':1',tool:'Bash',summary:'Run command',input:{command:'echo hi'}});}}); setTimeout(()=>{},500);\n"); const statuses:any[]=[]; const widgets:any[]=[]; const confirms:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:()=>{}},{ui:{notify:()=>{},setStatus:(...a:any[])=>statuses.push(a),setWidget:(...a:any[])=>widgets.push(a),confirm:async(...a:any[])=>{confirms.push(a); return true;}},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.ok(statuses.some(a=>a[0]==='villani'&&/awaiting approval/.test(a[1]))); assert.ok(widgets.some(a=>a[0]==='villani'&&Array.isArray(a[1]))); assert.ok(widgets.some(a=>a[0]==='villani'&&a[1]===undefined)); assert.equal(confirms[0][2].signal instanceof AbortSignal,true);}); + +test('tool result and finished events render readable summaries',async()=>{const { renderBridgeEvent } = await import('./render.js'); const notes:string[]=[]; await renderBridgeEvent({type:'tool_finished',tool:'Bash',summary:'Command finished: exit 0',ok:true},{},{ui:{notify:(m:string)=>notes.push(m)}}); await renderBridgeEvent({type:'tool_progress',message:'Running command: echo hi'},{},{ui:{notify:(m:string)=>notes.push(m)}}); assert.match(notes.join('\n'),/Villani tool finished: Bash — Command finished: exit 0/); assert.match(notes.join('\n'),/Villani: Running command: echo hi/);}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index b8c41f43..581baefd 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -4,7 +4,7 @@ import { resolveVillaniRuntime, cacheRoot } from './runtime.js'; import { VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; import { VillaniBridgeProcess } from './process.js'; import { resolvePiModel, sanitizeError, startModelProxyFromPiModel } from './modelProxy.js'; -import { confirm, finalMessage, notify, renderBridgeEvent, sendDurableVillaniMessage, setStatus } from './render.js'; +import { confirm, finalMessage, notify, renderBridgeEvent, sendDurableVillaniMessage, setStatus, setWidget } from './render.js'; type ActiveRun={id:string, abort:AbortController, bridge?:VillaniBridgeProcess, proxy?:{close:()=>void|Promise}, pending:Map}; let activeRun:ActiveRun|null=null; @@ -12,8 +12,9 @@ export function buildChildEnvForProxy(proxyUrl:string, model:any):NodeJS.Process function envConfig(){return {provider:process.env.VILLANI_PROVIDER,model:process.env.VILLANI_MODEL,base_url:process.env.VILLANI_BASE_URL,api_key:process.env.VILLANI_API_KEY};} export async function resolveModelAuth(ctx:any, model:any){ if(!ctx.modelRegistry?.getApiKeyAndHeaders) throw new Error('Pi modelRegistry.getApiKeyAndHeaders is unavailable.'); const auth=await ctx.modelRegistry.getApiKeyAndHeaders(model); if(!auth?.ok) throw new Error(`Villani could not resolve Pi model authentication: ${sanitizeError(auth?.error ?? 'unknown error')}`); return {apiKey:auth.apiKey,headers:auth.headers}; } export async function safeCommand(ctx:any,label:string,fn:()=>Promise):Promise{try{await fn();}catch(error){const message=error instanceof Error?error.message:String(error); await notify(ctx,`${label} failed: ${message}`,'error'); if(process.env.VILLANI_PI_DEBUG==='1') console.error(error);}} -function approvalSummary(e:any){const kind=e.kind||e.action||e.approval_type||'action'; const target=e.path||e.file||e.command||e.summary||''; if(kind==='write')return `Villani wants to write file:\n${target}`; if(kind==='patch')return `Villani wants to patch file:\n${target}`; if(kind==='shell'||e.command)return `Villani wants to run command:\n${e.command||target}`; return `Villani wants approval for ${kind}:\n${String(target).slice(0,500)}`;} -async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; run.pending.set(requestId,false); let approved=false; try{approved=await confirm(ctx,'Approve Villani action?',approvalSummary(e));}catch(err){await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); run.bridge?.respondToApproval(run.id,requestId,approved);} +export function approvalTitle(request:any) { if (request.tool === 'Write') return 'Villani wants to write a file'; if (request.tool === 'Patch') return 'Villani wants to apply a patch'; if (request.tool === 'Bash') return 'Villani wants to run a shell command'; return `Villani wants approval for ${request.tool}`; } +export function approvalMessage(request:any) { const input = request.input && typeof request.input === 'object' ? request.input : {}; const path = typeof input.path === 'string' ? input.path : undefined; const command = typeof input.command === 'string' ? input.command : undefined; const lines = [String(request.summary || 'Approval required'), '']; if (path) lines.push(`File: ${path}`, ''); if (command) lines.push('Command:', command, ''); lines.push('Allow this operation?'); return lines.join('\n'); } +async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; run.pending.set(requestId,false); let approved=false; const message=approvalMessage(e); try{await setStatus(ctx,`Villani: awaiting approval for ${e.tool}`); await setWidget(ctx,['Villani is awaiting approval',message]); approved=await confirm(ctx,approvalTitle(e),message,{signal:run.abort.signal});}catch(err){await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); if(approved){await setStatus(ctx,'Villani: running'); await setWidget(ctx,undefined);} else {await setStatus(ctx,`Villani: denied ${e.tool} approval`);} run.bridge?.respondToApproval(run.id,requestId,approved);} function denyPending(run:ActiveRun){for(const [id,done] of run.pending){if(!done){run.pending.set(id,true); run.bridge?.respondToApproval(run.id,id,false);}}} function bridgeStderr(bridge:VillaniBridgeProcess){return bridge.getRecentStderr?.() ?? bridge.stderr ?? '';} function bridgeDiagnosticMessage(e:unknown){const msg=sanitizeError(e); if(/ModuleNotFoundError: No module named ['"]villani_code['"]/.test(msg)) return `Villani bridge failed because the selected VILLANI_COMMAND could not import villani_code. diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 5027d766..29b4ca52 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -1,7 +1,8 @@ export async function notify(ctx: any, message: string, level: 'info'|'warn'|'error' = 'info'): Promise { try { if (ctx?.ui?.notify) await ctx.ui.notify(message, level); else (level === 'error' ? console.error : console.log)(message); } catch { try { console.error(message); } catch {} } } -export async function setStatus(ctx: any, message: string | undefined): Promise { try { if (ctx?.ui?.setStatus) await ctx.ui.setStatus(message); } catch {} } +export async function setStatus(ctx: any, message: string | undefined): Promise { try { if (ctx?.ui?.setStatus) await ctx.ui.setStatus('villani', message); } catch { try { if (ctx?.ui?.setStatus) await ctx.ui.setStatus(message); } catch {} } } +export async function setWidget(ctx: any, widget: any): Promise { try { if (ctx?.ui?.setWidget) await ctx.ui.setWidget('villani', widget); } catch {} } export async function sendDurableVillaniMessage(pi: any, ctx: any, message: string, details?: any): Promise { const payload = { customType: 'villani-result', @@ -21,8 +22,8 @@ export async function sendDurableVillaniMessage(pi: any, ctx: any, message: stri await notify(ctx, message, 'info'); } -export async function confirm(ctx: any, title: string, message: string): Promise { - try { if (ctx?.ui?.confirm) return !!(await ctx.ui.confirm(title, message)); } catch (e) { throw e; } +export async function confirm(ctx: any, title: string, message: string, options?: any): Promise { + try { if (ctx?.ui?.confirm) return !!(await ctx.ui.confirm(title, message, options)); } catch (e) { throw e; } return false; } export function visibleChangedFiles(files:string[]=[]){return files.filter(f=>!/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/.test(f));} @@ -45,4 +46,21 @@ function sanitizeDetails(value:any, seen=new WeakSet()):any{ } function verificationStatus(event:any):string|undefined{const verification=event.verification_status??event.verificationStatus??event.verification; if(!verification)return undefined; if(typeof verification==='string') return `Verification: ${verification}`; if(typeof verification==='object'){const status=verification.status??verification.result??verification.outcome; return status?`Verification: ${status}`:undefined;} return `Verification: ${String(verification)}`;} export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||event.changedFiles||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; const transcript=event.transcript_path||event.transcriptPath; const verification=verificationStatus(event); return [head,event.summary||event.error||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:'',transcript?`Transcript: ${transcript}`:'',verification].filter(Boolean).join('\n\n');} -export async function renderBridgeEvent(event:any, _pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; if(event.type==='bridge_diagnostic'&&!debug)return; if(event.type==='bridge_diagnostic'&&debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); else if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); else if(event.type==='model_request_started') await notify(ctx, 'Villani diagnostic: model request started', 'info'); else if(event.type==='model_request_completed') await notify(ctx, 'Villani diagnostic: model response received', 'info'); else if(event.type==='tool_started') await notify(ctx, `Villani tool started: ${String(event.tool??event.name??'unknown')}`, 'info'); else if(event.type==='tool_finished') await notify(ctx, `Villani tool finished: ${String(event.tool??event.name??'unknown')}`, 'info'); else if(event.type==='stream_text'&&debug) await notify(ctx, `Villani: ${String(event.text||'').slice(0,240)}`, 'info'); else if(new Set(['ready','phase','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']).has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); } +export async function renderBridgeEvent(event:any, _pi:any, ctx:any): Promise { + const debug=process.env.VILLANI_PI_DEBUG==='1'; + const tool=String(event.tool??event.name??'unknown'); + const summary=String(event.summary??'').slice(0,500); + const command=typeof event.command==='string'?event.command.slice(0,500):''; + if(event.type==='approval_required') return; + if(event.type==='bridge_diagnostic'&&!debug) return; + if(event.type==='bridge_diagnostic'&&debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); + else if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); + else if(event.type==='model_request_started') await notify(ctx, 'Villani is thinking...', 'info'); + else if(event.type==='model_request_completed') await notify(ctx, 'Villani received model response', 'info'); + else if(event.type==='tool_started') await notify(ctx, `Villani tool started: ${tool}${command?` — ${command}`:''}`, 'info'); + else if(event.type==='tool_progress') await notify(ctx, `Villani: ${String(event.message||'tool progress').slice(0,500)}`, 'info'); + else if(event.type==='tool_finished') await notify(ctx, `Villani tool finished: ${tool}${summary?` — ${summary}`:''}`, event.ok===false||event.is_error?'warn':'info'); + else if(event.type==='stream_text') { const text=String(event.text||'').trim().slice(0,240); if(text) await notify(ctx, `Villani: ${text}`, 'info'); } + else if(new Set(['ready','phase','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']).has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); + if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); +} diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py index 94a997c5..5d6a3c5b 100644 --- a/tests/integrations/test_pi_bridge.py +++ b/tests/integrations/test_pi_bridge.py @@ -60,14 +60,14 @@ def test_modes_and_max_turns_are_passed(tmp_path): r=DummyRunner(); b,out,_,made=make_bridge(r); b.handle(run_cmd(tmp_path,mode='runner',limits={'max_turns':7})); wait_for(out,lambda e:e['type']=='run_completed'); assert made[0].calls[0][0]=='run'; assert isinstance(made[0].calls[0][2],ExecutionBudget); assert made[0].calls[0][2].max_turns==7 r2=DummyRunner(); b,out,_,made=make_bridge(r2); b.handle(run_cmd(tmp_path,id='r2',mode='villani')); wait_for(out,lambda e:e['type']=='run_completed'); assert made[0].calls[0][0]=='villani' -def test_stream_text_and_executionplan_approval_events_are_not_exposed(tmp_path): - b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'stream')); wait_for(out,lambda e:e['type']=='run_completed'); assert 'SECRET' not in out.getvalue(); assert 'approval_required' not in [e['type'] for e in events(out)] +def test_stream_text_is_capped_and_executionplan_approval_events_are_not_exposed(tmp_path): + b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'stream')); wait_for(out,lambda e:e['type']=='run_completed'); assert any(e['type']=='stream_text' and e['text']=='SECRET' for e in events(out)); assert 'approval_required' not in [e['type'] for e in events(out)] def test_write_approval_blocks_and_rejected_write_does_not_mutate_file(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path); b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'write')); wait_for(out,lambda e:e['type']=='approval_required'); assert not (tmp_path/'a.txt').exists(); b.approval(type('C',(),{'id':'r1','request_id':'r1:1','approved':False})()); wait_for(out,lambda e:e['type']=='run_completed'); assert not (tmp_path/'a.txt').exists() def test_bash_approval_summary_is_concise(tmp_path): - b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'bash')); wait_for(out,lambda e:e['type']=='approval_required'); e=[x for x in events(out) if x['type']=='approval_required'][0]; assert e['title']=='Run command'; assert e['summary']=={'command':'echo hello && cat secret'}; b.abort('r1') + b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'bash')); wait_for(out,lambda e:e['type']=='approval_required'); e=[x for x in events(out) if x['type']=='approval_required'][0]; assert e['summary']=='Run command'; assert e['input']=={'command':'echo hello && cat secret'}; b.abort('r1') def test_duplicate_unknown_and_malformed_approval_responses(tmp_path): b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'approve-twice')); wait_for(out,lambda e:e['type']=='approval_required'); b.handle({'type':'approval_response','id':'r1','request_id':'r1:1','approved':True}); wait_for(out,lambda e:e.get('request_id')=='r1:2'); b.handle({'type':'approval_response','id':'r1','request_id':'r1:1','approved':False}); b.handle({'type':'approval_response','id':'r1','request_id':'nope','approved':True}); b.handle({'type':'approval_response','id':'r1','request_id':'r1:2','approved':'yes'}); assert sum(1 for e in events(out) if e['type']=='error')>=3; b.approval(type('C',(),{'id':'r1','request_id':'r1:2','approved':True})()); wait_for(out,lambda e:e['type']=='run_completed') @@ -175,3 +175,33 @@ def boom(_repo): b.handle(run_cmd(tmp_path)) wait_for(out,lambda e:e['type']=='run_failed') assert any(e['type']=='run_failed' for e in events(out)) + +def test_approval_required_shape_and_bash_input_command(tmp_path): + b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'bash')); wait_for(out,lambda e:e['type']=='approval_required') + e=[x for x in events(out) if x['type']=='approval_required'][0] + assert isinstance(e['summary'], str) + assert isinstance(e['input'], dict) + assert e['summary']=='Run command' + assert e['input']['command']=='echo hello && cat secret' + assert e['tool']=='Bash' + assert 'title' not in e + b.abort('r1') + +def test_map_runner_event_maps_tool_result_and_command_lifecycle(): + from villani_code.integrations.pi_bridge import map_runner_event + finished=map_runner_event('r', {'type':'tool_result','name':'Bash','input':{'command':'echo hi'},'is_error':False,'result':{'exit_code':0,'stdout':'hello'}}) + assert finished[0]['type']=='tool_finished' + assert finished[0]['tool']=='Bash' + assert finished[0]['ok'] is True + assert 'Bash finished: exit 0' in finished[0]['summary'] + progress=map_runner_event('r', {'type':'command_started','command':'sleep 1'}) + assert progress==[{'type':'tool_progress','id':'r','tool':'Bash','message':'Running command: sleep 1'}] + done=map_runner_event('r', {'type':'command_finished','exit_code':1}) + assert done==[{'type':'tool_finished','id':'r','tool':'Bash','ok':False,'is_error':True,'summary':'Command finished: exit 1'}] + +def test_bash_tool_result_summary_truncates_output(): + from villani_code.integrations.pi_bridge import map_runner_event + long='x'*800 + ev=map_runner_event('r', {'type':'tool_result','name':'Bash','input':{'command':'pytest'},'result':{'exit_code':1,'stdout':long,'stderr':long}})[0] + assert len(ev['summary']) < 1200 + assert 'output truncated' in ev['summary'] diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index afcd7ea1..72a50789 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -10,6 +10,9 @@ def _visible(p:str)->bool: parts=p.replace('\\','/').split('/'); return not (any(x in HIDDEN for x in parts) or p.endswith('.pyc')) def _cap(v:Any,n:int=2000)->str: return str(v)[:n] +def _preview(v:Any,n:int=500)->str: + text='' if v is None else str(v) + return text[:n] def summarize_approval_request(tool_name:str, tool_input:dict[str,Any])->tuple[str,dict[str,Any]]: if tool_name=='Write': path=str(tool_input.get('path') or tool_input.get('file_path') or '') @@ -54,6 +57,31 @@ def attributed_changed_files(repo:Path,before_dirty:list[str],before_dirty_hashe else: pre.append(f) return sorted(set(filter(_visible,changed))), sorted(set(filter(_visible,pre))) +def summarize_tool_result(event:dict[str,Any])->str: + tool=str(event.get('name') or event.get('tool') or 'tool') + result=event.get('result') if isinstance(event.get('result'),dict) else {} + inp=event.get('input') if isinstance(event.get('input'),dict) else {} + command=event.get('command') or result.get('command') or inp.get('command') + exit_code=event.get('exit_code', result.get('exit_code', result.get('returncode'))) + stdout=result.get('stdout_preview', event.get('stdout_preview', result.get('stdout'))) + stderr=result.get('stderr_preview', event.get('stderr_preview', result.get('stderr'))) + truncated=bool(event.get('truncated') or result.get('truncated') or (stdout is not None and len(str(stdout))>500) or (stderr is not None and len(str(stderr))>500)) + if tool=='Bash' or command is not None or exit_code is not None: + parts=[f"Bash finished: exit {exit_code if exit_code is not None else 'unknown'}"] + if command: parts.append(f"command: {_preview(command)}") + if stdout: parts.append(f"stdout: {_preview(stdout)}") + if stderr: parts.append(f"stderr: {_preview(stderr)}") + if truncated: parts.append('output truncated') + return '\n'.join(parts) + if event.get('is_error'): return f"{tool} failed" + return f"{tool} finished" + +def _workspace_path(event:dict[str,Any])->str|None: + inp=event.get('input') if isinstance(event.get('input'),dict) else {} + result=event.get('result') if isinstance(event.get('result'),dict) else {} + path=inp.get('path') or inp.get('file_path') or result.get('path') or result.get('file_path') + return str(path) if path else None + def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: t=event.get('type'); out=[] phase={'diagnosis_attempted','diagnosis_generated','planning_started','repair_attempt_started'} @@ -61,14 +89,22 @@ def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: elif t=='model_request_started': out += [{'type':'phase','id':run_id,'phase':t},{'type':'model_request_started','id':run_id},{'type':'bridge_diagnostic','id':run_id,'message':'model request started'}] elif t=='model_request_completed': out += [{'type':'model_request_completed','id':run_id},{'type':'bridge_diagnostic','id':run_id,'message':'model response received'}] elif t=='model_request_failed': out.append({'type':'bridge_diagnostic','id':run_id,'message':t}) - elif t=='tool_started': out += [{'type':'tool_started','id':run_id,'tool':event.get('name')},{'type':'bridge_diagnostic','id':run_id,'message':f"tool started: {event.get('name') or ''}".strip()}] - elif t=='tool_finished': - out.append({'type':'tool_finished','id':run_id,'tool':event.get('name'),'is_error':event.get('is_error')}) - if event.get('name') in {'Write','Patch','Edit'}: out.append({'type':'workspace_changed','id':run_id}) + elif t=='tool_started': + tool=str(event.get('name') or 'tool'); inp=event.get('input') if isinstance(event.get('input'),dict) else {}; command=inp.get('command') + out += [{'type':'tool_started','id':run_id,'tool':tool, **({'command':_cap(command,500)} if command else {})},{'type':'bridge_diagnostic','id':run_id,'message':f"tool started: {tool}".strip()}] + elif t in {'tool_result','tool_finished'}: + tool=str(event.get('name') or 'tool'); is_error=bool(event.get('is_error')); summary=summarize_tool_result(event) + out.append({'type':'tool_finished','id':run_id,'tool':tool,'ok':not is_error,'is_error':is_error,'summary':summary}) + if tool in {'Write','Patch','Edit'}: + path=_workspace_path(event); out.append({'type':'workspace_changed','id':run_id, **({'path':path} if path else {})}) + elif t=='command_started': + command=_cap(event.get('command',''),500); out.append({'type':'tool_progress','id':run_id,'tool':'Bash','message':f'Running command: {command}'}) + elif t=='command_finished': + exit_code=event.get('exit_code'); out.append({'type':'tool_finished','id':run_id,'tool':'Bash','ok':exit_code==0,'is_error':exit_code!=0,'summary':f'Command finished: exit {exit_code}'}) elif t=='validation_step_started': out.append({'type':'verification_started','id':run_id,'name':event.get('name')}) elif t in {'validation_step_finished','validation_completed'}: out.append({'type':'verification_finished','id':run_id,'passed':event.get('passed')}) elif t in {'command_wandering_detected','progress_governor_redirected','governor_redirect'}: out.append({'type':'governor_redirect','id':run_id,'reason':event.get('reason')}) - elif t=='stream_text' and os.environ.get('VILLANI_PI_DEBUG')=='1': out.append({'type':'stream_text','id':run_id,'text':_cap(event.get('text',''),240)}) + elif t=='stream_text': out.append({'type':'stream_text','id':run_id,'text':_cap(event.get('text',''),240)}) return out def extract_summary(r:dict[str,Any])->str|None: @@ -203,10 +239,10 @@ def ev(e): for m in map_runner_event(cmd.id,e): self._queue_event(m) def appr(tool,inp): if ar.abort_requested.is_set(): return False - title,summary=summarize_approval_request(tool,inp); ar.approval_seq += 1; req=f'{cmd.id}:{ar.approval_seq}' - p=PendingApproval(cmd.id,req,tool) + summary,safe_input=summarize_approval_request(str(tool),inp); ar.approval_seq += 1; req=f'{cmd.id}:{ar.approval_seq}' + p=PendingApproval(cmd.id,req,str(tool)) with self._lock: ar.pending_approvals[req]=p; self._pending_approvals[req]=p - self._queue_event({'type':'approval_required','id':cmd.id,'request_id':req,'tool':tool,'title':title,'summary':summary}) + self._queue_event({'type':'approval_required','id':cmd.id,'request_id':req,'tool':str(tool),'summary':summary,'input':safe_input}) p.ready.wait(); self._queue_event({'type':'approval_resolved','id':cmd.id,'request_id':req,'approved':bool(p.approved)}); return bool(p.approved) and not ar.abort_requested.is_set() try: if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return diff --git a/villani_code/state_tooling.py b/villani_code/state_tooling.py index 20d790a9..d493ead2 100644 --- a/villani_code/state_tooling.py +++ b/villani_code/state_tooling.py @@ -677,6 +677,31 @@ def _debug_callback_with_turn(event_type: str, payload: dict[str, Any]) -> None: debug_callback = getattr(runner, "_debug_tool_callback", None) if callable(debug_callback): debug_callback(event_type, callback_payload) + if event_type == "command_started": + runner.event_callback( + { + "type": "command_started", + "name": "Bash", + "command": payload.get("command"), + "cwd": payload.get("cwd"), + "tool_use_id": payload.get("tool_call_id"), + "turn_index": emit_turn_index, + } + ) + elif event_type == "command_finished": + runner.event_callback( + { + "type": "command_finished", + "name": "Bash", + "command": payload.get("command"), + "cwd": payload.get("cwd"), + "exit_code": payload.get("exit_code"), + "stdout_preview": str(payload.get("stdout") or "")[:500], + "stderr_preview": str(payload.get("stderr") or "")[:500], + "tool_use_id": payload.get("tool_call_id"), + "turn_index": emit_turn_index, + } + ) debug_recorder = getattr(runner, "_debug_recorder", None) debug_root = getattr(getattr(debug_recorder, "artifacts", None), "root", None) From d84629ebb1fcd190aef535ec4a5ec3e75534b8b5 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 08:15:43 +1000 Subject: [PATCH 23/36] Fix Pi bridge tool result lifecycle --- integrations/pi-villani/src/extension.test.ts | 4 +++ integrations/pi-villani/src/index.ts | 8 +++--- integrations/pi-villani/src/render.ts | 28 +++++++++++-------- villani_code/integrations/pi_bridge.py | 11 ++++++-- 4 files changed, 34 insertions(+), 17 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 5f8e4d15..39b40042 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -53,3 +53,7 @@ test('confirm passes signal option to ctx.ui.confirm',async()=>{const { confirm test('/villani approval pending sets status/widget and accepted clears widget',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='approval_response'){send({type:'tool_progress',id:msg.id,tool:'Bash',message:'Running command: echo hi'}); send({type:'run_completed',id:msg.id,summary:'done'}); continue;} send({type:'run_started',id:msg.id}); send({type:'approval_required',id:msg.id,request_id:msg.id+':1',tool:'Bash',summary:'Run command',input:{command:'echo hi'}});}}); setTimeout(()=>{},500);\n"); const statuses:any[]=[]; const widgets:any[]=[]; const confirms:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:()=>{}},{ui:{notify:()=>{},setStatus:(...a:any[])=>statuses.push(a),setWidget:(...a:any[])=>widgets.push(a),confirm:async(...a:any[])=>{confirms.push(a); return true;}},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.ok(statuses.some(a=>a[0]==='villani'&&/awaiting approval/.test(a[1]))); assert.ok(widgets.some(a=>a[0]==='villani'&&Array.isArray(a[1]))); assert.ok(widgets.some(a=>a[0]==='villani'&&a[1]===undefined)); assert.equal(confirms[0][2].signal instanceof AbortSignal,true);}); test('tool result and finished events render readable summaries',async()=>{const { renderBridgeEvent } = await import('./render.js'); const notes:string[]=[]; await renderBridgeEvent({type:'tool_finished',tool:'Bash',summary:'Command finished: exit 0',ok:true},{},{ui:{notify:(m:string)=>notes.push(m)}}); await renderBridgeEvent({type:'tool_progress',message:'Running command: echo hi'},{},{ui:{notify:(m:string)=>notes.push(m)}}); assert.match(notes.join('\n'),/Villani tool finished: Bash — Command finished: exit 0/); assert.match(notes.join('\n'),/Villani: Running command: echo hi/);}); + +test('/villani keeps waiting after nonzero tool_finished and renders next model event',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'tool_finished',id:msg.id,tool:'Bash',ok:false,is_error:true,summary:'Bash finished: exit 255\\nstderr: head not recognized'}); setTimeout(()=>send({type:'model_request_started',id:msg.id}),50); setTimeout(()=>send({type:'run_completed',id:msg.id,summary:'done'}),100);}}); setTimeout(()=>{},500);\n"); const statuses:string[]=[]; const notes:string[]=[]; const sent:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:any)=>sent.push(m)},{ui:{notify:(m:string)=>notes.push(m),setStatus:(_:string,m:string)=>statuses.push(m)},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.match(notes.join('\n'),/Villani tool finished: Bash — Bash finished: exit 255/); assert.ok(statuses.includes('Villani received command result. Waiting for next step...')); assert.ok(statuses.includes('Villani is thinking...')); assert.equal(sent.length,1);}); + +test('repeated model_request_started updates status without notify spam',async()=>{const { renderBridgeEvent } = await import('./render.js'); const statuses:string[]=[]; const notes:string[]=[]; const ctx={ui:{setStatus:(_:string,m:string)=>statuses.push(m),notify:(m:string)=>notes.push(m)}}; await renderBridgeEvent({type:'model_request_started'},{},ctx); await renderBridgeEvent({type:'model_request_started'},{},ctx); assert.deepEqual(statuses,['Villani is thinking...','Villani is thinking...']); assert.deepEqual(notes,[]);}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 581baefd..d67bc9af 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -12,9 +12,9 @@ export function buildChildEnvForProxy(proxyUrl:string, model:any):NodeJS.Process function envConfig(){return {provider:process.env.VILLANI_PROVIDER,model:process.env.VILLANI_MODEL,base_url:process.env.VILLANI_BASE_URL,api_key:process.env.VILLANI_API_KEY};} export async function resolveModelAuth(ctx:any, model:any){ if(!ctx.modelRegistry?.getApiKeyAndHeaders) throw new Error('Pi modelRegistry.getApiKeyAndHeaders is unavailable.'); const auth=await ctx.modelRegistry.getApiKeyAndHeaders(model); if(!auth?.ok) throw new Error(`Villani could not resolve Pi model authentication: ${sanitizeError(auth?.error ?? 'unknown error')}`); return {apiKey:auth.apiKey,headers:auth.headers}; } export async function safeCommand(ctx:any,label:string,fn:()=>Promise):Promise{try{await fn();}catch(error){const message=error instanceof Error?error.message:String(error); await notify(ctx,`${label} failed: ${message}`,'error'); if(process.env.VILLANI_PI_DEBUG==='1') console.error(error);}} -export function approvalTitle(request:any) { if (request.tool === 'Write') return 'Villani wants to write a file'; if (request.tool === 'Patch') return 'Villani wants to apply a patch'; if (request.tool === 'Bash') return 'Villani wants to run a shell command'; return `Villani wants approval for ${request.tool}`; } -export function approvalMessage(request:any) { const input = request.input && typeof request.input === 'object' ? request.input : {}; const path = typeof input.path === 'string' ? input.path : undefined; const command = typeof input.command === 'string' ? input.command : undefined; const lines = [String(request.summary || 'Approval required'), '']; if (path) lines.push(`File: ${path}`, ''); if (command) lines.push('Command:', command, ''); lines.push('Allow this operation?'); return lines.join('\n'); } -async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; run.pending.set(requestId,false); let approved=false; const message=approvalMessage(e); try{await setStatus(ctx,`Villani: awaiting approval for ${e.tool}`); await setWidget(ctx,['Villani is awaiting approval',message]); approved=await confirm(ctx,approvalTitle(e),message,{signal:run.abort.signal});}catch(err){await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); if(approved){await setStatus(ctx,'Villani: running'); await setWidget(ctx,undefined);} else {await setStatus(ctx,`Villani: denied ${e.tool} approval`);} run.bridge?.respondToApproval(run.id,requestId,approved);} +export function approvalTitle(request:any) { if (request.tool === 'Bash') return 'Villani wants to run a shell command'; if (request.tool === 'Write') return 'Villani wants to write a file'; if (request.tool === 'Patch') return 'Villani wants to apply a patch'; return 'Villani wants approval'; } +export function approvalMessage(request:any) { const input = request.input && typeof request.input === 'object' && !Array.isArray(request.input) ? request.input : {}; const path = typeof input.path === 'string' ? input.path : undefined; const command = typeof input.command === 'string' ? input.command : undefined; const safeSummary = typeof request.summary === 'string' && request.summary.trim() ? request.summary : 'Approval required'; const lines = [safeSummary, '']; if (command) lines.push('Command:', command, ''); else if (path) lines.push('File:', path, ''); lines.push('Allow this operation?'); return lines.join('\n'); } +async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; const tool=String(e.tool||'tool'); run.pending.set(requestId,false); let approved=false; const message=approvalMessage(e); try{await setStatus(ctx,`Villani awaiting approval: ${tool}`); await setWidget(ctx,['Villani is awaiting approval',message]); approved=await confirm(ctx,approvalTitle(e),message,{signal:run.abort.signal});}catch(err){approved=false; await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} finally {await setWidget(ctx,undefined);} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); await setStatus(ctx,approved?'Villani approval accepted':'Villani approval denied'); run.bridge?.respondToApproval(run.id,requestId,approved); if(process.env.VILLANI_PI_DEBUG==='1') await notify(ctx,'Villani diagnostic: approval response sent','info');} function denyPending(run:ActiveRun){for(const [id,done] of run.pending){if(!done){run.pending.set(id,true); run.bridge?.respondToApproval(run.id,id,false);}}} function bridgeStderr(bridge:VillaniBridgeProcess){return bridge.getRecentStderr?.() ?? bridge.stderr ?? '';} function bridgeDiagnosticMessage(e:unknown){const msg=sanitizeError(e); if(/ModuleNotFoundError: No module named ['"]villani_code['"]/.test(msg)) return `Villani bridge failed because the selected VILLANI_COMMAND could not import villani_code. @@ -25,7 +25,7 @@ ${msg}`; return msg;} async function assertBridgePing(executable:string, ctx:any, env?:NodeJS.ProcessEnv, signal?:AbortSignal){const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,cwd:ctx.cwd??process.cwd()}); try{await bridge.waitUntilReady(undefined,signal); bridge.send({type:'ping'}); const pong=await bridge.waitForEvent('pong',5000,signal); if(!pong) throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); await notify(ctx,'Villani bridge ping succeeded.','info'); return true;}catch(e){throw new Error(bridgeDiagnosticMessage(e));}finally{bridge.kill(); await bridge.waitForExit(1500).catch(()=>{});}} async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['run_started','error','run_failed','run_aborted'],runId,10000,signal); if(event?.type==='run_started') return event; if(event){const msg=event.error||event.message||event.summary||event.type; throw new Error(`Villani bridge rejected run command: ${msg}`);} throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`);} async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000,signal); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} -export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi,cwd:ctx.cwd??process.cwd()}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendDurableVillaniMessage(pi, ctx, finalMessage(final), final); } finally { if(activeRun===run){denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } +export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; let postToolTimer:NodeJS.Timeout|undefined; const clearPostToolTimer=()=>{if(postToolTimer){clearTimeout(postToolTimer); postToolTimer=undefined;}}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi,cwd:ctx.cwd??process.cwd()}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(process.env.VILLANI_PI_DEBUG==='1') void notify(ctx,`Villani diagnostic: bridge event received: ${e.type}`,'info'); if(['model_request_started','model_request_completed','tool_started','approval_required','stream_text','run_completed','run_failed','run_aborted'].includes(e.type)) clearPostToolTimer(); if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); if(e.type==='tool_finished'){ void setStatus(ctx,'Villani received command result. Waiting for next step...'); if(process.env.VILLANI_PI_DEBUG==='1') void notify(ctx,'Villani diagnostic: waiting after tool result','info'); clearPostToolTimer(); postToolTimer=setTimeout(()=>{void setStatus(ctx,'Villani is still running. Waiting for the next runner event...');},5000); postToolTimer.unref?.(); } }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendDurableVillaniMessage(pi, ctx, finalMessage(final), final); } finally { if(activeRun===run){clearPostToolTimer(); denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); await notify(ctx,`active model id: ${model?.id??'unavailable'}`,'info'); await notify(ctx,`modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,'info'); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'auth resolution succeeded: true','info'); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn,completeSource:ctx.completeSource,completeImporter:ctx.completeImporter,pi:ctx.pi}); try{ await notify(ctx,`proxy URL: ${proxy.url}`,'info'); await notify(ctx,'POST /v1/chat/completions reached proxy: sending','info'); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); await notify(ctx,'POST /v1/chat/completions reached proxy: yes','info'); await notify(ctx,`selected Pi completion helper source: ${proxy.completionSource??'unavailable'}`,'info'); const text=await r.text(); if(!r.ok){await notify(ctx,`complete returned or failed: failed (${r.status})`,'info'); throw new Error(`HTTP ${r.status}: ${text}`);} await notify(ctx,'complete returned or failed: returned','info'); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); run.abort.abort(); if(!aborted) run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} export async function bridgePing(ctx:any):Promise{ await notify(ctx,'Villani bridge ping starting...','info'); const executable=await resolveVillaniRuntime({}); await notify(ctx,`runtime executable: ${executable}`,'info'); await assertBridgePing(executable,ctx); } diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 29b4ca52..44569195 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -46,21 +46,27 @@ function sanitizeDetails(value:any, seen=new WeakSet()):any{ } function verificationStatus(event:any):string|undefined{const verification=event.verification_status??event.verificationStatus??event.verification; if(!verification)return undefined; if(typeof verification==='string') return `Verification: ${verification}`; if(typeof verification==='object'){const status=verification.status??verification.result??verification.outcome; return status?`Verification: ${status}`:undefined;} return `Verification: ${String(verification)}`;} export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||event.changedFiles||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; const transcript=event.transcript_path||event.transcriptPath; const verification=verificationStatus(event); return [head,event.summary||event.error||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:'',transcript?`Transcript: ${transcript}`:'',verification].filter(Boolean).join('\n\n');} +let lastStreamAt=0; export async function renderBridgeEvent(event:any, _pi:any, ctx:any): Promise { const debug=process.env.VILLANI_PI_DEBUG==='1'; const tool=String(event.tool??event.name??'unknown'); const summary=String(event.summary??'').slice(0,500); const command=typeof event.command==='string'?event.command.slice(0,500):''; if(event.type==='approval_required') return; - if(event.type==='bridge_diagnostic'&&!debug) return; - if(event.type==='bridge_diagnostic'&&debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); - else if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); - else if(event.type==='model_request_started') await notify(ctx, 'Villani is thinking...', 'info'); - else if(event.type==='model_request_completed') await notify(ctx, 'Villani received model response', 'info'); - else if(event.type==='tool_started') await notify(ctx, `Villani tool started: ${tool}${command?` — ${command}`:''}`, 'info'); - else if(event.type==='tool_progress') await notify(ctx, `Villani: ${String(event.message||'tool progress').slice(0,500)}`, 'info'); - else if(event.type==='tool_finished') await notify(ctx, `Villani tool finished: ${tool}${summary?` — ${summary}`:''}`, event.ok===false||event.is_error?'warn':'info'); - else if(event.type==='stream_text') { const text=String(event.text||'').trim().slice(0,240); if(text) await notify(ctx, `Villani: ${text}`, 'info'); } - else if(new Set(['ready','phase','workspace_changed','verification_started','verification_finished','governor_redirect','approval_resolved']).has(event.type)) await notify(ctx, `Villani: ${event.type}`, 'info'); - if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); + if(event.type==='bridge_diagnostic') { if(debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); return; } + if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); + else if(event.type==='model_request_started') await setStatus(ctx, 'Villani is thinking...'); + else if(event.type==='model_request_completed') await setStatus(ctx, 'Villani received model response'); + else if(event.type==='model_request_failed') await notify(ctx, `Villani model request failed: ${event.error||event.message||'unknown error'}`, 'error'); + else if(event.type==='tool_started') { + if(tool==='Bash') await setStatus(ctx, 'Villani running command'); + await notify(ctx, `Villani tool started: ${tool}${command?` — ${command}`:''}`, 'info'); + } + else if(event.type==='tool_progress') { const msg=`Villani: ${String(event.message||'tool progress').slice(0,500)}`; await setStatus(ctx, msg); await notify(ctx, msg, 'info'); } + else if(event.type==='tool_finished') { + if(tool==='Bash') await setStatus(ctx, 'Command finished'); + await notify(ctx, `Villani tool finished: ${tool}${summary?` — ${summary}`:''}`, event.ok===false||event.is_error?'warn':'info'); + } + else if(event.type==='stream_text') { const text=String(event.text||'').trim().slice(0,240); const now=Date.now(); if(text&&now-lastStreamAt>1000){lastStreamAt=now; await setStatus(ctx, `Villani: ${text}`);} } + else if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); } diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index 72a50789..3b1b9cef 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -12,7 +12,7 @@ def _visible(p:str)->bool: def _cap(v:Any,n:int=2000)->str: return str(v)[:n] def _preview(v:Any,n:int=500)->str: text='' if v is None else str(v) - return text[:n] + return text[:n] + ('...' if len(text)>n else '') def summarize_approval_request(tool_name:str, tool_input:dict[str,Any])->tuple[str,dict[str,Any]]: if tool_name=='Write': path=str(tool_input.get('path') or tool_input.get('file_path') or '') @@ -93,8 +93,13 @@ def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: tool=str(event.get('name') or 'tool'); inp=event.get('input') if isinstance(event.get('input'),dict) else {}; command=inp.get('command') out += [{'type':'tool_started','id':run_id,'tool':tool, **({'command':_cap(command,500)} if command else {})},{'type':'bridge_diagnostic','id':run_id,'message':f"tool started: {tool}".strip()}] elif t in {'tool_result','tool_finished'}: - tool=str(event.get('name') or 'tool'); is_error=bool(event.get('is_error')); summary=summarize_tool_result(event) + tool=str(event.get('name') or event.get('tool') or 'tool') + result=event.get('result') if isinstance(event.get('result'),dict) else {} + exit_code=event.get('exit_code', result.get('exit_code', result.get('returncode'))) + is_error=bool(event.get('is_error') or (tool=='Bash' and exit_code is not None and exit_code != 0)) + summary=summarize_tool_result(event) out.append({'type':'tool_finished','id':run_id,'tool':tool,'ok':not is_error,'is_error':is_error,'summary':summary}) + out.append({'type':'bridge_diagnostic','id':run_id,'message':'tool result mapped'}) if tool in {'Write','Patch','Edit'}: path=_workspace_path(event); out.append({'type':'workspace_changed','id':run_id, **({'path':path} if path else {})}) elif t=='command_started': @@ -242,6 +247,8 @@ def appr(tool,inp): summary,safe_input=summarize_approval_request(str(tool),inp); ar.approval_seq += 1; req=f'{cmd.id}:{ar.approval_seq}' p=PendingApproval(cmd.id,req,str(tool)) with self._lock: ar.pending_approvals[req]=p; self._pending_approvals[req]=p + if not isinstance(summary,str): summary=str(summary) + if not isinstance(safe_input,dict): safe_input={} self._queue_event({'type':'approval_required','id':cmd.id,'request_id':req,'tool':str(tool),'summary':summary,'input':safe_input}) p.ready.wait(); self._queue_event({'type':'approval_resolved','id':cmd.id,'request_id':req,'approved':bool(p.approved)}); return bool(p.approved) and not ar.abort_requested.is_set() try: From 14810b2d35ffb37980eec98dbcdf7d3ee5974c53 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 08:35:38 +1000 Subject: [PATCH 24/36] Fix Pi bridge lifecycle event handling --- integrations/pi-villani/src/extension.test.ts | 2 +- integrations/pi-villani/src/index.ts | 2 +- integrations/pi-villani/src/modelProxy.ts | 12 +++--- integrations/pi-villani/src/process.ts | 2 +- integrations/pi-villani/src/protocol.ts | 8 +++- integrations/pi-villani/src/render.ts | 17 +++++++- tests/integrations/test_pi_bridge.py | 39 ++++++++++++++++--- villani_code/integrations/pi_bridge.py | 34 ++++++++++++---- 8 files changed, 93 insertions(+), 23 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 39b40042..b110d4b3 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -54,6 +54,6 @@ test('/villani approval pending sets status/widget and accepted clears widget',a test('tool result and finished events render readable summaries',async()=>{const { renderBridgeEvent } = await import('./render.js'); const notes:string[]=[]; await renderBridgeEvent({type:'tool_finished',tool:'Bash',summary:'Command finished: exit 0',ok:true},{},{ui:{notify:(m:string)=>notes.push(m)}}); await renderBridgeEvent({type:'tool_progress',message:'Running command: echo hi'},{},{ui:{notify:(m:string)=>notes.push(m)}}); assert.match(notes.join('\n'),/Villani tool finished: Bash — Command finished: exit 0/); assert.match(notes.join('\n'),/Villani: Running command: echo hi/);}); -test('/villani keeps waiting after nonzero tool_finished and renders next model event',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'tool_finished',id:msg.id,tool:'Bash',ok:false,is_error:true,summary:'Bash finished: exit 255\\nstderr: head not recognized'}); setTimeout(()=>send({type:'model_request_started',id:msg.id}),50); setTimeout(()=>send({type:'run_completed',id:msg.id,summary:'done'}),100);}}); setTimeout(()=>{},500);\n"); const statuses:string[]=[]; const notes:string[]=[]; const sent:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:any)=>sent.push(m)},{ui:{notify:(m:string)=>notes.push(m),setStatus:(_:string,m:string)=>statuses.push(m)},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.match(notes.join('\n'),/Villani tool finished: Bash — Bash finished: exit 255/); assert.ok(statuses.includes('Villani received command result. Waiting for next step...')); assert.ok(statuses.includes('Villani is thinking...')); assert.equal(sent.length,1);}); +test('/villani keeps waiting after nonzero tool_finished and renders next model event',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'tool_finished',id:msg.id,tool:'Bash',ok:false,is_error:true,summary:'Bash finished: exit 255\\nstderr: head not recognized'}); setTimeout(()=>send({type:'model_request_started',id:msg.id}),50); setTimeout(()=>send({type:'run_completed',id:msg.id,summary:'done'}),100);}}); setTimeout(()=>{},500);\n"); const statuses:string[]=[]; const notes:string[]=[]; const sent:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:any)=>sent.push(m)},{ui:{notify:(m:string)=>notes.push(m),setStatus:(_:string,m:string)=>statuses.push(m)},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.match(notes.join('\n'),/Villani tool finished: Bash — Bash finished: exit 255/); assert.ok(statuses.includes('Villani finished tool handling')); assert.ok(statuses.includes('Villani is thinking...')); assert.equal(sent.length,1);}); test('repeated model_request_started updates status without notify spam',async()=>{const { renderBridgeEvent } = await import('./render.js'); const statuses:string[]=[]; const notes:string[]=[]; const ctx={ui:{setStatus:(_:string,m:string)=>statuses.push(m),notify:(m:string)=>notes.push(m)}}; await renderBridgeEvent({type:'model_request_started'},{},ctx); await renderBridgeEvent({type:'model_request_started'},{},ctx); assert.deepEqual(statuses,['Villani is thinking...','Villani is thinking...']); assert.deepEqual(notes,[]);}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index d67bc9af..c59a7a7b 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -25,7 +25,7 @@ ${msg}`; return msg;} async function assertBridgePing(executable:string, ctx:any, env?:NodeJS.ProcessEnv, signal?:AbortSignal){const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,cwd:ctx.cwd??process.cwd()}); try{await bridge.waitUntilReady(undefined,signal); bridge.send({type:'ping'}); const pong=await bridge.waitForEvent('pong',5000,signal); if(!pong) throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); await notify(ctx,'Villani bridge ping succeeded.','info'); return true;}catch(e){throw new Error(bridgeDiagnosticMessage(e));}finally{bridge.kill(); await bridge.waitForExit(1500).catch(()=>{});}} async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['run_started','error','run_failed','run_aborted'],runId,10000,signal); if(event?.type==='run_started') return event; if(event){const msg=event.error||event.message||event.summary||event.type; throw new Error(`Villani bridge rejected run command: ${msg}`);} throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`);} async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000,signal); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} -export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; let postToolTimer:NodeJS.Timeout|undefined; const clearPostToolTimer=()=>{if(postToolTimer){clearTimeout(postToolTimer); postToolTimer=undefined;}}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi,cwd:ctx.cwd??process.cwd()}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const onBridgeEvent=(e:any)=>{ if(process.env.VILLANI_PI_DEBUG==='1') void notify(ctx,`Villani diagnostic: bridge event received: ${e.type}`,'info'); if(['model_request_started','model_request_completed','tool_started','approval_required','stream_text','run_completed','run_failed','run_aborted'].includes(e.type)) clearPostToolTimer(); if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); if(e.type==='tool_finished'){ void setStatus(ctx,'Villani received command result. Waiting for next step...'); if(process.env.VILLANI_PI_DEBUG==='1') void notify(ctx,'Villani diagnostic: waiting after tool result','info'); clearPostToolTimer(); postToolTimer=setTimeout(()=>{void setStatus(ctx,'Villani is still running. Waiting for the next runner event...');},5000); postToolTimer.unref?.(); } }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendDurableVillaniMessage(pi, ctx, finalMessage(final), final); } finally { if(activeRun===run){clearPostToolTimer(); denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } +export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; let postToolTimer:NodeJS.Timeout|undefined; let heartbeatTimer:NodeJS.Timeout|undefined; let heartbeatSeq=0; const clearPostToolTimer=()=>{if(postToolTimer){clearTimeout(postToolTimer); postToolTimer=undefined;}}; const clearHeartbeat=()=>{if(heartbeatTimer){clearInterval(heartbeatTimer); heartbeatTimer=undefined;}}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000,onEvent:(e:any)=>void renderBridgeEvent(e,pi,ctx)}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi,cwd:ctx.cwd??process.cwd()}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const startPostToolTimer=(eventType:string)=>{ clearPostToolTimer(); postToolTimer=setTimeout(async()=>{ try{ bridge.send({type:'ping',id:`${runId}-post-tool-ping`}); const pong=await bridge.waitForEvent('pong',3000,abort.signal); if(pong) await setStatus(ctx,`Villani is still running. Last event: ${eventType}. Bridge is alive.`); else await notify(ctx,`Villani bridge did not respond while waiting for next runner event. ${bridgeStderr(bridge)}`,'error'); }catch(err){ await notify(ctx,`Villani bridge ping failed while waiting: ${sanitizeError(err)} ${bridgeStderr(bridge)}`,'error'); } },5000); postToolTimer.unref?.(); }; const onBridgeEvent=(e:any)=>{ if(process.env.VILLANI_PI_DEBUG==='1') void notify(ctx,`Villani diagnostic: bridge event received: ${e.type}`,'info'); if(['model_request_started','model_request_completed','proxy_request_started','proxy_request_completed','command_started','command_finished','tool_started','approval_required','stream_text','run_completed','run_failed','run_aborted'].includes(e.type)) clearPostToolTimer(); if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); if(e.type==='tool_result'||e.type==='tool_finished') startPostToolTimer(e.type); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); heartbeatTimer=setInterval(async()=>{ try{ const id=`${runId}-heartbeat-${++heartbeatSeq}`; bridge.send({type:'ping',id}); const pong=await bridge.waitForEvent('pong',3000,abort.signal); if(!pong) await notify(ctx,`Villani bridge heartbeat failed. ${bridgeStderr(bridge)}`,'error'); else if(process.env.VILLANI_PI_DEBUG==='1') await notify(ctx,'Villani diagnostic: bridge heartbeat pong','info'); }catch(err){ await notify(ctx,`Villani bridge heartbeat failed: ${sanitizeError(err)} ${bridgeStderr(bridge)}`,'error'); } },1000); heartbeatTimer.unref?.(); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendDurableVillaniMessage(pi, ctx, finalMessage(final), final); } finally { if(activeRun===run){clearPostToolTimer(); clearHeartbeat(); denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); await notify(ctx,`active model id: ${model?.id??'unavailable'}`,'info'); await notify(ctx,`modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,'info'); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'auth resolution succeeded: true','info'); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn,completeSource:ctx.completeSource,completeImporter:ctx.completeImporter,pi:ctx.pi}); try{ await notify(ctx,`proxy URL: ${proxy.url}`,'info'); await notify(ctx,'POST /v1/chat/completions reached proxy: sending','info'); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); await notify(ctx,'POST /v1/chat/completions reached proxy: yes','info'); await notify(ctx,`selected Pi completion helper source: ${proxy.completionSource??'unavailable'}`,'info'); const text=await r.text(); if(!r.ok){await notify(ctx,`complete returned or failed: failed (${r.status})`,'info'); throw new Error(`HTTP ${r.status}: ${text}`);} await notify(ctx,'complete returned or failed: returned','info'); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); run.abort.abort(); if(!aborted) run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} export async function bridgePing(ctx:any):Promise{ await notify(ctx,'Villani bridge ping starting...','info'); const executable=await resolveVillaniRuntime({}); await notify(ctx,`runtime executable: ${executable}`,'info'); await assertBridgePing(executable,ctx); } diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts index 3a5eca93..9ab8f7e8 100644 --- a/integrations/pi-villani/src/modelProxy.ts +++ b/integrations/pi-villani/src/modelProxy.ts @@ -16,7 +16,7 @@ export async function resolvePiComplete(importer:PiCompleteResolver=(name)=>impo type Usage={input:number;output:number;totalTokens?:number}; type AssistantMessage={role?:'assistant';content?:any[];responseId?:string;timestamp:number;responseModel?:string;model?:string;usage?:Usage;stopReason?:'stop'|'toolUse'|'length'|'error'|'aborted'|string;errorMessage?:string}; -export interface PiModelProxyOptions { model:any; apiKey?:string; headers?:Record; signal?:AbortSignal; timeoutMs?:number; completeFn?:any; completeSource?:string; completeImporter?:PiCompleteResolver; pi?:any; } +export interface PiModelProxyOptions { model:any; apiKey?:string; headers?:Record; signal?:AbortSignal; timeoutMs?:number; completeFn?:any; completeSource?:string; completeImporter?:PiCompleteResolver; pi?:any; onEvent?:(event:any)=>void; } export class UpstreamModelError extends Error{constructor(message:string){super(message);this.name='UpstreamModelError';}} export class AbortRequestError extends Error{constructor(message='Pi model request aborted'){super(message);this.name='AbortRequestError';}} export function abortError(){return new AbortRequestError();} @@ -57,20 +57,22 @@ export class PiModelProxy { private server?:http.Server; completionSource?:strin private async handle(req:http.IncomingMessage,res:http.ServerResponse){if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} debug('POST /v1/chat/completions'); try{const body=await new Promise((resolve,reject)=>{let b=''; req.on('data',d=>b+=d); req.on('end',()=>resolve(b)); req.on('error',reject);}); const payload=JSON.parse(body||'{}'); const context=openAIChatToPiContext(payload); const assistant=normalizeAssistant(await this.complete(context,payload),payload,this.options.model); if(payload.stream) writeOpenAIStream(res,assistant); else writeJson(res,200,piAssistantToOpenAIResponse(assistant));}catch(e){debug(`Pi complete failed: ${sanitizeErrorMessage(e)}`); writeProxyError(res,e);}} private async complete(context:any,payload:any){ debug('calling Pi complete'); + this.options.onEvent?.({type:'proxy_request_started'}); + const completeSafely=async (fn:Function, receiver:any, args:any[])=>{try{const r=await fn.apply(receiver,args); this.options.onEvent?.({type:'proxy_request_completed'}); return r;}catch(e){this.options.onEvent?.({type:'proxy_request_failed', error:sanitizeErrorMessage(e)}); throw e;}}; const opts={apiKey:this.options.apiKey,headers:this.options.headers,maxTokens:payload.max_tokens,temperature:payload.temperature,signal:this.options.signal,timeoutMs:this.options.timeoutMs}; let completeFn=this.options.completeFn; if(typeof completeFn==='function'){ this.completionSource=this.options.completeSource??'injected:completeFn'; - const r=await completeFn(this.options.model,context,opts); debug('Pi complete returned'); return r; + const r=await completeSafely(completeFn, undefined, [this.options.model,context,opts]); debug('Pi complete returned'); return r; } let resolveError:unknown; try{const resolved=await resolvePiComplete(this.options.completeImporter); completeFn=resolved.fn; this.completionSource=resolved.source;}catch(e){resolveError=e;} - if(typeof completeFn==='function'){const r=await completeFn(this.options.model,context,opts); debug('Pi complete returned'); return r;} + if(typeof completeFn==='function'){const r=await completeSafely(completeFn, undefined, [this.options.model,context,opts]); debug('Pi complete returned'); return r;} debug(`Pi completion helper unavailable: ${sanitizeErrorMessage(resolveError)}`); const model=this.options.model; const fallbacks:[string,any][]=[['model.api.streamSimple',model?.api?.streamSimple],['model.complete',model?.complete],['ctx.pi.complete',this.options.pi?.complete]]; - for(const [source,fn] of fallbacks){if(typeof fn!=='function') continue; this.completionSource=source; const r=await fn.call(source==='ctx.pi.complete'?this.options.pi:(source==='model.complete'?model:model?.api),model,context,opts); debug(`${source} returned`); return r;} - throw new Error('Active Pi model cannot be proxied: no supported completion API found.'); + for(const [source,fn] of fallbacks){if(typeof fn!=='function') continue; this.completionSource=source; const receiver=source==='ctx.pi.complete'?this.options.pi:(source==='model.complete'?model:model?.api); const r=await completeSafely(fn, receiver, [model,context,opts]); debug(`${source} returned`); return r;} + { const error=new Error('Active Pi model cannot be proxied: no supported completion API found.'); this.options.onEvent?.({type:'proxy_request_failed', error:sanitizeErrorMessage(error)}); throw error; } } } export async function startModelProxyFromPiModel(options:PiModelProxyOptions){const p=new PiModelProxy(options); const url=await p.start(); return {url,close:()=>p.stop(),proxy:p,get completionSource(){return p.completionSource;}};} diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts index 37fed61c..885a8c7c 100644 --- a/integrations/pi-villani/src/process.ts +++ b/integrations/pi-villani/src/process.ts @@ -5,7 +5,7 @@ export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutN constructor(executable:string, opts:BridgeProcessOptions={}){super(); const args=opts.bridgeArgs??['bridge','--stdio']; this.startupTimeoutMs=opts.startupTimeoutMs??30000; this.proc=spawn(executable,args,{shell:false,env:sanitizedEnv(opts),cwd:opts.cwd}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e)); this.proc.once('exit',(code,signal)=>{this.exitInfo={code,signal}; this.emit('exit',{code,signal});});} getRecentStderr(){return this.stderr;} onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id)this.finals.set(e.id,e); this.emit('event',e);}catch{const err=new Error('Malformed JSONL from bridge'); this.kill(); this.emit('error',err);}}} - send(c:unknown){this.proc.stdin.write(JSON.stringify(c)+'\n');} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} + send(c:unknown){try{if(this.proc.stdin.destroyed||!this.proc.stdin.writable)return false; return this.proc.stdin.write(JSON.stringify(c)+'\n');}catch(e){this.emit('error',e); return false;}} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} private timeout(timeoutMs:number|undefined, fn:()=>void){if(!timeoutMs)return undefined; const t=setTimeout(fn,timeoutMs); t.unref?.(); return t;} waitForEvent(type:string,timeoutMs=1000,signal?:AbortSignal){return new Promise((res,rej)=>{if(this.exitInfo)return res(undefined); if(signal?.aborted)return res(undefined); let done=false; const cleanup=()=>{if(done)return false; done=true; this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const finish=(value?:BridgeEvent)=>{if(cleanup())res(value);}; const fail=(e:Error)=>{if(cleanup())rej(e);}; const on=(e:BridgeEvent)=>{if(e.type===type)finish(e);}; const onError=(e:Error)=>fail(e); const onExit=()=>finish(undefined); const onAbort=()=>finish(undefined); const t=this.timeout(timeoutMs,()=>finish(undefined)); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} waitForAnyEvent(types:string[],runId?:string,timeoutMs=1000,signal?:AbortSignal){return new Promise((res,rej)=>{if(this.exitInfo)return res(undefined); if(signal?.aborted)return res(undefined); const wanted=new Set(types); let done=false; const cleanup=()=>{if(done)return false; done=true; this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const finish=(value?:BridgeEvent)=>{if(cleanup())res(value);}; const fail=(e:Error)=>{if(cleanup())rej(e);}; const on=(e:BridgeEvent)=>{if(wanted.has(e.type)&&(!runId||!e.id||e.id===runId))finish(e);}; const onError=(e:Error)=>fail(e); const onExit=()=>finish(undefined); const onAbort=()=>finish(undefined); const t=this.timeout(timeoutMs,()=>finish(undefined)); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} diff --git a/integrations/pi-villani/src/protocol.ts b/integrations/pi-villani/src/protocol.ts index 2bdbecc5..3ea2a4e0 100644 --- a/integrations/pi-villani/src/protocol.ts +++ b/integrations/pi-villani/src/protocol.ts @@ -1,3 +1,9 @@ -export interface BridgeEvent { type: string; [key: string]: any } +export type BridgeEventType = + | 'tool_started' | 'tool_finished' | 'command_started' | 'command_finished' | 'tool_result' + | 'runner_heartbeat' | 'proxy_request_started' | 'proxy_request_completed' | 'proxy_request_failed' + | 'model_request_started' | 'model_request_completed' | 'model_request_failed' + | 'approval_required' | 'run_completed' | 'run_failed' | 'run_aborted' + | 'run_started' | 'phase' | 'bridge_diagnostic' | 'stream_text' | 'error'; +export interface BridgeEvent { type: BridgeEventType | string; [key: string]: any } export interface OpenAIMessage { role: string; content?: any; tool_call_id?: string; tool_calls?: any[]; [key:string]: any } export interface PiCompletionResult { text: string; tool_calls: any[] } diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 44569195..7c24370a 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -57,16 +57,29 @@ export async function renderBridgeEvent(event:any, _pi:any, ctx:any): Promise1000){lastStreamAt=now; await setStatus(ctx, `Villani: ${text}`);} } else if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); } diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py index 5d6a3c5b..c392a1ca 100644 --- a/tests/integrations/test_pi_bridge.py +++ b/tests/integrations/test_pi_bridge.py @@ -190,14 +190,14 @@ def test_approval_required_shape_and_bash_input_command(tmp_path): def test_map_runner_event_maps_tool_result_and_command_lifecycle(): from villani_code.integrations.pi_bridge import map_runner_event finished=map_runner_event('r', {'type':'tool_result','name':'Bash','input':{'command':'echo hi'},'is_error':False,'result':{'exit_code':0,'stdout':'hello'}}) - assert finished[0]['type']=='tool_finished' + assert finished[0]['type']=='tool_result' assert finished[0]['tool']=='Bash' assert finished[0]['ok'] is True assert 'Bash finished: exit 0' in finished[0]['summary'] - progress=map_runner_event('r', {'type':'command_started','command':'sleep 1'}) - assert progress==[{'type':'tool_progress','id':'r','tool':'Bash','message':'Running command: sleep 1'}] - done=map_runner_event('r', {'type':'command_finished','exit_code':1}) - assert done==[{'type':'tool_finished','id':'r','tool':'Bash','ok':False,'is_error':True,'summary':'Command finished: exit 1'}] + progress=map_runner_event('r', {'type':'command_started','command':'sleep 1','cwd':'/tmp'}) + assert progress==[{'type':'command_started','id':'r','tool':'Bash','command':'sleep 1','cwd':'/tmp'}] + done=map_runner_event('r', {'type':'command_finished','command':'false','exit_code':1,'stderr':'bad'}) + assert done==[{'type':'command_finished','id':'r','tool':'Bash','command':'false','exit_code':1,'stdout_preview':'','stderr_preview':'bad','truncated':False}] def test_bash_tool_result_summary_truncates_output(): from villani_code.integrations.pi_bridge import map_runner_event @@ -205,3 +205,32 @@ def test_bash_tool_result_summary_truncates_output(): ev=map_runner_event('r', {'type':'tool_result','name':'Bash','input':{'command':'pytest'},'result':{'exit_code':1,'stdout':long,'stderr':long}})[0] assert len(ev['summary']) < 1200 assert 'output truncated' in ev['summary'] + + +def test_nonzero_bash_events_do_not_map_to_run_failed(): + from villani_code.integrations.pi_bridge import map_runner_event + command = map_runner_event('r', {'type':'command_finished','command':'false','exit_code':255}) + result = map_runner_event('r', {'type':'tool_result','name':'Bash','is_error':False,'result':{'exit_code':255}}) + assert all(e['type'] != 'run_failed' for e in command + result) + assert command[0]['type'] == 'command_finished' + assert result[0]['type'] == 'tool_result' + assert result[0]['is_error'] is False + +def test_runner_heartbeat_is_emitted_after_idle_active_run(monkeypatch): + from villani_code.integrations import pi_bridge as mod + b,out,_,_=make_bridge() + ar=mod.ActiveRun(type('Cmd',(),{'id':'r-heart'})()) + ar.last_runner_event_type='tool_result' + ar.last_runner_event_at=0 + ar.heartbeat_due_at=0 + ar.thread=type('T',(),{'is_alive':lambda self: True})() + b._active['r-heart']=ar + b._stdio_running=True + monkeypatch.setattr(mod.time, 'monotonic', lambda: 16) + b._drain_events() + ev=events(out)[0] + assert ev['type']=='runner_heartbeat' + assert ev['id']=='r-heart' + assert ev['last_event_type']=='tool_result' + assert ev['seconds_since_last_event']==16 + assert ev['worker_alive'] is True diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index 3b1b9cef..e2f142c6 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -1,5 +1,5 @@ from __future__ import annotations -import hashlib,json,io,os,queue,subprocess,sys,threading,traceback +import hashlib,json,io,os,queue,subprocess,sys,threading,traceback,time from dataclasses import dataclass,field from pathlib import Path from typing import Any,Callable,TextIO @@ -96,16 +96,24 @@ def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: tool=str(event.get('name') or event.get('tool') or 'tool') result=event.get('result') if isinstance(event.get('result'),dict) else {} exit_code=event.get('exit_code', result.get('exit_code', result.get('returncode'))) - is_error=bool(event.get('is_error') or (tool=='Bash' and exit_code is not None and exit_code != 0)) + is_error=bool(event.get('is_error')) summary=summarize_tool_result(event) - out.append({'type':'tool_finished','id':run_id,'tool':tool,'ok':not is_error,'is_error':is_error,'summary':summary}) - out.append({'type':'bridge_diagnostic','id':run_id,'message':'tool result mapped'}) + base={'type':t,'id':run_id,'tool':tool,'ok':not is_error,'is_error':is_error,'summary':summary} + if exit_code is not None: base['exit_code']=exit_code + for key in ('stdout_preview','stderr_preview','truncated'): + if key in event: base[key]=event[key] + elif key in result: base[key]=result[key] + if 'stdout_preview' not in base and result.get('stdout') is not None: base['stdout_preview']=_preview(result.get('stdout')) + if 'stderr_preview' not in base and result.get('stderr') is not None: base['stderr_preview']=_preview(result.get('stderr')) + out.append(base) + out.append({'type':'bridge_diagnostic','id':run_id,'message':f'{t} mapped'}) if tool in {'Write','Patch','Edit'}: path=_workspace_path(event); out.append({'type':'workspace_changed','id':run_id, **({'path':path} if path else {})}) elif t=='command_started': - command=_cap(event.get('command',''),500); out.append({'type':'tool_progress','id':run_id,'tool':'Bash','message':f'Running command: {command}'}) + command=_cap(event.get('command',''),500); out.append({'type':'command_started','id':run_id,'tool':'Bash','command':command, **({'cwd':str(event.get('cwd'))} if event.get('cwd') else {})}) elif t=='command_finished': - exit_code=event.get('exit_code'); out.append({'type':'tool_finished','id':run_id,'tool':'Bash','ok':exit_code==0,'is_error':exit_code!=0,'summary':f'Command finished: exit {exit_code}'}) + exit_code=event.get('exit_code'); + out.append({'type':'command_finished','id':run_id,'tool':'Bash','command':_cap(event.get('command',''),500),'exit_code':exit_code,'stdout_preview':_preview(event.get('stdout_preview',event.get('stdout',''))),'stderr_preview':_preview(event.get('stderr_preview',event.get('stderr',''))),'truncated':bool(event.get('truncated'))}) elif t=='validation_step_started': out.append({'type':'verification_started','id':run_id,'name':event.get('name')}) elif t in {'validation_step_finished','validation_completed'}: out.append({'type':'verification_finished','id':run_id,'passed':event.get('passed')}) elif t in {'command_wandering_detected','progress_governor_redirected','governor_redirect'}: out.append({'type':'governor_redirect','id':run_id,'reason':event.get('reason')}) @@ -137,7 +145,7 @@ class PendingApproval: run_id:str; request_id:str; tool:str; ready:threading.Event=field(default_factory=threading.Event); approved:bool|None=None @dataclass(slots=True) class ActiveRun: - command:RunCommand; abort_requested:threading.Event=field(default_factory=threading.Event); thread:threading.Thread|None=None; pending_approvals:dict[str,PendingApproval]=field(default_factory=dict); touched_files:set[str]=field(default_factory=set); approval_seq:int=0 + command:RunCommand; abort_requested:threading.Event=field(default_factory=threading.Event); thread:threading.Thread|None=None; pending_approvals:dict[str,PendingApproval]=field(default_factory=dict); touched_files:set[str]=field(default_factory=set); approval_seq:int=0; last_runner_event_type:str='run_started'; last_runner_event_at:float=field(default_factory=time.monotonic); last_bridge_event_at:float=field(default_factory=time.monotonic); heartbeat_due_at:float=field(default_factory=lambda: time.monotonic()+15) class PiBridge: def __init__(self,*,stdin:TextIO|None=None,stdout:TextIO|None=None,stderr:TextIO|None=None,runner_factory:Callable[...,Any]|None=None)->None: self.stdin=stdin or sys.stdin; self.stdout=stdout or sys.stdout; self.stderr=stderr or sys.stderr; self.runner_factory=runner_factory or build_default_runner @@ -146,6 +154,10 @@ def __init__(self,*,stdin:TextIO|None=None,stdout:TextIO|None=None,stderr:TextIO def emit(self,e): self.stdout.write(to_json_line(e)); self.stdout.flush() def _queue_event(self,e:dict[str,Any]|None): + if e is not None and e.get('id'): + with self._lock: + ar=self._active.get(str(e.get('id'))) + if ar: ar.last_bridge_event_at=time.monotonic() if self._stdio_running: self._events.put(e) elif e is not None: self.emit(e) def _diagnostic(self,run_id:str|None,message:str,**extra:Any)->None: @@ -154,6 +166,13 @@ def _diagnostic(self,run_id:str|None,message:str,**extra:Any)->None: ev.update({k:v for k,v in extra.items() if k!='api_key'}) self._queue_event(ev) def _drain_events(self)->None: + now=time.monotonic() + with self._lock: + active=list(self._active.items()) + for run_id,ar in active: + if now-ar.last_runner_event_at>=15 and now>=ar.heartbeat_due_at: + ar.heartbeat_due_at=now+15 + self._events.put({'type':'runner_heartbeat','id':run_id,'last_event_type':ar.last_runner_event_type,'seconds_since_last_event':int(now-ar.last_runner_event_at),'worker_alive':bool(ar.thread and ar.thread.is_alive())}) while True: try: ev=self._events.get_nowait() except queue.Empty: return @@ -241,6 +260,7 @@ def _worker(self,ar): self._diagnostic(cmd.id,'run worker started') def ev(e): if e.get('type')=='approval_required': return + ar.last_runner_event_type=str(e.get('type') or 'unknown'); ar.last_runner_event_at=time.monotonic(); ar.heartbeat_due_at=ar.last_runner_event_at+15 for m in map_runner_event(cmd.id,e): self._queue_event(m) def appr(tool,inp): if ar.abort_requested.is_set(): return False From e2e97d97f67678ce35251dc2939145e4f02fbc48 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 09:15:55 +1000 Subject: [PATCH 25/36] Fix Pi Villani UI final output --- integrations/pi-villani/src/extension.test.ts | 718 +++++++++++++++++- integrations/pi-villani/src/index.ts | 678 ++++++++++++++++- integrations/pi-villani/src/render.ts | 355 +++++++-- tests/integrations/test_pi_bridge.py | 28 + villani_code/integrations/pi_bridge.py | 42 +- 5 files changed, 1695 insertions(+), 126 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index b110d4b3..bda0b509 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -1,59 +1,713 @@ -import test from 'node:test'; import assert from 'node:assert/strict'; import activate, { bridgePing } from './index.js'; -function api(){const commands:any={}; return {commands, registerCommand:(name:string,opts:any)=>{commands[name]=opts; assert.equal(typeof opts.handler,'function');}};} -test('registers Villani Pi commands',()=>{const a=api(); activate(a); assert.deepEqual(Object.keys(a.commands),['villani','villani-abort','villani-confirm-test','villani-ping','villani-doctor','villani-proxy-test','villani-bridge-ping']);}); -test('/villani-ping only notifies OK',async()=>{const a=api(); activate(a); const notes:any[]=[]; await a.commands['villani-ping'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani ping OK']);}); -test('/villani-confirm-test accepted, rejected, and missing UI are visible',async()=>{for(const value of [true,false]){const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m),confirm:async()=>value}}); assert.equal(notes[0],'Villani confirmation test starting...'); assert.equal(notes.at(-1),value?'Villani confirmation accepted':'Villani confirmation rejected');} const a=api(); activate(a); const notes:string[]=[]; await a.commands['villani-confirm-test'].handler('',{ui:{notify:(m:string)=>notes.push(m)}}); assert.deepEqual(notes,['Villani confirmation test starting...','Villani confirmation UI unavailable']);}); -test('/villani-doctor prints diagnostics without secrets',async()=>{const a=api(); activate(a); const notes:string[]=[]; process.env.VILLANI_API_KEY='secret'; try{await a.commands['villani-doctor'].handler('',{cwd:'/tmp/repo',model:{id:'m'},modelRegistry:{getApiKeyAndHeaders(){}} ,ui:{notify:(m:string)=>notes.push(m)}}); const msg=notes.join('\n'); assert.match(msg,/package version: 0.1.4/); assert.match(msg,/runtime version: 0.1.3/); assert.match(msg,/ctx.model exists: true/); assert.doesNotMatch(msg,/secret|VILLANI_API_KEY/);} finally{delete process.env.VILLANI_API_KEY;}}); -import { chmodSync, existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { runVillani } from './index.js'; -function bridgeScript(body:string){const d=mkdtempSync(join(tmpdir(),'pi-villani-run-')); const p=join(d,'bridge.mjs'); writeFileSync(p,`#!/usr/bin/env node\n${body}`); chmodSync(p,0o755); return p;} -const readyPrelude="process.stdout.write(JSON.stringify({type:'ready'})+'\\n');\nconst send=e=>process.stdout.write(JSON.stringify(e)+'\\n');\n"; +import test from "node:test"; +import assert from "node:assert/strict"; +import activate, { bridgePing } from "./index.js"; +function api() { + const commands: any = {}; + return { + commands, + registerCommand: (name: string, opts: any) => { + commands[name] = opts; + assert.equal(typeof opts.handler, "function"); + }, + }; +} +test("registers Villani Pi commands", () => { + const a = api(); + activate(a); + assert.deepEqual(Object.keys(a.commands), [ + "villani", + "villani-abort", + "villani-confirm-test", + "villani-ping", + "villani-doctor", + "villani-proxy-test", + "villani-bridge-ping", + ]); +}); +test("/villani-ping only notifies OK", async () => { + const a = api(); + activate(a); + const notes: any[] = []; + await a.commands["villani-ping"].handler("", { + ui: { notify: (m: string) => notes.push(m) }, + }); + assert.deepEqual(notes, ["Villani ping OK"]); +}); +test("/villani-confirm-test accepted, rejected, and missing UI are visible", async () => { + for (const value of [true, false]) { + const a = api(); + activate(a); + const notes: string[] = []; + await a.commands["villani-confirm-test"].handler("", { + ui: { notify: (m: string) => notes.push(m), confirm: async () => value }, + }); + assert.equal(notes[0], "Villani confirmation test starting..."); + assert.equal( + notes.at(-1), + value ? "Villani confirmation accepted" : "Villani confirmation rejected", + ); + } + const a = api(); + activate(a); + const notes: string[] = []; + await a.commands["villani-confirm-test"].handler("", { + ui: { notify: (m: string) => notes.push(m) }, + }); + assert.deepEqual(notes, [ + "Villani confirmation test starting...", + "Villani confirmation UI unavailable", + ]); +}); +test("/villani-doctor prints diagnostics without secrets", async () => { + const a = api(); + activate(a); + const notes: string[] = []; + process.env.VILLANI_API_KEY = "secret"; + try { + await a.commands["villani-doctor"].handler("", { + cwd: "/tmp/repo", + model: { id: "m" }, + modelRegistry: { getApiKeyAndHeaders() {} }, + ui: { notify: (m: string) => notes.push(m) }, + }); + const msg = notes.join("\n"); + assert.match(msg, /package version: 0.1.4/); + assert.match(msg, /runtime version: 0.1.3/); + assert.match(msg, /ctx.model exists: true/); + assert.doesNotMatch(msg, /secret|VILLANI_API_KEY/); + } finally { + delete process.env.VILLANI_API_KEY; + } +}); +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runVillani } from "./index.js"; +function bridgeScript(body: string) { + const d = mkdtempSync(join(tmpdir(), "pi-villani-run-")); + const p = join(d, "bridge.mjs"); + writeFileSync(p, `#!/usr/bin/env node\n${body}`); + chmodSync(p, 0o755); + return p; +} +const readyPrelude = + "process.stdout.write(JSON.stringify({type:'ready'})+'\\n');\nconst send=e=>process.stdout.write(JSON.stringify(e)+'\\n');\n"; +test("/villani sends command notification and only reports run started after run_started event", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'phase',id:msg.id,name:'x'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n", + ); + process.env.VILLANI_COMMAND = p; + const notes: string[] = []; + try { + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: (m: string) => notes.push(m) }, + { + ui: { notify: (m: string) => notes.push(m) }, + cwd: "/tmp", + model: { id: "m" }, + }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + delete process.env.VILLANI_USE_PI_MODEL; + } + const joined = notes.join("\n"); + assert.match(joined, /Villani starting/); + assert.doesNotMatch( + joined, + /bridge event received|heartbeat pong|model request started/, + ); +}); -test('/villani sends command notification and only reports run started after run_started event',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'phase',id:msg.id,name:'x'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:string)=>notes.push(m)},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp',model:{id:'m'},});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} const joined=notes.join('\n'); assert.match(joined,/Villani bridge ready/); assert.match(joined,/Villani run command sent/); assert.match(joined,/Villani run started\./); assert.ok(notes.indexOf('Villani run command sent.') { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')send({type:'pong'});}}); console.error('no ack'); setTimeout(()=>{},15000);\n", + ); + process.env.VILLANI_COMMAND = p; + const notes: string[] = []; + try { + process.env.VILLANI_USE_PI_MODEL = "false"; + await assert.rejects( + () => + runVillani( + "task", + {}, + { ui: { notify: (m: string) => notes.push(m) }, cwd: "/tmp" }, + ), + /did not acknowledge run command within 10 seconds.*no ack/s, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + delete process.env.VILLANI_USE_PI_MODEL; + } + assert.doesNotMatch(notes.join("\n"), /Villani run started\./); +}); -test('/villani missing run_started timeout reports visible error instead of started',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')send({type:'pong'});}}); console.error('no ack'); setTimeout(()=>{},15000);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{process.env.VILLANI_USE_PI_MODEL='false'; await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/did not acknowledge run command within 10 seconds.*no ack/s);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} assert.doesNotMatch(notes.join('\n'),/Villani run started\./);}); +async function waitForPidGone(pid: number, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch { + return; + } + await new Promise((r) => setTimeout(r, 50)); + } + assert.fail(`fake bridge process ${pid} remained alive`); +} - -async function waitForPidGone(pid:number,timeoutMs=3000){const deadline=Date.now()+timeoutMs; while(Date.now()setTimeout(r,50));} assert.fail(`fake bridge process ${pid} remained alive`);} - -test('/villani run_started timeout cleans up fake bridge process',async()=>{const oldCommand=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const d=mkdtempSync(join(tmpdir(),'pi-villani-cleanup-')); const pidFile=join(d,'pid'); const p=join(d,'bridge.mjs'); writeFileSync(p,`#!/usr/bin/env node +test("/villani run_started timeout cleans up fake bridge process", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const d = mkdtempSync(join(tmpdir(), "pi-villani-cleanup-")); + const pidFile = join(d, "pid"); + const p = join(d, "bridge.mjs"); + writeFileSync( + p, + `#!/usr/bin/env node import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(pidFile)},String(process.pid)); process.stdout.write(JSON.stringify({type:'ready'})+'\\n'); process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')process.stdout.write(JSON.stringify({type:'pong'})+'\\n');}}); console.error('cleanup no ack'); setInterval(()=>{},1000); -`); chmodSync(p,0o755); process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; const notes:string[]=[]; try{await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/did not acknowledge run command within 10 seconds.*cleanup no ack/s); assert.ok(existsSync(pidFile)); await waitForPidGone(Number(readFileSync(pidFile,'utf8'))); assert.doesNotMatch(notes.join('\n'),/Villani run started\./);} finally{if(oldCommand===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=oldCommand; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;}}); +`, + ); + chmodSync(p, 0o755); + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + const notes: string[] = []; + try { + await assert.rejects( + () => + runVillani( + "task", + {}, + { ui: { notify: (m: string) => notes.push(m) }, cwd: "/tmp" }, + ), + /did not acknowledge run command within 10 seconds.*cleanup no ack/s, + ); + assert.ok(existsSync(pidFile)); + await waitForPidGone(Number(readFileSync(pidFile, "utf8"))); + assert.doesNotMatch(notes.join("\n"), /Villani run started\./); + } finally { + if (oldCommand === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = oldCommand; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } +}); + +test("bridge exit before ready with ModuleNotFoundError produces pip install diagnostic", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + "console.error(\"ModuleNotFoundError: No module named 'villani_code'\"); process.exit(1);\n", + ); + process.env.VILLANI_COMMAND = p; + try { + await assert.rejects( + () => bridgePing({ ui: { notify: () => {} } }), + /pip install -e/, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + } +}); + +test("/villani-bridge-ping succeeds with fake bridge", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split(/\\n/)){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')send({type:'pong'});}}); setTimeout(()=>{},500);\n", + ); + process.env.VILLANI_COMMAND = p; + const notes: string[] = []; + try { + await bridgePing({ ui: { notify: (m: string) => notes.push(m) } }); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + } + assert.match(notes.join("\n"), /Villani bridge ping succeeded/); +}); + +test("/villani-bridge-ping surfaces stderr on failure", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript("console.error('boom stderr'); process.exit(2);\n"); + process.env.VILLANI_COMMAND = p; + try { + await assert.rejects( + () => bridgePing({ ui: { notify: () => {} } }), + /boom stderr/, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + } +}); +test("/villani surfaces bridge error before run_started without waiting 10 seconds", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='run')send({type:'error',id:msg.id,error:'bad run command'});}}); setTimeout(()=>{},15000);\n", + ); + process.env.VILLANI_COMMAND = p; + const notes: string[] = []; + const started = Date.now(); + try { + process.env.VILLANI_USE_PI_MODEL = "false"; + await assert.rejects( + () => + runVillani( + "task", + {}, + { ui: { notify: (m: string) => notes.push(m) }, cwd: "/tmp" }, + ), + /bad run command/, + ); + assert.ok(Date.now() - started < 5000); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + delete process.env.VILLANI_USE_PI_MODEL; + } + assert.doesNotMatch(notes.join("\n"), /Villani run started\./); +}); -test('bridge exit before ready with ModuleNotFoundError produces pip install diagnostic',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript("console.error(\"ModuleNotFoundError: No module named 'villani_code'\"); process.exit(1);\n"); process.env.VILLANI_COMMAND=p; try{await assert.rejects(()=>bridgePing({ui:{notify:()=>{}}}),/pip install -e/);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old;} }); +test("/villani launches bridge with ctx cwd", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const d = mkdtempSync(join(tmpdir(), "pi-villani-cwd-")); + const cwdFile = join(d, "cwd.txt"); + const p = bridgeScript( + readyPrelude + + `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(cwdFile)}, process.cwd()); process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'phase',id:msg.id,name:'x'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n`, + ); + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + try { + await runVillani( + "task", + { sendMessage: () => {} }, + { ui: { notify: () => {} }, cwd: d, model: { id: "m" } }, + ); + assert.equal(readFileSync(cwdFile, "utf8"), d); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } +}); -test('/villani-bridge-ping succeeds with fake bridge',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split(/\\n/)){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')send({type:'pong'});}}); setTimeout(()=>{},500);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; try{await bridgePing({ui:{notify:(m:string)=>notes.push(m)}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old;} assert.match(notes.join('\n'),/Villani bridge ping succeeded/);}); +test("VILLANI_PI_DEBUG sends bridge diagnostics to console not notify", async () => { + const old = process.env.VILLANI_PI_DEBUG; + const notes: string[] = []; + const errs: string[] = []; + const oldErr = console.error; + try { + process.env.VILLANI_PI_DEBUG = "1"; + console.error = (m: any) => errs.push(String(m)); + const { renderBridgeEvent } = await import("./render.js"); + await renderBridgeEvent( + { type: "bridge_diagnostic", message: "capturing initial git status" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + assert.equal(notes.length, 0); + assert.match(errs.join("\n"), /capturing initial git status/); + } finally { + console.error = oldErr; + if (old === undefined) delete process.env.VILLANI_PI_DEBUG; + else process.env.VILLANI_PI_DEBUG = old; + } +}); -test('/villani-bridge-ping surfaces stderr on failure',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript("console.error('boom stderr'); process.exit(2);\n"); process.env.VILLANI_COMMAND=p; try{await assert.rejects(()=>bridgePing({ui:{notify:()=>{}}}),/boom stderr/);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old;} }); +test("sendDurableVillaniMessage sends a structured iterable custom message", async () => { + const { sendDurableVillaniMessage } = await import("./render.js"); + const sent: any[] = []; + await sendDurableVillaniMessage( + { sendMessage: (m: any) => sent.push(m) }, + { ui: { notify: () => assert.fail("notify should not be used") } }, + "hello", + { + type: "run_completed", + authorization: "Bearer secret", + headers: { authorization: "Bearer secret" }, + apiKey: "secret", + token: "secret", + }, + ); + assert.equal(sent.length, 1); + assert.notEqual(typeof sent[0], "string"); + assert.equal(sent[0].customType, "villani-result"); + assert.equal(sent[0].display, true); + assert.deepEqual(sent[0].content, [{ type: "text", text: "hello" }]); + assert.ok(Symbol.iterator in Object(sent[0].content)); + assert.doesNotMatch( + JSON.stringify(sent[0].details), + /secret|authorization|apiKey|token/i, + ); +}); -test('/villani surfaces bridge error before run_started without waiting 10 seconds',async()=>{const old=process.env.VILLANI_COMMAND; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='run')send({type:'error',id:msg.id,error:'bad run command'});}}); setTimeout(()=>{},15000);\n"); process.env.VILLANI_COMMAND=p; const notes:string[]=[]; const started=Date.now(); try{process.env.VILLANI_USE_PI_MODEL='false'; await assert.rejects(()=>runVillani('task',{},{ui:{notify:(m:string)=>notes.push(m)},cwd:'/tmp'}),/bad run command/); assert.ok(Date.now()-started<5000);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; delete process.env.VILLANI_USE_PI_MODEL;} assert.doesNotMatch(notes.join('\n'),/Villani run started\./);}); +test("sendDurableVillaniMessage falls back to ctx.ui.notify if Pi sendMessage throws", async () => { + const { sendDurableVillaniMessage } = await import("./render.js"); + const notes: string[] = []; + await sendDurableVillaniMessage( + { + sendMessage: async () => { + throw new Error("boom"); + }, + }, + { + ui: { notify: (m: string, level: string) => notes.push(`${level}:${m}`) }, + }, + "fallback text", + ); + assert.deepEqual(notes, ["info:fallback text"]); +}); -test('/villani launches bridge with ctx cwd',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const d=mkdtempSync(join(tmpdir(),'pi-villani-cwd-')); const cwdFile=join(d,'cwd.txt'); const p=bridgeScript(readyPrelude+`import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(cwdFile)}, process.cwd()); process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'phase',id:msg.id,name:'x'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n`); process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; try{await runVillani('task',{sendMessage:()=>{}},{ui:{notify:()=>{}},cwd:d,model:{id:'m'}}); assert.equal(readFileSync(cwdFile,'utf8'),d);} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;}}); +test("renderBridgeEvent does not send durable final messages", async () => { + const { renderBridgeEvent } = await import("./render.js"); + let sent = 0; + const notes: string[] = []; + for (const type of ["run_completed", "run_failed", "run_aborted"]) + await renderBridgeEvent( + { type, summary: "done" }, + { sendMessage: () => sent++ }, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + assert.equal(sent, 0); + assert.deepEqual(notes, []); +}); -test('VILLANI_PI_DEBUG renders bridge diagnostics visibly',async()=>{const old=process.env.VILLANI_PI_DEBUG; const notes:string[]=[]; try{process.env.VILLANI_PI_DEBUG='1'; const { renderBridgeEvent } = await import('./render.js'); await renderBridgeEvent({type:'bridge_diagnostic',message:'capturing initial git status'},{},{ui:{notify:(m:string)=>notes.push(m)}}); assert.match(notes.join('\n'),/capturing initial git status/);} finally{if(old===undefined) delete process.env.VILLANI_PI_DEBUG; else process.env.VILLANI_PI_DEBUG=old;}}); +test("/villani sends exactly one iterable final durable message with summary metadata", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'model_request_started',id:msg.id}); send({type:'run_completed',id:msg.id,summary:'done',changed_files:['src/a.ts','.villani/secret'],transcript_path:'/tmp/transcript.jsonl',verification_status:'passed',authorization:'Bearer secret',headers:{authorization:'Bearer secret'}});}}); setTimeout(()=>{},500);\n", + ); + const sent: any[] = []; + try { + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: (m: any) => sent.push(m) }, + { ui: { notify: () => {} }, cwd: "/tmp", model: { id: "m" } }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } + assert.equal(sent.length, 1); + const payload = sent[0]; + assert.equal(payload.customType, "villani-result"); + assert.deepEqual(payload.content, [ + { type: "text", text: payload.content[0].text }, + ]); + assert.ok(Array.isArray(payload.content)); + assert.match(payload.content[0].text, /Villani completed/); + assert.match(payload.content[0].text, /done/); + assert.match(payload.content[0].text, /src\/a\.ts/); + assert.doesNotMatch(payload.content[0].text, /\.villani\/secret/); + assert.match(payload.content[0].text, /Transcript: \/tmp\/transcript\.jsonl/); + assert.match(payload.content[0].text, /Verification: passed/); + assert.doesNotMatch( + JSON.stringify(payload.details), + /authorization|Bearer secret/i, + ); +}); -test('sendDurableVillaniMessage sends a structured iterable custom message',async()=>{const { sendDurableVillaniMessage } = await import('./render.js'); const sent:any[]=[]; await sendDurableVillaniMessage({sendMessage:(m:any)=>sent.push(m)},{ui:{notify:()=>assert.fail('notify should not be used')}} ,'hello',{type:'run_completed',authorization:'Bearer secret',headers:{authorization:'Bearer secret'},apiKey:'secret',token:'secret'}); assert.equal(sent.length,1); assert.notEqual(typeof sent[0],'string'); assert.equal(sent[0].customType,'villani-result'); assert.equal(sent[0].display,true); assert.deepEqual(sent[0].content,[{type:'text',text:'hello'}]); assert.ok(Symbol.iterator in Object(sent[0].content)); assert.doesNotMatch(JSON.stringify(sent[0].details),/secret|authorization|apiKey|token/i);}); +test("approvalMessage never renders object and includes Bash command", async () => { + const { approvalMessage, approvalTitle } = await import("./index.js"); + const msg = approvalMessage({ + tool: "Bash", + summary: "Run command", + input: { command: "echo hi" }, + }); + assert.doesNotMatch(msg, /\[object Object\]/); + assert.match(msg, /Command:\necho hi/); + assert.equal( + approvalTitle({ tool: "Bash" }), + "Villani wants to run a shell command", + ); +}); -test('sendDurableVillaniMessage falls back to ctx.ui.notify if Pi sendMessage throws',async()=>{const { sendDurableVillaniMessage } = await import('./render.js'); const notes:string[]=[]; await sendDurableVillaniMessage({sendMessage:async()=>{throw new Error('boom');}},{ui:{notify:(m:string,level:string)=>notes.push(`${level}:${m}`)}} ,'fallback text'); assert.deepEqual(notes,['info:fallback text']);}); +test("confirm passes signal option to ctx.ui.confirm", async () => { + const { confirm } = await import("./render.js"); + const signal = new AbortController().signal; + let args: any[] = []; + const ok = await confirm( + { + ui: { + confirm: async (...a: any[]) => { + args = a; + return true; + }, + }, + }, + "t", + "m", + { signal }, + ); + assert.equal(ok, true); + assert.equal(args[2].signal, signal); +}); -test('renderBridgeEvent does not send durable final messages',async()=>{const { renderBridgeEvent } = await import('./render.js'); let sent=0; const notes:string[]=[]; for(const type of ['run_completed','run_failed','run_aborted']) await renderBridgeEvent({type,summary:'done'},{sendMessage:()=>sent++},{ui:{notify:(m:string)=>notes.push(m)}}); assert.equal(sent,0); assert.deepEqual(notes,[]);}); +test("/villani approval pending sets status/widget and accepted clears widget", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='approval_response'){send({type:'tool_progress',id:msg.id,tool:'Bash',message:'Running command: echo hi'}); send({type:'run_completed',id:msg.id,summary:'done'}); continue;} send({type:'run_started',id:msg.id}); send({type:'approval_required',id:msg.id,request_id:msg.id+':1',tool:'Bash',summary:'Run command',input:{command:'echo hi'}});}}); setTimeout(()=>{},500);\n", + ); + const statuses: any[] = []; + const widgets: any[] = []; + const confirms: any[] = []; + try { + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: () => {} }, + { + ui: { + notify: () => {}, + setStatus: (...a: any[]) => statuses.push(a), + setWidget: (...a: any[]) => widgets.push(a), + confirm: async (...a: any[]) => { + confirms.push(a); + return true; + }, + }, + cwd: "/tmp", + model: { id: "m" }, + }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } + assert.ok( + statuses.some((a) => a[0] === "villani" && /awaiting approval/.test(a[1])), + ); + assert.ok(widgets.some((a) => a[0] === "villani" && Array.isArray(a[1]))); + assert.ok(widgets.some((a) => a[0] === "villani" && a[1] === undefined)); + assert.equal(confirms[0][2].signal instanceof AbortSignal, true); +}); -test('/villani sends exactly one iterable final durable message with summary metadata',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'model_request_started',id:msg.id}); send({type:'run_completed',id:msg.id,summary:'done',changed_files:['src/a.ts','.villani/secret'],transcript_path:'/tmp/transcript.jsonl',verification_status:'passed',authorization:'Bearer secret',headers:{authorization:'Bearer secret'}});}}); setTimeout(()=>{},500);\n"); const sent:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:any)=>sent.push(m)},{ui:{notify:()=>{}},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.equal(sent.length,1); const payload=sent[0]; assert.equal(payload.customType,'villani-result'); assert.deepEqual(payload.content,[{type:'text',text:payload.content[0].text}]); assert.ok(Array.isArray(payload.content)); assert.match(payload.content[0].text,/Villani completed/); assert.match(payload.content[0].text,/done/); assert.match(payload.content[0].text,/src\/a\.ts/); assert.doesNotMatch(payload.content[0].text,/\.villani\/secret/); assert.match(payload.content[0].text,/Transcript: \/tmp\/transcript\.jsonl/); assert.match(payload.content[0].text,/Verification: passed/); assert.doesNotMatch(JSON.stringify(payload.details),/authorization|Bearer secret/i);}); +test("tool result and finished events update status without notify spam", async () => { + const { renderBridgeEvent } = await import("./render.js"); + const notes: string[] = []; + const statuses: string[] = []; + await renderBridgeEvent( + { + type: "tool_finished", + tool: "Bash", + summary: "Command finished: exit 0", + ok: true, + }, + {}, + { + ui: { + notify: (m: string) => notes.push(m), + setStatus: (_: string, m: string) => statuses.push(m), + }, + }, + ); + await renderBridgeEvent( + { type: "tool_progress", message: "Running command: echo hi" }, + {}, + { + ui: { + notify: (m: string) => notes.push(m), + setStatus: (_: string, m: string) => statuses.push(m), + }, + }, + ); + assert.deepEqual(notes, []); + assert.ok(statuses.includes("Command finished")); + assert.ok(statuses.some((s) => /Villani: Running command/.test(s))); +}); -test('approvalMessage never renders object and includes Bash command',async()=>{const { approvalMessage, approvalTitle } = await import('./index.js'); const msg=approvalMessage({tool:'Bash',summary:'Run command',input:{command:'echo hi'}}); assert.doesNotMatch(msg,/\[object Object\]/); assert.match(msg,/Command:\necho hi/); assert.equal(approvalTitle({tool:'Bash'}),'Villani wants to run a shell command');}); +test("/villani keeps waiting after nonzero tool_finished and renders next model event", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'tool_finished',id:msg.id,tool:'Bash',ok:false,is_error:true,summary:'Bash finished: exit 255\\nstderr: head not recognized'}); setTimeout(()=>send({type:'model_request_started',id:msg.id}),50); setTimeout(()=>send({type:'run_completed',id:msg.id,summary:'done'}),100);}}); setTimeout(()=>{},500);\n", + ); + const statuses: string[] = []; + const notes: string[] = []; + const sent: any[] = []; + try { + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: (m: any) => sent.push(m) }, + { + ui: { + notify: (m: string) => notes.push(m), + setStatus: (_: string, m: string) => statuses.push(m), + }, + cwd: "/tmp", + model: { id: "m" }, + }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } + assert.doesNotMatch(notes.join("\n"), /Villani tool finished/); + assert.ok(statuses.includes("Command finished")); + assert.ok(statuses.includes("Villani is thinking...")); + assert.equal(sent.length, 1); +}); -test('confirm passes signal option to ctx.ui.confirm',async()=>{const { confirm } = await import('./render.js'); const signal=new AbortController().signal; let args:any[]=[]; const ok=await confirm({ui:{confirm:async(...a:any[])=>{args=a; return true;}}},'t','m',{signal}); assert.equal(ok,true); assert.equal(args[2].signal,signal);}); +test("repeated model_request_started updates status without notify spam", async () => { + const { renderBridgeEvent } = await import("./render.js"); + const statuses: string[] = []; + const notes: string[] = []; + const ctx = { + ui: { + setStatus: (_: string, m: string) => statuses.push(m), + notify: (m: string) => notes.push(m), + }, + }; + await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); + await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); + assert.deepEqual(statuses, [ + "Villani is thinking...", + "Villani is thinking...", + ]); + assert.deepEqual(notes, []); +}); -test('/villani approval pending sets status/widget and accepted clears widget',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='approval_response'){send({type:'tool_progress',id:msg.id,tool:'Bash',message:'Running command: echo hi'}); send({type:'run_completed',id:msg.id,summary:'done'}); continue;} send({type:'run_started',id:msg.id}); send({type:'approval_required',id:msg.id,request_id:msg.id+':1',tool:'Bash',summary:'Run command',input:{command:'echo hi'}});}}); setTimeout(()=>{},500);\n"); const statuses:any[]=[]; const widgets:any[]=[]; const confirms:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:()=>{}},{ui:{notify:()=>{},setStatus:(...a:any[])=>statuses.push(a),setWidget:(...a:any[])=>widgets.push(a),confirm:async(...a:any[])=>{confirms.push(a); return true;}},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.ok(statuses.some(a=>a[0]==='villani'&&/awaiting approval/.test(a[1]))); assert.ok(widgets.some(a=>a[0]==='villani'&&Array.isArray(a[1]))); assert.ok(widgets.some(a=>a[0]==='villani'&&a[1]===undefined)); assert.equal(confirms[0][2].signal instanceof AbortSignal,true);}); +test("bridge heartbeat pong never calls notify, including debug", async () => { + const { renderBridgeEvent } = await import("./render.js"); + const old = process.env.VILLANI_PI_DEBUG; + const notes: string[] = []; + try { + delete process.env.VILLANI_PI_DEBUG; + await renderBridgeEvent( + { type: "pong" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + process.env.VILLANI_PI_DEBUG = "1"; + await renderBridgeEvent( + { type: "bridge_diagnostic", message: "bridge heartbeat pong" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + assert.deepEqual(notes, []); + } finally { + if (old === undefined) delete process.env.VILLANI_PI_DEBUG; + else process.env.VILLANI_PI_DEBUG = old; + } +}); -test('tool result and finished events render readable summaries',async()=>{const { renderBridgeEvent } = await import('./render.js'); const notes:string[]=[]; await renderBridgeEvent({type:'tool_finished',tool:'Bash',summary:'Command finished: exit 0',ok:true},{},{ui:{notify:(m:string)=>notes.push(m)}}); await renderBridgeEvent({type:'tool_progress',message:'Running command: echo hi'},{},{ui:{notify:(m:string)=>notes.push(m)}}); assert.match(notes.join('\n'),/Villani tool finished: Bash — Command finished: exit 0/); assert.match(notes.join('\n'),/Villani: Running command: echo hi/);}); +test("bridge_diagnostic does not notify unless debug enabled and debug uses console", async () => { + const { renderBridgeEvent } = await import("./render.js"); + const old = process.env.VILLANI_PI_DEBUG; + const oldErr = console.error; + const notes: string[] = []; + const errs: string[] = []; + try { + delete process.env.VILLANI_PI_DEBUG; + await renderBridgeEvent( + { type: "bridge_diagnostic", message: "model request started" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + process.env.VILLANI_PI_DEBUG = "1"; + console.error = (m: any) => errs.push(String(m)); + await renderBridgeEvent( + { type: "bridge_diagnostic", message: "model request started" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + assert.deepEqual(notes, []); + assert.match(errs.join("\n"), /model request started/); + } finally { + console.error = oldErr; + if (old === undefined) delete process.env.VILLANI_PI_DEBUG; + else process.env.VILLANI_PI_DEBUG = old; + } +}); -test('/villani keeps waiting after nonzero tool_finished and renders next model event',async()=>{const old=process.env.VILLANI_COMMAND; const oldUsePi=process.env.VILLANI_USE_PI_MODEL; const p=bridgeScript(readyPrelude+"process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'tool_finished',id:msg.id,tool:'Bash',ok:false,is_error:true,summary:'Bash finished: exit 255\\nstderr: head not recognized'}); setTimeout(()=>send({type:'model_request_started',id:msg.id}),50); setTimeout(()=>send({type:'run_completed',id:msg.id,summary:'done'}),100);}}); setTimeout(()=>{},500);\n"); const statuses:string[]=[]; const notes:string[]=[]; const sent:any[]=[]; try{process.env.VILLANI_COMMAND=p; process.env.VILLANI_USE_PI_MODEL='false'; await runVillani('task',{sendMessage:(m:any)=>sent.push(m)},{ui:{notify:(m:string)=>notes.push(m),setStatus:(_:string,m:string)=>statuses.push(m)},cwd:'/tmp',model:{id:'m'}});} finally{if(old===undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND=old; if(oldUsePi===undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL=oldUsePi;} assert.match(notes.join('\n'),/Villani tool finished: Bash — Bash finished: exit 255/); assert.ok(statuses.includes('Villani finished tool handling')); assert.ok(statuses.includes('Villani is thinking...')); assert.equal(sent.length,1);}); +test("command lifecycle sets then clears widget through final result", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'command_started',id:msg.id,command:'echo hi'}); send({type:'command_finished',id:msg.id,command:'echo hi',exit_code:0,stdout_preview:'hi'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n", + ); + const widgets: any[] = []; + const sent: any[] = []; + try { + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: (m: any) => sent.push(m) }, + { + ui: { + notify: () => {}, + setWidget: (...a: any[]) => widgets.push(a), + setStatus: () => {}, + }, + cwd: "/tmp", + model: { id: "m" }, + }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } + assert.ok( + widgets.some((a) => a[0] === "villani" && String(a[1]).includes("echo hi")), + ); + assert.ok(widgets.some((a) => a[0] === "villani" && a[1] === undefined)); + assert.equal(sent.length, 1); +}); -test('repeated model_request_started updates status without notify spam',async()=>{const { renderBridgeEvent } = await import('./render.js'); const statuses:string[]=[]; const notes:string[]=[]; const ctx={ui:{setStatus:(_:string,m:string)=>statuses.push(m),notify:(m:string)=>notes.push(m)}}; await renderBridgeEvent({type:'model_request_started'},{},ctx); await renderBridgeEvent({type:'model_request_started'},{},ctx); assert.deepEqual(statuses,['Villani is thinking...','Villani is thinking...']); assert.deepEqual(notes,[]);}); +test("villani-result renderer displays summary text", async () => { + const { renderVillaniResultMessage } = await import("./render.js"); + assert.match( + String( + renderVillaniResultMessage({ + content: [{ type: "text", text: "Villani completed\n\nSummary" }], + }), + ), + /Summary/, + ); +}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index c59a7a7b..a22ba6fd 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -1,34 +1,654 @@ -import { randomUUID } from 'node:crypto'; -import { existsSync } from 'node:fs'; -import { resolveVillaniRuntime, cacheRoot } from './runtime.js'; -import { VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; -import { VillaniBridgeProcess } from './process.js'; -import { resolvePiModel, sanitizeError, startModelProxyFromPiModel } from './modelProxy.js'; -import { confirm, finalMessage, notify, renderBridgeEvent, sendDurableVillaniMessage, setStatus, setWidget } from './render.js'; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { resolveVillaniRuntime, cacheRoot } from "./runtime.js"; +import { + VILLANI_RUNTIME_TAG, + VILLANI_RUNTIME_VERSION, +} from "./runtimeConfig.js"; +import { VillaniBridgeProcess } from "./process.js"; +import { + resolvePiModel, + sanitizeError, + startModelProxyFromPiModel, +} from "./modelProxy.js"; +import { + confirm, + finalMessage, + notify, + renderBridgeEvent, + renderVillaniResultMessage, + resetVillaniUiState, + sendDurableVillaniMessage, + setStatus, + setWidget, +} from "./render.js"; -type ActiveRun={id:string, abort:AbortController, bridge?:VillaniBridgeProcess, proxy?:{close:()=>void|Promise}, pending:Map}; -let activeRun:ActiveRun|null=null; -export function buildChildEnvForProxy(proxyUrl:string, model:any):NodeJS.ProcessEnv{const env={...process.env}; for(const k of Object.keys(env)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k)) delete env[k];} env.VILLANI_PROVIDER='openai'; env.VILLANI_MODEL=model?.id??'pi-current-model'; env.VILLANI_BASE_URL=proxyUrl; return env;} -function envConfig(){return {provider:process.env.VILLANI_PROVIDER,model:process.env.VILLANI_MODEL,base_url:process.env.VILLANI_BASE_URL,api_key:process.env.VILLANI_API_KEY};} -export async function resolveModelAuth(ctx:any, model:any){ if(!ctx.modelRegistry?.getApiKeyAndHeaders) throw new Error('Pi modelRegistry.getApiKeyAndHeaders is unavailable.'); const auth=await ctx.modelRegistry.getApiKeyAndHeaders(model); if(!auth?.ok) throw new Error(`Villani could not resolve Pi model authentication: ${sanitizeError(auth?.error ?? 'unknown error')}`); return {apiKey:auth.apiKey,headers:auth.headers}; } -export async function safeCommand(ctx:any,label:string,fn:()=>Promise):Promise{try{await fn();}catch(error){const message=error instanceof Error?error.message:String(error); await notify(ctx,`${label} failed: ${message}`,'error'); if(process.env.VILLANI_PI_DEBUG==='1') console.error(error);}} -export function approvalTitle(request:any) { if (request.tool === 'Bash') return 'Villani wants to run a shell command'; if (request.tool === 'Write') return 'Villani wants to write a file'; if (request.tool === 'Patch') return 'Villani wants to apply a patch'; return 'Villani wants approval'; } -export function approvalMessage(request:any) { const input = request.input && typeof request.input === 'object' && !Array.isArray(request.input) ? request.input : {}; const path = typeof input.path === 'string' ? input.path : undefined; const command = typeof input.command === 'string' ? input.command : undefined; const safeSummary = typeof request.summary === 'string' && request.summary.trim() ? request.summary : 'Approval required'; const lines = [safeSummary, '']; if (command) lines.push('Command:', command, ''); else if (path) lines.push('File:', path, ''); lines.push('Allow this operation?'); return lines.join('\n'); } -async function handleApproval(run:ActiveRun, ctx:any, e:any){const requestId=e.request_id||e.requestId; if(!requestId||run.pending.has(requestId))return; const tool=String(e.tool||'tool'); run.pending.set(requestId,false); let approved=false; const message=approvalMessage(e); try{await setStatus(ctx,`Villani awaiting approval: ${tool}`); await setWidget(ctx,['Villani is awaiting approval',message]); approved=await confirm(ctx,approvalTitle(e),message,{signal:run.abort.signal});}catch(err){approved=false; await notify(ctx,`Villani approval UI failed; denying request: ${err instanceof Error?err.message:String(err)}`,'warn');} finally {await setWidget(ctx,undefined);} if(run.pending.get(requestId)!==false)return; run.pending.set(requestId,true); await setStatus(ctx,approved?'Villani approval accepted':'Villani approval denied'); run.bridge?.respondToApproval(run.id,requestId,approved); if(process.env.VILLANI_PI_DEBUG==='1') await notify(ctx,'Villani diagnostic: approval response sent','info');} -function denyPending(run:ActiveRun){for(const [id,done] of run.pending){if(!done){run.pending.set(id,true); run.bridge?.respondToApproval(run.id,id,false);}}} -function bridgeStderr(bridge:VillaniBridgeProcess){return bridge.getRecentStderr?.() ?? bridge.stderr ?? '';} -function bridgeDiagnosticMessage(e:unknown){const msg=sanitizeError(e); if(/ModuleNotFoundError: No module named ['"]villani_code['"]/.test(msg)) return `Villani bridge failed because the selected VILLANI_COMMAND could not import villani_code. +type ActiveRun = { + id: string; + abort: AbortController; + bridge?: VillaniBridgeProcess; + proxy?: { close: () => void | Promise }; + pending: Map; +}; +let activeRun: ActiveRun | null = null; +export function buildChildEnvForProxy( + proxyUrl: string, + model: any, +): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const k of Object.keys(env)) { + if ( + ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "VILLANI_API_KEY"].includes(k) || + /(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k) + ) + delete env[k]; + } + env.VILLANI_PROVIDER = "openai"; + env.VILLANI_MODEL = model?.id ?? "pi-current-model"; + env.VILLANI_BASE_URL = proxyUrl; + return env; +} +function envConfig() { + return { + provider: process.env.VILLANI_PROVIDER, + model: process.env.VILLANI_MODEL, + base_url: process.env.VILLANI_BASE_URL, + api_key: process.env.VILLANI_API_KEY, + }; +} +export async function resolveModelAuth(ctx: any, model: any) { + if (!ctx.modelRegistry?.getApiKeyAndHeaders) + throw new Error("Pi modelRegistry.getApiKeyAndHeaders is unavailable."); + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); + if (!auth?.ok) + throw new Error( + `Villani could not resolve Pi model authentication: ${sanitizeError(auth?.error ?? "unknown error")}`, + ); + return { apiKey: auth.apiKey, headers: auth.headers }; +} +export async function safeCommand( + ctx: any, + label: string, + fn: () => Promise, +): Promise { + try { + await fn(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await notify(ctx, `${label} failed: ${message}`, "error"); + if (process.env.VILLANI_PI_DEBUG === "1") console.error(error); + } +} +export function approvalTitle(request: any) { + if (request.tool === "Bash") return "Villani wants to run a shell command"; + if (request.tool === "Write") return "Villani wants to write a file"; + if (request.tool === "Patch") return "Villani wants to apply a patch"; + return "Villani wants approval"; +} +export function approvalMessage(request: any) { + const input = + request.input && + typeof request.input === "object" && + !Array.isArray(request.input) + ? request.input + : {}; + const path = typeof input.path === "string" ? input.path : undefined; + const command = typeof input.command === "string" ? input.command : undefined; + const safeSummary = + typeof request.summary === "string" && request.summary.trim() + ? request.summary + : "Approval required"; + const lines = [safeSummary, ""]; + if (command) lines.push("Command:", command, ""); + else if (path) lines.push("File:", path, ""); + lines.push("Allow this operation?"); + return lines.join("\n"); +} +async function handleApproval(run: ActiveRun, ctx: any, e: any) { + const requestId = e.request_id || e.requestId; + if (!requestId || run.pending.has(requestId)) return; + const tool = String(e.tool || "tool"); + run.pending.set(requestId, false); + let approved = false; + const message = approvalMessage(e); + try { + await setStatus(ctx, `Villani awaiting approval: ${tool}`); + await setWidget(ctx, ["Villani is awaiting approval", message]); + approved = await confirm(ctx, approvalTitle(e), message, { + signal: run.abort.signal, + }); + } catch (err) { + approved = false; + await notify( + ctx, + `Villani approval UI failed; denying request: ${err instanceof Error ? err.message : String(err)}`, + "warn", + ); + } finally { + await setWidget(ctx, undefined); + } + if (run.pending.get(requestId) !== false) return; + run.pending.set(requestId, true); + await setStatus( + ctx, + approved ? "Villani approval accepted" : "Villani approval denied", + ); + run.bridge?.respondToApproval(run.id, requestId, approved); + if (process.env.VILLANI_PI_DEBUG === "1") + await notify(ctx, "Villani diagnostic: approval response sent", "info"); +} +function denyPending(run: ActiveRun) { + for (const [id, done] of run.pending) { + if (!done) { + run.pending.set(id, true); + run.bridge?.respondToApproval(run.id, id, false); + } + } +} +function bridgeStderr(bridge: VillaniBridgeProcess) { + return bridge.getRecentStderr?.() ?? bridge.stderr ?? ""; +} +function bridgeDiagnosticMessage(e: unknown) { + const msg = sanitizeError(e); + if (/ModuleNotFoundError: No module named ['"]villani_code['"]/.test(msg)) + return `Villani bridge failed because the selected VILLANI_COMMAND could not import villani_code. From repo root, run: .\\.venv\\Scripts\\python.exe -m pip install -e . -${msg}`; return msg;} -async function assertBridgePing(executable:string, ctx:any, env?:NodeJS.ProcessEnv, signal?:AbortSignal){const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,cwd:ctx.cwd??process.cwd()}); try{await bridge.waitUntilReady(undefined,signal); bridge.send({type:'ping'}); const pong=await bridge.waitForEvent('pong',5000,signal); if(!pong) throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); await notify(ctx,'Villani bridge ping succeeded.','info'); return true;}catch(e){throw new Error(bridgeDiagnosticMessage(e));}finally{bridge.kill(); await bridge.waitForExit(1500).catch(()=>{});}} -async function waitForRunAcknowledgement(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['run_started','error','run_failed','run_aborted'],runId,10000,signal); if(event?.type==='run_started') return event; if(event){const msg=event.error||event.message||event.summary||event.type; throw new Error(`Villani bridge rejected run command: ${msg}`);} throw new Error(`Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`);} -async function waitForFirstProgressAfterStart(bridge:VillaniBridgeProcess, runId:string, signal?:AbortSignal){const event=await bridge.waitForAnyEvent(['model_request_started','approval_required','tool_started','phase','run_completed','run_failed','run_aborted'],runId,60000,signal); if(!event) throw new Error('Villani stalled after run_started before any model/tool/progress event.'); return event;} -export async function runVillani(task:string, pi:any={}, ctx:any=pi):Promise{ await notify(ctx,'Villani starting...','info'); if(!task?.trim()){await notify(ctx,'/villani requires a task argument','warn'); return;} if(activeRun){await notify(ctx,'Villani is already running. Use /villani-abort first.','warn'); return;} const runId=randomUUID(); const abort=new AbortController(); const run:ActiveRun={id:runId,abort,pending:new Map()}; let postToolTimer:NodeJS.Timeout|undefined; let heartbeatTimer:NodeJS.Timeout|undefined; let heartbeatSeq=0; const clearPostToolTimer=()=>{if(postToolTimer){clearTimeout(postToolTimer); postToolTimer=undefined;}}; const clearHeartbeat=()=>{if(heartbeatTimer){clearInterval(heartbeatTimer); heartbeatTimer=undefined;}}; activeRun=run; try{let model:any; let proxy:any; const usePi=process.env.VILLANI_USE_PI_MODEL!=='false'; let config:any; let env:any; if(usePi){await notify(ctx,'Villani resolving active Pi model...','info'); model=await resolvePiModel(ctx); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'Villani starting local model proxy...','info'); proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,signal:abort.signal,timeoutMs:300000,onEvent:(e:any)=>void renderBridgeEvent(e,pi,ctx)}); run.proxy=proxy; config={provider:'openai',model:model.id??'pi-current-model',base_url:proxy.url,pi_model_proxy:true}; env=buildChildEnvForProxy(proxy.url,model);} else {model={id:process.env.VILLANI_MODEL}; config=envConfig(); env=process.env;} await notify(ctx,'Villani resolving runtime...','info'); const executable=await resolveVillaniRuntime({signal:abort.signal}); if(process.env.VILLANI_COMMAND){await notify(ctx,'Villani checking selected VILLANI_COMMAND bridge...','info'); await assertBridgePing(executable,ctx,env,abort.signal);} await notify(ctx,'Villani launching bridge...','info'); const bridge=new VillaniBridgeProcess(executable,{env,startupTimeoutMs:30000,proxyMode:usePi,explicitConfigMode:!usePi,cwd:ctx.cwd??process.cwd()}); run.bridge=bridge; await notify(ctx,'Villani waiting for bridge readiness...','info'); await bridge.waitUntilReady(undefined,abort.signal); await notify(ctx,'Villani bridge ready.','info'); setStatus(ctx,'Villani running...'); const startPostToolTimer=(eventType:string)=>{ clearPostToolTimer(); postToolTimer=setTimeout(async()=>{ try{ bridge.send({type:'ping',id:`${runId}-post-tool-ping`}); const pong=await bridge.waitForEvent('pong',3000,abort.signal); if(pong) await setStatus(ctx,`Villani is still running. Last event: ${eventType}. Bridge is alive.`); else await notify(ctx,`Villani bridge did not respond while waiting for next runner event. ${bridgeStderr(bridge)}`,'error'); }catch(err){ await notify(ctx,`Villani bridge ping failed while waiting: ${sanitizeError(err)} ${bridgeStderr(bridge)}`,'error'); } },5000); postToolTimer.unref?.(); }; const onBridgeEvent=(e:any)=>{ if(process.env.VILLANI_PI_DEBUG==='1') void notify(ctx,`Villani diagnostic: bridge event received: ${e.type}`,'info'); if(['model_request_started','model_request_completed','proxy_request_started','proxy_request_completed','command_started','command_finished','tool_started','approval_required','stream_text','run_completed','run_failed','run_aborted'].includes(e.type)) clearPostToolTimer(); if(e.type==='approval_required') void handleApproval(run,ctx,e); else void renderBridgeEvent(e,pi,ctx); if(e.type==='tool_result'||e.type==='tool_finished') startPostToolTimer(e.type); }; bridge.off('event',onBridgeEvent); bridge.on('event',onBridgeEvent); heartbeatTimer=setInterval(async()=>{ try{ const id=`${runId}-heartbeat-${++heartbeatSeq}`; bridge.send({type:'ping',id}); const pong=await bridge.waitForEvent('pong',3000,abort.signal); if(!pong) await notify(ctx,`Villani bridge heartbeat failed. ${bridgeStderr(bridge)}`,'error'); else if(process.env.VILLANI_PI_DEBUG==='1') await notify(ctx,'Villani diagnostic: bridge heartbeat pong','info'); }catch(err){ await notify(ctx,`Villani bridge heartbeat failed: ${sanitizeError(err)} ${bridgeStderr(bridge)}`,'error'); } },1000); heartbeatTimer.unref?.(); const runStartedPromise=waitForRunAcknowledgement(bridge,runId,abort.signal); const progressPromise=waitForFirstProgressAfterStart(bridge,runId,abort.signal); let finished=false; const finalPromise=bridge.waitForFinalEvent(runId,30*60*1000,abort.signal).then((event)=>{finished=true; return event;}); const exitPromise=bridge.waitForExit(0,abort.signal).then((code)=>{if(!finished&&!abort.signal.aborted){throw new Error(`Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`);} return undefined;}); bridge.send({type:'run',id:runId,task,repo:ctx.cwd??process.cwd(),mode:'runner',config}); await notify(ctx,'Villani run command sent.','info'); try{await runStartedPromise;}catch(e){abort.abort(); progressPromise.catch(()=>{}); finalPromise.catch(()=>{}); exitPromise.catch(()=>{}); bridge.kill(); await proxy?.close?.(); await bridge.waitForExit(1500).catch(()=>{}); throw e;} await Promise.race([progressPromise,finalPromise]); const final=await Promise.race([finalPromise,exitPromise]); if(final) await sendDurableVillaniMessage(pi, ctx, finalMessage(final), final); } finally { if(activeRun===run){clearPostToolTimer(); clearHeartbeat(); denyPending(run); run.abort.abort(); run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); activeRun=null;} } } -export async function proxyTest(ctx:any):Promise{ await notify(ctx,'Villani proxy test starting...','info'); const model=await resolvePiModel(ctx); await notify(ctx,`active model id: ${model?.id??'unavailable'}`,'info'); await notify(ctx,`modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,'info'); const auth=await resolveModelAuth(ctx,model); await notify(ctx,'auth resolution succeeded: true','info'); const proxy=await startModelProxyFromPiModel({model,apiKey:auth.apiKey,headers:auth.headers,timeoutMs:300000,completeFn:ctx.completeFn,completeSource:ctx.completeSource,completeImporter:ctx.completeImporter,pi:ctx.pi}); try{ await notify(ctx,`proxy URL: ${proxy.url}`,'info'); await notify(ctx,'POST /v1/chat/completions reached proxy: sending','info'); const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({model:model.id??'pi-current-model',messages:[{role:'user',content:'Say exactly READY'}],stream:false})}); await notify(ctx,'POST /v1/chat/completions reached proxy: yes','info'); await notify(ctx,`selected Pi completion helper source: ${proxy.completionSource??'unavailable'}`,'info'); const text=await r.text(); if(!r.ok){await notify(ctx,`complete returned or failed: failed (${r.status})`,'info'); throw new Error(`HTTP ${r.status}: ${text}`);} await notify(ctx,'complete returned or failed: returned','info'); await notify(ctx,`Villani proxy test succeeded: ${text.slice(0,500)}`,'info'); } finally { await proxy.close(); } } -export async function abortVillani(ctx:any={}):Promise{ if(!activeRun){await notify(ctx,'No active Villani run.','info'); return false;} const run=activeRun; await notify(ctx,'Aborting Villani...','info'); denyPending(run); run.bridge?.abort(run.id); const aborted=await run.bridge?.waitForEvent('run_aborted',1500); run.abort.abort(); if(!aborted) run.bridge?.kill(); await run.proxy?.close?.(); await run.bridge?.waitForExit(1500).catch(()=>{}); await setStatus(ctx,undefined); if(activeRun===run) activeRun=null; await notify(ctx,aborted?'Villani aborted.':'Villani abort requested.','info'); return true;} -export async function bridgePing(ctx:any):Promise{ await notify(ctx,'Villani bridge ping starting...','info'); const executable=await resolveVillaniRuntime({}); await notify(ctx,`runtime executable: ${executable}`,'info'); await assertBridgePing(executable,ctx); } -async function doctor(ctx:any){const cached=(()=>{try{return existsSync(cacheRoot())}catch{return false}})(); const lines=[`package version: 0.1.4`,`runtime version: ${VILLANI_RUNTIME_VERSION}`,`cwd: ${ctx.cwd??process.cwd()}`,`ctx.model exists: ${!!ctx.model}`,`ctx.model.id: ${ctx.model?.id??'unavailable'}`,`ctx.modelRegistry exists: ${!!ctx.modelRegistry}`,`ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders==='function'}`,`VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL!==undefined}`,`VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND!==undefined}`,`runtime tag: ${VILLANI_RUNTIME_TAG}`,`cached runtime executable exists: ${cached}`,`active run: ${activeRun?'yes':'no'}`]; if(process.env.VILLANI_COMMAND){try{const executable=await resolveVillaniRuntime({}); await assertBridgePing(executable,ctx);}catch(e){lines.push(`VILLANI_COMMAND bridge ping failed: ${bridgeDiagnosticMessage(e)}`);}} await notify(ctx,lines.join('\n'),'info');} -export default function activate(api:any){ const reg=(name:string,description:string,handler:(args:string,ctx:any)=>Promise)=>api.registerCommand(name,{description,handler}); reg('villani','Run Villani Code on a task',async(args,ctx)=>safeCommand(ctx,'Villani',()=>runVillani(args,ctx?.pi??api,ctx))); reg('villani-abort','Abort the active Villani run',async(_args,ctx)=>safeCommand(ctx,'Villani abort',async()=>{ await abortVillani(ctx); })); reg('villani-confirm-test','Test Villani approval UI',async(_args,ctx)=>safeCommand(ctx,'Villani confirmation test',async()=>{await notify(ctx,'Villani confirmation test starting...'); if(!ctx?.ui?.confirm){await notify(ctx,'Villani confirmation UI unavailable','warn'); return;} const ok=await confirm(ctx,'Villani confirmation test','Confirm Villani test?'); await notify(ctx,ok?'Villani confirmation accepted':'Villani confirmation rejected');})); reg('villani-ping','Check Villani extension loading',async(_args,ctx)=>safeCommand(ctx,'Villani ping',()=>notify(ctx,'Villani ping OK'))); reg('villani-doctor','Show Villani diagnostics',async(_args,ctx)=>safeCommand(ctx,'Villani doctor',()=>doctor(ctx))); reg('villani-proxy-test','Test Villani Pi model proxy',async(_args,ctx)=>safeCommand(ctx,'Villani proxy test',()=>proxyTest(ctx))); reg('villani-bridge-ping','Ping Villani bridge stdio',async(_args,ctx)=>safeCommand(ctx,'Villani bridge ping',()=>bridgePing(ctx))); try{api.onSessionStart?.(async(ctx:any)=>notify(ctx,'Villani extension loaded')); api.on?.('sessionStart',async(ctx:any)=>notify(ctx,'Villani extension loaded'));}catch{} } +${msg}`; + return msg; +} +async function assertBridgePing( + executable: string, + ctx: any, + env?: NodeJS.ProcessEnv, + signal?: AbortSignal, +) { + const bridge = new VillaniBridgeProcess(executable, { + env, + startupTimeoutMs: 30000, + cwd: ctx.cwd ?? process.cwd(), + }); + try { + await bridge.waitUntilReady(undefined, signal); + bridge.send({ type: "ping" }); + const pong = await bridge.waitForEvent("pong", 5000, signal); + if (!pong) + throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); + await notify(ctx, "Villani bridge ping succeeded.", "info"); + return true; + } catch (e) { + throw new Error(bridgeDiagnosticMessage(e)); + } finally { + bridge.kill(); + await bridge.waitForExit(1500).catch(() => {}); + } +} +async function waitForRunAcknowledgement( + bridge: VillaniBridgeProcess, + runId: string, + signal?: AbortSignal, +) { + const event = await bridge.waitForAnyEvent( + ["run_started", "error", "run_failed", "run_aborted"], + runId, + 10000, + signal, + ); + if (event?.type === "run_started") return event; + if (event) { + const msg = event.error || event.message || event.summary || event.type; + throw new Error(`Villani bridge rejected run command: ${msg}`); + } + throw new Error( + `Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`, + ); +} +async function waitForFirstProgressAfterStart( + bridge: VillaniBridgeProcess, + runId: string, + signal?: AbortSignal, +) { + const event = await bridge.waitForAnyEvent( + [ + "model_request_started", + "approval_required", + "tool_started", + "phase", + "run_completed", + "run_failed", + "run_aborted", + ], + runId, + 60000, + signal, + ); + if (!event) + throw new Error( + "Villani stalled after run_started before any model/tool/progress event.", + ); + return event; +} +export async function runVillani( + task: string, + pi: any = {}, + ctx: any = pi, +): Promise { + resetVillaniUiState(); + await notify(ctx, "Villani starting...", "info"); + await setStatus(ctx, "Starting Villani..."); + if (!task?.trim()) { + await notify(ctx, "/villani requires a task argument", "warn"); + return; + } + if (activeRun) { + await notify( + ctx, + "Villani is already running. Use /villani-abort first.", + "warn", + ); + return; + } + const runId = randomUUID(); + const abort = new AbortController(); + const run: ActiveRun = { id: runId, abort, pending: new Map() }; + let postToolTimer: NodeJS.Timeout | undefined; + let heartbeatTimer: NodeJS.Timeout | undefined; + let heartbeatSeq = 0; + const clearPostToolTimer = () => { + if (postToolTimer) { + clearTimeout(postToolTimer); + postToolTimer = undefined; + } + }; + const clearHeartbeat = () => { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = undefined; + } + }; + activeRun = run; + try { + let model: any; + let proxy: any; + const usePi = process.env.VILLANI_USE_PI_MODEL !== "false"; + let config: any; + let env: any; + if (usePi) { + await setStatus(ctx, "Preparing model connection..."); + model = await resolvePiModel(ctx); + const auth = await resolveModelAuth(ctx, model); + await setStatus(ctx, "Preparing model connection..."); + proxy = await startModelProxyFromPiModel({ + model, + apiKey: auth.apiKey, + headers: auth.headers, + signal: abort.signal, + timeoutMs: 300000, + onEvent: (e: any) => void renderBridgeEvent(e, pi, ctx), + }); + run.proxy = proxy; + config = { + provider: "openai", + model: model.id ?? "pi-current-model", + base_url: proxy.url, + pi_model_proxy: true, + }; + env = buildChildEnvForProxy(proxy.url, model); + } else { + model = { id: process.env.VILLANI_MODEL }; + config = envConfig(); + env = process.env; + } + await setStatus(ctx, "Starting Villani..."); + const executable = await resolveVillaniRuntime({ signal: abort.signal }); + if (process.env.VILLANI_COMMAND) { + await setStatus(ctx, "Starting Villani..."); + await assertBridgePing(executable, ctx, env, abort.signal); + } + await setStatus(ctx, "Starting Villani..."); + const bridge = new VillaniBridgeProcess(executable, { + env, + startupTimeoutMs: 30000, + proxyMode: usePi, + explicitConfigMode: !usePi, + cwd: ctx.cwd ?? process.cwd(), + }); + run.bridge = bridge; + await setStatus(ctx, "Starting Villani..."); + await bridge.waitUntilReady(undefined, abort.signal); + await setStatus(ctx, "Villani is thinking..."); + const startPostToolTimer = (eventType: string) => { + clearPostToolTimer(); + postToolTimer = setTimeout(async () => { + try { + bridge.send({ type: "ping", id: `${runId}-post-tool-ping` }); + const pong = await bridge.waitForEvent("pong", 3000, abort.signal); + if (pong) + await setStatus( + ctx, + `Villani is still running. Last event: ${eventType}. Bridge is alive.`, + ); + else + await notify( + ctx, + `Villani bridge did not respond while waiting for next runner event. ${bridgeStderr(bridge)}`, + "error", + ); + } catch (err) { + await notify( + ctx, + `Villani bridge ping failed while waiting: ${sanitizeError(err)} ${bridgeStderr(bridge)}`, + "error", + ); + } + }, 5000); + postToolTimer.unref?.(); + }; + const onBridgeEvent = (e: any) => { + if (process.env.VILLANI_PI_DEBUG === "1" && e.type !== "pong") + console.error(`[pi-villani bridge] event received: ${e.type}`); + if ( + [ + "model_request_started", + "model_request_completed", + "proxy_request_started", + "proxy_request_completed", + "command_started", + "command_finished", + "tool_started", + "approval_required", + "stream_text", + "run_completed", + "run_failed", + "run_aborted", + ].includes(e.type) + ) + clearPostToolTimer(); + if (e.type === "approval_required") void handleApproval(run, ctx, e); + else void renderBridgeEvent(e, pi, ctx); + if (e.type === "tool_result" || e.type === "tool_finished") + startPostToolTimer(e.type); + }; + bridge.off("event", onBridgeEvent); + bridge.on("event", onBridgeEvent); + heartbeatTimer = setInterval(async () => { + try { + const id = `${runId}-heartbeat-${++heartbeatSeq}`; + bridge.send({ type: "ping", id }); + const pong = await bridge.waitForEvent("pong", 3000, abort.signal); + if (!pong) + await notify( + ctx, + `Villani bridge heartbeat failed. ${bridgeStderr(bridge)}`, + "error", + ); + else if (process.env.VILLANI_PI_DEBUG === "1") + console.error("[pi-villani bridge] heartbeat pong"); + } catch (err) { + await notify( + ctx, + `Villani bridge heartbeat failed: ${sanitizeError(err)} ${bridgeStderr(bridge)}`, + "error", + ); + } + }, 15000); + heartbeatTimer.unref?.(); + const runStartedPromise = waitForRunAcknowledgement( + bridge, + runId, + abort.signal, + ); + const progressPromise = waitForFirstProgressAfterStart( + bridge, + runId, + abort.signal, + ); + let finished = false; + const finalPromise = bridge + .waitForFinalEvent(runId, 30 * 60 * 1000, abort.signal) + .then((event) => { + finished = true; + return event; + }); + const exitPromise = bridge.waitForExit(0, abort.signal).then((code) => { + if (!finished && !abort.signal.aborted) { + throw new Error( + `Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`, + ); + } + return undefined; + }); + bridge.send({ + type: "run", + id: runId, + task, + repo: ctx.cwd ?? process.cwd(), + mode: "runner", + config, + }); + await setStatus(ctx, "Villani is thinking..."); + try { + await runStartedPromise; + } catch (e) { + abort.abort(); + progressPromise.catch(() => {}); + finalPromise.catch(() => {}); + exitPromise.catch(() => {}); + bridge.kill(); + await proxy?.close?.(); + await bridge.waitForExit(1500).catch(() => {}); + throw e; + } + await Promise.race([progressPromise, finalPromise]); + const final = await Promise.race([finalPromise, exitPromise]); + if (final) { + clearPostToolTimer(); + clearHeartbeat(); + await setWidget(ctx, undefined); + await setStatus( + ctx, + final.type === "run_completed" + ? "Villani completed" + : final.type === "run_aborted" + ? "Villani aborted" + : "Villani failed", + ); + await sendDurableVillaniMessage(pi, ctx, finalMessage(final), final); + } + } finally { + if (activeRun === run) { + clearPostToolTimer(); + clearHeartbeat(); + denyPending(run); + run.abort.abort(); + run.bridge?.kill(); + await run.proxy?.close?.(); + await run.bridge?.waitForExit(1500).catch(() => {}); + await setStatus(ctx, undefined); + activeRun = null; + } + } +} +export async function proxyTest(ctx: any): Promise { + await notify(ctx, "Villani proxy test starting...", "info"); + const model = await resolvePiModel(ctx); + await notify(ctx, `active model id: ${model?.id ?? "unavailable"}`, "info"); + await notify( + ctx, + `modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders === "function"}`, + "info", + ); + const auth = await resolveModelAuth(ctx, model); + await notify(ctx, "auth resolution succeeded: true", "info"); + const proxy = await startModelProxyFromPiModel({ + model, + apiKey: auth.apiKey, + headers: auth.headers, + timeoutMs: 300000, + completeFn: ctx.completeFn, + completeSource: ctx.completeSource, + completeImporter: ctx.completeImporter, + pi: ctx.pi, + }); + try { + await notify(ctx, `proxy URL: ${proxy.url}`, "info"); + await notify( + ctx, + "POST /v1/chat/completions reached proxy: sending", + "info", + ); + const r = await fetch(proxy.url + "/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: model.id ?? "pi-current-model", + messages: [{ role: "user", content: "Say exactly READY" }], + stream: false, + }), + }); + await notify(ctx, "POST /v1/chat/completions reached proxy: yes", "info"); + await notify( + ctx, + `selected Pi completion helper source: ${proxy.completionSource ?? "unavailable"}`, + "info", + ); + const text = await r.text(); + if (!r.ok) { + await notify( + ctx, + `complete returned or failed: failed (${r.status})`, + "info", + ); + throw new Error(`HTTP ${r.status}: ${text}`); + } + await notify(ctx, "complete returned or failed: returned", "info"); + await notify( + ctx, + `Villani proxy test succeeded: ${text.slice(0, 500)}`, + "info", + ); + } finally { + await proxy.close(); + } +} +export async function abortVillani(ctx: any = {}): Promise { + if (!activeRun) { + await notify(ctx, "No active Villani run.", "info"); + return false; + } + const run = activeRun; + await notify(ctx, "Aborting Villani...", "info"); + denyPending(run); + run.bridge?.abort(run.id); + const aborted = await run.bridge?.waitForEvent("run_aborted", 1500); + run.abort.abort(); + if (!aborted) run.bridge?.kill(); + await run.proxy?.close?.(); + await run.bridge?.waitForExit(1500).catch(() => {}); + await setStatus(ctx, undefined); + if (activeRun === run) activeRun = null; + await notify( + ctx, + aborted ? "Villani aborted." : "Villani abort requested.", + "info", + ); + return true; +} +export async function bridgePing(ctx: any): Promise { + await notify(ctx, "Villani bridge ping starting...", "info"); + const executable = await resolveVillaniRuntime({}); + await notify(ctx, `runtime executable: ${executable}`, "info"); + await assertBridgePing(executable, ctx); +} +async function doctor(ctx: any) { + const cached = (() => { + try { + return existsSync(cacheRoot()); + } catch { + return false; + } + })(); + const lines = [ + `package version: 0.1.4`, + `runtime version: ${VILLANI_RUNTIME_VERSION}`, + `cwd: ${ctx.cwd ?? process.cwd()}`, + `ctx.model exists: ${!!ctx.model}`, + `ctx.model.id: ${ctx.model?.id ?? "unavailable"}`, + `ctx.modelRegistry exists: ${!!ctx.modelRegistry}`, + `ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders === "function"}`, + `VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL !== undefined}`, + `VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND !== undefined}`, + `runtime tag: ${VILLANI_RUNTIME_TAG}`, + `cached runtime executable exists: ${cached}`, + `active run: ${activeRun ? "yes" : "no"}`, + ]; + if (process.env.VILLANI_COMMAND) { + try { + const executable = await resolveVillaniRuntime({}); + await assertBridgePing(executable, ctx); + } catch (e) { + lines.push( + `VILLANI_COMMAND bridge ping failed: ${bridgeDiagnosticMessage(e)}`, + ); + } + } + await notify(ctx, lines.join("\n"), "info"); +} +export default function activate(api: any) { + try { + api.registerMessageRenderer?.("villani-result", renderVillaniResultMessage); + } catch {} + const reg = ( + name: string, + description: string, + handler: (args: string, ctx: any) => Promise, + ) => api.registerCommand(name, { description, handler }); + reg("villani", "Run Villani Code on a task", async (args, ctx) => + safeCommand(ctx, "Villani", () => runVillani(args, ctx?.pi ?? api, ctx)), + ); + reg("villani-abort", "Abort the active Villani run", async (_args, ctx) => + safeCommand(ctx, "Villani abort", async () => { + await abortVillani(ctx); + }), + ); + reg("villani-confirm-test", "Test Villani approval UI", async (_args, ctx) => + safeCommand(ctx, "Villani confirmation test", async () => { + await notify(ctx, "Villani confirmation test starting..."); + if (!ctx?.ui?.confirm) { + await notify(ctx, "Villani confirmation UI unavailable", "warn"); + return; + } + const ok = await confirm( + ctx, + "Villani confirmation test", + "Confirm Villani test?", + ); + await notify( + ctx, + ok ? "Villani confirmation accepted" : "Villani confirmation rejected", + ); + }), + ); + reg("villani-ping", "Check Villani extension loading", async (_args, ctx) => + safeCommand(ctx, "Villani ping", () => notify(ctx, "Villani ping OK")), + ); + reg("villani-doctor", "Show Villani diagnostics", async (_args, ctx) => + safeCommand(ctx, "Villani doctor", () => doctor(ctx)), + ); + reg("villani-proxy-test", "Test Villani Pi model proxy", async (_args, ctx) => + safeCommand(ctx, "Villani proxy test", () => proxyTest(ctx)), + ); + reg("villani-bridge-ping", "Ping Villani bridge stdio", async (_args, ctx) => + safeCommand(ctx, "Villani bridge ping", () => bridgePing(ctx)), + ); + try { + api.onSessionStart?.(async (ctx: any) => + notify(ctx, "Villani extension loaded"), + ); + api.on?.("sessionStart", async (ctx: any) => + notify(ctx, "Villani extension loaded"), + ); + } catch {} +} export { notify, setStatus, sendDurableVillaniMessage, confirm }; diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 7c24370a..3bb01bcb 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -1,85 +1,316 @@ -export async function notify(ctx: any, message: string, level: 'info'|'warn'|'error' = 'info'): Promise { - try { if (ctx?.ui?.notify) await ctx.ui.notify(message, level); else (level === 'error' ? console.error : console.log)(message); } catch { try { console.error(message); } catch {} } +export type VillaniUiState = { + phase: string; + lastCommand?: string; + lastCommandExitCode?: number; + lastCommandPreview?: string; + finalSummary?: string; + changedFiles?: string[]; + transcriptPath?: string; + lastEventAt?: number; +}; + +export async function notify( + ctx: any, + message: string, + level: "info" | "warn" | "error" = "info", +): Promise { + try { + if (ctx?.ui?.notify) await ctx.ui.notify(message, level); + else (level === "error" ? console.error : console.log)(message); + } catch { + try { + console.error(message); + } catch {} + } +} +export async function setStatus( + ctx: any, + message: string | undefined, +): Promise { + try { + if (ctx?.ui?.setStatus) await ctx.ui.setStatus("villani", message); + } catch { + try { + if (ctx?.ui?.setStatus) await ctx.ui.setStatus(message); + } catch {} + } } -export async function setStatus(ctx: any, message: string | undefined): Promise { try { if (ctx?.ui?.setStatus) await ctx.ui.setStatus('villani', message); } catch { try { if (ctx?.ui?.setStatus) await ctx.ui.setStatus(message); } catch {} } } -export async function setWidget(ctx: any, widget: any): Promise { try { if (ctx?.ui?.setWidget) await ctx.ui.setWidget('villani', widget); } catch {} } -export async function sendDurableVillaniMessage(pi: any, ctx: any, message: string, details?: any): Promise { +export async function setWidget(ctx: any, widget: any): Promise { + try { + if (ctx?.ui?.setWidget) await ctx.ui.setWidget("villani", widget); + } catch {} +} +export async function sendDurableVillaniMessage( + pi: any, + ctx: any, + message: string, + details?: any, +): Promise { const payload = { - customType: 'villani-result', - content: [{ type: 'text', text: message }], + customType: "villani-result", + content: [{ type: "text", text: message }], display: true, details: sanitizeDetails(details), }; - try { - if (typeof pi?.sendMessage === 'function') { + if (typeof pi?.sendMessage === "function") { await pi.sendMessage(payload); return; } - } catch { - // Fall back to notify below when Pi rejects the custom message. - } - - await notify(ctx, message, 'info'); + } catch {} + await notify(ctx, message, "info"); } -export async function confirm(ctx: any, title: string, message: string, options?: any): Promise { - try { if (ctx?.ui?.confirm) return !!(await ctx.ui.confirm(title, message, options)); } catch (e) { throw e; } +export async function confirm( + ctx: any, + title: string, + message: string, + options?: any, +): Promise { + try { + if (ctx?.ui?.confirm) + return !!(await ctx.ui.confirm(title, message, options)); + } catch (e) { + throw e; + } return false; } -export function visibleChangedFiles(files:string[]=[]){return files.filter(f=>!/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/.test(f));} -function sanitizeDetails(value:any, seen=new WeakSet()):any{ - if(value===undefined||value===null) return value; - if(typeof value==='string'||typeof value==='number'||typeof value==='boolean') return value; - if(Array.isArray(value)) return value.map(v=>sanitizeDetails(v,seen)); - if(typeof value==='object'){ - if(seen.has(value)) return '[Circular]'; +export function visibleChangedFiles(files: string[] = []) { + return files.filter( + (f) => !/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/.test(f), + ); +} +function sanitizeDetails(value: any, seen = new WeakSet()): any { + if (value === undefined || value === null) return value; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) + return value; + if (Array.isArray(value)) return value.map((v) => sanitizeDetails(v, seen)); + if (typeof value === "object") { + if (seen.has(value)) return "[Circular]"; seen.add(value); - const out:any={}; - for(const [key,entry] of Object.entries(value)){ - if(/(api[_-]?key|authorization|auth|bearer|token|secret|cookie|headers?)$/i.test(key)||/^(authorization|cookie)$/i.test(key)) continue; - out[key]=sanitizeDetails(entry,seen); + const out: any = {}; + for (const [key, entry] of Object.entries(value)) { + if ( + /(api[_-]?key|authorization|auth|bearer|token|secret|cookie|headers?)$/i.test( + key, + ) || + /^(authorization|cookie)$/i.test(key) + ) + continue; + out[key] = sanitizeDetails(entry, seen); } seen.delete(value); return out; } return String(value); } -function verificationStatus(event:any):string|undefined{const verification=event.verification_status??event.verificationStatus??event.verification; if(!verification)return undefined; if(typeof verification==='string') return `Verification: ${verification}`; if(typeof verification==='object'){const status=verification.status??verification.result??verification.outcome; return status?`Verification: ${status}`:undefined;} return `Verification: ${String(verification)}`;} -export function finalMessage(event:any){const files=visibleChangedFiles(event.changed_files||event.changedFiles||[]); const head=event.type==='run_completed'?'Villani completed':event.type==='run_aborted'?'Villani aborted':'Villani failed'; const transcript=event.transcript_path||event.transcriptPath; const verification=verificationStatus(event); return [head,event.summary||event.error||event.message,files.length?`Changed files:\n${files.map((f:string)=>`- ${f}`).join('\n')}`:'',transcript?`Transcript: ${transcript}`:'',verification].filter(Boolean).join('\n\n');} -let lastStreamAt=0; -export async function renderBridgeEvent(event:any, _pi:any, ctx:any): Promise { - const debug=process.env.VILLANI_PI_DEBUG==='1'; - const tool=String(event.tool??event.name??'unknown'); - const summary=String(event.summary??'').slice(0,500); - const command=typeof event.command==='string'?event.command.slice(0,500):''; - if(event.type==='approval_required') return; - if(event.type==='bridge_diagnostic') { if(debug) await notify(ctx, `Villani diagnostic: ${event.message||event.error||'diagnostic'}`, 'info'); return; } - if(event.type==='run_started') await notify(ctx, 'Villani run started.', 'info'); - else if(event.type==='model_request_started') await setStatus(ctx, 'Villani is thinking...'); - else if(event.type==='model_request_completed') await setStatus(ctx, 'Villani received model response'); - else if(event.type==='proxy_request_started') await setStatus(ctx, 'Villani is sending request to Pi model...'); - else if(event.type==='proxy_request_completed') await setStatus(ctx, 'Villani received model response'); - else if(event.type==='proxy_request_failed') await setStatus(ctx, 'Villani Pi model request failed'); - else if(event.type==='model_request_failed') await notify(ctx, `Villani model request failed: ${event.error||event.message||'unknown error'}`, 'error'); - else if(event.type==='tool_started') { - if(tool==='Bash') await setStatus(ctx, 'Villani preparing command...'); - await notify(ctx, `Villani tool started: ${tool}${command?` — ${command}`:''}`, 'info'); +function verificationStatus(event: any): string | undefined { + const verification = + event.verification_status ?? event.verificationStatus ?? event.verification; + if (!verification) return undefined; + if (typeof verification === "string") return `Verification: ${verification}`; + if (typeof verification === "object") { + const status = + verification.status ?? verification.result ?? verification.outcome; + return status ? `Verification: ${status}` : undefined; + } + return `Verification: ${String(verification)}`; +} +export function finalMessage(event: any) { + const files = visibleChangedFiles( + event.changed_files || event.changedFiles || [], + ); + const head = + event.type === "run_completed" + ? "Villani completed" + : event.type === "run_aborted" + ? "Villani aborted" + : "Villani failed"; + const transcript = event.transcript_path || event.transcriptPath; + const verification = verificationStatus(event); + const body = + event.summary || + event.error || + event.message || + (event.type === "run_completed" + ? "Villani completed. See transcript for details." + : "No details were provided."); + const last = + event.type === "run_failed" && event.last_status + ? `Last known status:\n${event.last_status}` + : ""; + return [ + head, + body, + files.length + ? `Changed files:\n${files.map((f: string) => `- ${f}`).join("\n")}` + : "", + last, + transcript ? `Transcript: ${transcript}` : "", + verification, + ] + .filter(Boolean) + .join("\n\n"); +} +function preview(event: any) { + const parts = []; + if (event.stderr_preview) + parts.push(`stderr: ${String(event.stderr_preview).slice(0, 500)}`); + if (event.stdout_preview) + parts.push(`stdout: ${String(event.stdout_preview).slice(0, 500)}`); + return parts.join("\n"); +} +export function reduceVillaniUiState( + state: VillaniUiState, + event: any, +): VillaniUiState { + const next = { ...state, lastEventAt: Date.now() }; + if (event.type === "run_started") next.phase = "Starting Villani..."; + else if ( + event.type === "model_request_started" || + event.type === "proxy_request_started" + ) + next.phase = "Villani is thinking..."; + else if ( + event.type === "model_request_completed" || + event.type === "proxy_request_completed" + ) + next.phase = "Checking result"; + else if (event.type === "approval_required") + next.phase = "Villani wants approval"; + else if (event.type === "command_started") { + next.phase = "Running command"; + next.lastCommand = String(event.command || "").slice(0, 500); + next.lastCommandExitCode = undefined; + next.lastCommandPreview = undefined; + } else if (event.type === "command_finished") { + next.phase = "Command finished"; + next.lastCommand = String(event.command || next.lastCommand || "").slice( + 0, + 500, + ); + next.lastCommandExitCode = event.exit_code; + next.lastCommandPreview = preview(event); + } else if (event.type === "tool_result" || event.type === "tool_finished") { + next.phase = + event.tool === "Bash" ? "Command finished" : "Applying changes"; + } else if (event.type === "workspace_changed") { + next.phase = "Applying changes"; + if (event.path) + next.changedFiles = visibleChangedFiles([ + ...(next.changedFiles || []), + String(event.path), + ]); + } else if (event.type === "verification_started") + next.phase = "Checking result"; + else if (event.type === "run_completed") { + next.phase = "Completed"; + next.finalSummary = event.summary; + next.changedFiles = visibleChangedFiles( + event.changed_files || event.changedFiles || next.changedFiles || [], + ); + next.transcriptPath = event.transcript_path || event.transcriptPath; + } else if (event.type === "run_failed") { + next.phase = "Failed"; + next.finalSummary = event.summary || event.error; + } else if (event.type === "run_aborted") next.phase = "Failed"; + else if ( + event.type === "runner_heartbeat" && + Date.now() - (state.lastEventAt || 0) > 15000 + ) + next.phase = "Villani is thinking..."; + return next; +} +export function widgetForState(state: VillaniUiState): any { + if (state.phase === "Running command") + return ["Running command:", state.lastCommand || ""]; + if (state.phase === "Command finished") + return [ + `Command finished: exit ${state.lastCommandExitCode ?? "unknown"}`, + state.lastCommandPreview || "", + ] + .filter(Boolean) + .join("\n"); + if (state.phase === "Completed" || state.phase === "Failed") return undefined; + if (state.changedFiles?.length) + return `Changed files:\n${state.changedFiles.map((f) => `- ${f}`).join("\n")}`; + return undefined; +} +export async function renderState(ctx: any, state: VillaniUiState) { + await setStatus(ctx, state.phase); + await setWidget(ctx, widgetForState(state)); +} +let state: VillaniUiState = { phase: "Starting Villani..." }; +let lastStreamAt = 0; +export async function renderBridgeEvent( + event: any, + _pi: any, + ctx: any, +): Promise { + const debug = process.env.VILLANI_PI_DEBUG === "1"; + if (event.type === "approval_required") return; + if (event.type === "bridge_diagnostic") { + if (debug && !/heartbeat pong/i.test(String(event.message || ""))) + console.error( + `[pi-villani bridge] ${event.message || event.error || "diagnostic"}`, + ); + return; } - else if(event.type==='command_started') { await setStatus(ctx, 'Villani running command'); await setWidget(ctx, ['Running command:', command]); } - else if(event.type==='command_finished') { - await setStatus(ctx, 'Command finished'); - const lines=[`Command finished: exit ${event.exit_code ?? 'unknown'}`]; - if(event.stderr_preview) lines.push(`stderr: ${String(event.stderr_preview).slice(0,500)}`); - if(event.stdout_preview) lines.push(`stdout: ${String(event.stdout_preview).slice(0,500)}`); - await setWidget(ctx, lines.join('\n')); + if (event.type === "pong") return; + if ( + event.type === "run_completed" || + event.type === "run_failed" || + event.type === "run_aborted" + ) + return; + if (event.type === "error") { + await notify( + ctx, + `Villani error: ${event.error || event.message || "unknown error"}`, + "error", + ); + return; } - else if(event.type==='tool_result') await setStatus(ctx, 'Villani produced tool result for runner'); - else if(event.type==='tool_progress') { const msg=`Villani: ${String(event.message||'tool progress').slice(0,500)}`; await setStatus(ctx, msg); await notify(ctx, msg, 'info'); } - else if(event.type==='tool_finished') { - await setStatus(ctx, 'Villani finished tool handling'); - await notify(ctx, `Villani tool finished: ${tool}${summary?` — ${summary}`:''}`, event.ok===false||event.is_error?'warn':'info'); + if ( + event.type === "model_request_failed" || + event.type === "proxy_request_failed" + ) { + await setStatus(ctx, "Failed"); + await notify( + ctx, + `Villani model request failed: ${event.error || event.message || "unknown error"}`, + "error", + ); + return; } - else if(event.type==='runner_heartbeat') await setStatus(ctx, `Villani is still running. Last runner event: ${String(event.last_event_type||'unknown')}.`); - else if(event.type==='stream_text') { const text=String(event.text||'').trim().slice(0,240); const now=Date.now(); if(text&&now-lastStreamAt>1000){lastStreamAt=now; await setStatus(ctx, `Villani: ${text}`);} } - else if(event.type==='error') await notify(ctx, `Villani error: ${event.error||event.message||'unknown error'}`, 'error'); + if (event.type === "tool_progress") { + const msg = `Villani: ${String(event.message || "tool progress").slice(0, 500)}`; + await setStatus(ctx, msg); + return; + } + if (event.type === "stream_text") { + const text = String(event.text || "") + .trim() + .slice(0, 240); + const now = Date.now(); + if (text && now - lastStreamAt > 1000) { + lastStreamAt = now; + await setStatus(ctx, `Villani: ${text}`); + } + return; + } + state = reduceVillaniUiState(state, event); + await renderState(ctx, state); +} +export function renderVillaniResultMessage(message: any): any { + const text = Array.isArray(message?.content) + ? message.content.map((p: any) => p?.text ?? "").join("\n") + : String(message?.content ?? ""); + return text; +} +export function resetVillaniUiState() { + state = { phase: "Starting Villani..." }; } diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py index c392a1ca..2cc202e9 100644 --- a/tests/integrations/test_pi_bridge.py +++ b/tests/integrations/test_pi_bridge.py @@ -234,3 +234,31 @@ def test_runner_heartbeat_is_emitted_after_idle_active_run(monkeypatch): assert ev['last_event_type']=='tool_result' assert ev['seconds_since_last_event']==16 assert ev['worker_alive'] is True + +def test_extract_summary_reads_response_content_blocks(): + from villani_code.integrations.pi_bridge import extract_summary + result={'response':{'content':[{'type':'text','text':'All 14 tests pass.'},{'type':'text','text':'Summary here.'}]}} + assert extract_summary(result)=='All 14 tests pass.\n\nSummary here.' + +def test_extract_summary_reads_execution_final_text(): + from villani_code.integrations.pi_bridge import extract_summary + assert extract_summary({'execution':{'final_text':'final markdown'}})=='final markdown' + +def test_extract_summary_reads_transcript_final_assistant_content(): + from villani_code.integrations.pi_bridge import extract_summary + result={'transcript':{'final_assistant_content':[{'type':'text','text':'final assistant'}]}} + assert extract_summary(result)=='final assistant' + +def test_extract_summary_falls_back_safely_when_absent(): + from villani_code.integrations.pi_bridge import extract_summary + assert extract_summary({'response':{'content':[{'type':'toolCall','arguments':{'x':1}}]}}) is None + +def test_run_completed_includes_summary_from_final_assistant_content(tmp_path): + class R(DummyRunner): + def run(self, task, execution_budget=None): + return {'response':{'content':[{'type':'text','text':'All tests pass.'}]},'execution':{'completed':True}} + b,out,_,_=make_bridge(R()) + b.handle(run_cmd(tmp_path)) + es=wait_for(out,lambda e:e['type']=='run_completed') + ev=[e for e in es if e['type']=='run_completed'][-1] + assert ev['summary']=='All tests pass.' diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index e2f142c6..a4780758 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -120,10 +120,46 @@ def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: elif t=='stream_text': out.append({'type':'stream_text','id':run_id,'text':_cap(event.get('text',''),240)}) return out +def _extract_text_blocks(value:Any)->str|None: + blocks=value if isinstance(value,list) else (value.get('content') if isinstance(value,dict) else None) + if not isinstance(blocks,list): return None + texts=[] + for block in blocks: + if isinstance(block,str): + text=block + elif isinstance(block,dict) and block.get('type') in {None,'text'}: + text=block.get('text') or block.get('content') or '' + else: + continue + if isinstance(text,str) and text.strip(): texts.append(text.strip()) + joined='\n\n'.join(texts).strip() + return joined or None + +def _cap_summary(text:str,n:int=6000)->str: + text=text.strip() + return text if len(text)<=n else text[:n].rstrip()+"…" + def extract_summary(r:dict[str,Any])->str|None: - for k in ('summary','response'): + for k in ('summary','final_text'): v=r.get(k) - if isinstance(v,str): return v[:4000] + if isinstance(v,str) and v.strip(): return _cap_summary(v) + ex=r.get('execution') + if isinstance(ex,dict) and isinstance(ex.get('final_text'),str) and ex.get('final_text','').strip(): return _cap_summary(ex['final_text']) + response=r.get('response') + if isinstance(response,str) and response.strip(): return _cap_summary(response) + if isinstance(response,dict): + text=_extract_text_blocks(response.get('content')) + if text: return _cap_summary(text) + transcript=r.get('transcript') + if isinstance(transcript,dict): + text=_extract_text_blocks(transcript.get('final_assistant_content')) + if text: return _cap_summary(text) + responses=transcript.get('responses') + if isinstance(responses,list) and responses: + last=responses[-1] + if isinstance(last,dict): + text=_extract_text_blocks(last.get('content')) + if text: return _cap_summary(text) return None def run_existing_runner(runner:Any, command:RunCommand)->dict[str,Any]: @@ -286,7 +322,7 @@ def appr(tool,inp): changed,pre=attributed_changed_files(repo,before,before_hash,ar.touched_files) if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return ex=result.get('execution') if isinstance(result.get('execution'),dict) else {}; reason=ex.get('terminated_reason') or ex.get('reason') - base={'id':cmd.id,'changed_files':changed,'preexisting_dirty_files':pre,'summary':extract_summary(result),'transcript_path':result.get('transcript_path'),'verification_passed':result.get('verification_passed'),'terminated_reason':reason} + base={'id':cmd.id,'changed_files':changed,'preexisting_dirty_files':pre,'summary':extract_summary(result) or 'Villani completed. See transcript for details.','transcript_path':result.get('transcript_path'),'verification_passed':result.get('verification_passed'),'terminated_reason':reason} if ex.get('completed') is False: self._queue_event({'type':'run_failed','success':False,'error':str(reason or 'Runner stopped before completion.'),**base}) else: self._queue_event({'type':'run_completed','success':True,**base}) except Exception as exc: From 837d6ddfff3d0108a509f7e27bb95e9c9ba93e68 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 09:30:48 +1000 Subject: [PATCH 26/36] Clean Pi Villani progress rendering --- integrations/pi-villani/src/extension.test.ts | 102 +++++++++- integrations/pi-villani/src/index.ts | 23 +-- integrations/pi-villani/src/render.ts | 188 ++++++++++++++---- tests/integrations/test_pi_bridge.py | 20 ++ villani_code/integrations/pi_bridge.py | 8 +- 5 files changed, 274 insertions(+), 67 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index bda0b509..53647728 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -512,7 +512,7 @@ test("/villani approval pending sets status/widget and accepted clears widget", else process.env.VILLANI_USE_PI_MODEL = oldUsePi; } assert.ok( - statuses.some((a) => a[0] === "villani" && /awaiting approval/.test(a[1])), + statuses.some((a) => a[0] === "villani" && a[1] === "Waiting for approval..."), ); assert.ok(widgets.some((a) => a[0] === "villani" && Array.isArray(a[1]))); assert.ok(widgets.some((a) => a[0] === "villani" && a[1] === undefined)); @@ -549,8 +549,7 @@ test("tool result and finished events update status without notify spam", async }, ); assert.deepEqual(notes, []); - assert.ok(statuses.includes("Command finished")); - assert.ok(statuses.some((s) => /Villani: Running command/.test(s))); + assert.deepEqual(statuses, []); }); test("/villani keeps waiting after nonzero tool_finished and renders next model event", async () => { @@ -585,7 +584,6 @@ test("/villani keeps waiting after nonzero tool_finished and renders next model else process.env.VILLANI_USE_PI_MODEL = oldUsePi; } assert.doesNotMatch(notes.join("\n"), /Villani tool finished/); - assert.ok(statuses.includes("Command finished")); assert.ok(statuses.includes("Villani is thinking...")); assert.equal(sent.length, 1); }); @@ -693,9 +691,6 @@ test("command lifecycle sets then clears widget through final result", async () if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; else process.env.VILLANI_USE_PI_MODEL = oldUsePi; } - assert.ok( - widgets.some((a) => a[0] === "villani" && String(a[1]).includes("echo hi")), - ); assert.ok(widgets.some((a) => a[0] === "villani" && a[1] === undefined)); assert.equal(sent.length, 1); }); @@ -711,3 +706,96 @@ test("villani-result renderer displays summary text", async () => { /Summary/, ); }); + +test("strict render allowlist suppresses bridge plumbing", async () => { + const { renderBridgeEvent, shouldRenderUserFacingEvent } = await import("./render.js"); + const calls: any[] = []; + const ctx = { + ui: { + notify: (...a: any[]) => calls.push(["notify", ...a]), + setStatus: (...a: any[]) => calls.push(["setStatus", ...a]), + setWidget: (...a: any[]) => calls.push(["setWidget", ...a]), + }, + }; + for (const event of [ + { type: "bridge_diagnostic", message: "event received: tool_result" }, + { type: "bridge_diagnostic", message: "bridge heartbeat pong" }, + { type: "runner_heartbeat" }, + { type: "pong" }, + { type: "tool_result", summary: "tool_result mapped" }, + ]) { + assert.equal(shouldRenderUserFacingEvent(event), false); + await renderBridgeEvent(event, {}, ctx); + } + assert.deepEqual(calls, []); +}); + +test("stream_text renders clean assistant blocks and suppresses duplicates/whitespace", async () => { + const { renderBridgeEvent, resetVillaniUiState } = await import("./render.js"); + resetVillaniUiState(); + const notes: string[] = []; + const ctx = { ui: { notify: (m: string) => notes.push(m) } }; + await renderBridgeEvent({ type: "stream_text", text: "\n\nI'll start...\n\n" }, {}, ctx); + await renderBridgeEvent({ type: "stream_text", text: "I'll start..." }, {}, ctx); + await renderBridgeEvent({ type: "stream_text", text: "\n \t\n" }, {}, ctx); + assert.deepEqual(notes, ["I'll start..."]); +}); + +test("tool and command events render readable English", async () => { + const { renderBridgeEvent, resetVillaniUiState } = await import("./render.js"); + resetVillaniUiState(); + const notes: string[] = []; + const widgets: any[] = []; + const statuses: string[] = []; + const ctx = { + ui: { + notify: (m: string) => notes.push(m), + setWidget: (_: string, w: any) => widgets.push(w), + setStatus: (_: string, s: string) => statuses.push(s), + }, + }; + await renderBridgeEvent({ type: "tool_started", tool: "Bash" }, {}, ctx); + await renderBridgeEvent({ type: "command_started", command: "python -m pytest -v" }, {}, ctx); + await renderBridgeEvent({ type: "command_finished", command: "python -m pytest -v", exit_code: 1, stderr_preview: "boom" }, {}, ctx); + assert.ok(notes.includes("Preparing command")); + assert.ok(notes.includes("Running command:\npython -m pytest -v")); + assert.ok(notes.includes("Command finished: exit 1\n\nstderr:\nboom")); + assert.doesNotMatch(notes.join("\n"), /tool_started|command_started|command_finished/); + assert.ok(statuses.includes("Running command...")); + assert.ok(widgets.some((w) => String(w).includes("python -m pytest -v"))); +}); + +test("model request clears stale command widget and final clears widget", async () => { + const { renderBridgeEvent, resetVillaniUiState } = await import("./render.js"); + resetVillaniUiState(); + const widgets: any[] = []; + const statuses: string[] = []; + const ctx = { + ui: { + notify: () => {}, + setWidget: (_: string, w: any) => widgets.push(w), + setStatus: (_: string, s: string) => statuses.push(s), + }, + }; + await renderBridgeEvent({ type: "command_started", command: "echo hi" }, {}, ctx); + await renderBridgeEvent({ type: "command_finished", command: "echo hi", exit_code: 0, stdout_preview: "hi" }, {}, ctx); + await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); + await renderBridgeEvent({ type: "run_completed", summary: "final assistant summary" }, {}, ctx); + assert.ok(widgets.some((w) => String(w).includes("Command finished: exit 0"))); + assert.ok(widgets.some((w) => w === undefined)); + assert.ok(statuses.includes("Completed")); +}); + +test("final message includes final assistant summary", async () => { + const { finalMessage } = await import("./render.js"); + const msg = finalMessage({ + type: "run_completed", + summary: "final assistant summary", + changed_files: ["src/a.ts"], + transcript_path: "/tmp/t.jsonl", + }); + assert.match(msg, /Villani completed/); + assert.match(msg, /final assistant summary/); + assert.match(msg, /Changed files:\n- src\/a\.ts/); + assert.match(msg, /Transcript: \/tmp\/t\.jsonl/); +}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index a22ba6fd..0883a50e 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -112,8 +112,8 @@ async function handleApproval(run: ActiveRun, ctx: any, e: any) { let approved = false; const message = approvalMessage(e); try { - await setStatus(ctx, `Villani awaiting approval: ${tool}`); - await setWidget(ctx, ["Villani is awaiting approval", message]); + await setStatus(ctx, "Waiting for approval..."); + await setWidget(ctx, ["Pending approval", message]); approved = await confirm(ctx, approvalTitle(e), message, { signal: run.abort.signal, }); @@ -129,13 +129,10 @@ async function handleApproval(run: ActiveRun, ctx: any, e: any) { } if (run.pending.get(requestId) !== false) return; run.pending.set(requestId, true); - await setStatus( - ctx, - approved ? "Villani approval accepted" : "Villani approval denied", - ); + await setStatus(ctx, "Villani is thinking..."); run.bridge?.respondToApproval(run.id, requestId, approved); if (process.env.VILLANI_PI_DEBUG === "1") - await notify(ctx, "Villani diagnostic: approval response sent", "info"); + console.error("[pi-villani bridge] approval response sent"); } function denyPending(run: ActiveRun) { for (const [id, done] of run.pending) { @@ -324,11 +321,7 @@ export async function runVillani( try { bridge.send({ type: "ping", id: `${runId}-post-tool-ping` }); const pong = await bridge.waitForEvent("pong", 3000, abort.signal); - if (pong) - await setStatus( - ctx, - `Villani is still running. Last event: ${eventType}. Bridge is alive.`, - ); + if (pong) await setStatus(ctx, "Villani is thinking..."); else await notify( ctx, @@ -449,10 +442,10 @@ export async function runVillani( await setStatus( ctx, final.type === "run_completed" - ? "Villani completed" + ? "Completed" : final.type === "run_aborted" - ? "Villani aborted" - : "Villani failed", + ? "Failed" + : "Failed", ); await sendDurableVillaniMessage(pi, ctx, finalMessage(final), final); } diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 3bb01bcb..0d69dfb0 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -3,12 +3,54 @@ export type VillaniUiState = { lastCommand?: string; lastCommandExitCode?: number; lastCommandPreview?: string; + lastAssistantText?: string; finalSummary?: string; changedFiles?: string[]; transcriptPath?: string; lastEventAt?: number; }; +const USER_FACING_EVENT_TYPES = new Set([ + "stream_text", + "tool_started", + "command_started", + "command_finished", + "approval_required", + "approval_resolved", + "run_completed", + "run_failed", + "run_aborted", + "model_request_started", + "model_request_completed", + "model_request_failed", + "proxy_request_started", + "proxy_request_completed", + "proxy_request_failed", +]); + +export function shouldRenderUserFacingEvent(event: any): boolean { + if (!event || typeof event !== "object") return false; + const type = String(event.type || ""); + if (!USER_FACING_EVENT_TYPES.has(type)) return false; + if ( + type === "bridge_diagnostic" || + type === "runner_heartbeat" || + type === "pong" + ) + return false; + const text = `${event.message || ""} ${event.text || ""}`; + if (/bridge heartbeat pong|event received|tool_result mapped/i.test(text)) + return false; + return true; +} + +export function cleanAssistantText(value: unknown): string | null { + if (typeof value !== "string") return null; + const text = value.replace(/\r\n/g, "\n").trim(); + if (!text) return null; + return text; +} + export async function notify( ctx: any, message: string, @@ -158,54 +200,69 @@ export function finalMessage(event: any) { function preview(event: any) { const parts = []; if (event.stderr_preview) - parts.push(`stderr: ${String(event.stderr_preview).slice(0, 500)}`); - if (event.stdout_preview) - parts.push(`stdout: ${String(event.stdout_preview).slice(0, 500)}`); + parts.push(`stderr:\n${String(event.stderr_preview).slice(0, 500)}`); + if (!event.stderr_preview && event.stdout_preview) + parts.push(`output:\n${String(event.stdout_preview).slice(0, 500)}`); return parts.join("\n"); } +function toolStartedMessage(event: any): string { + const tool = String(event.tool || event.name || "tool"); + const input = event.input && typeof event.input === "object" ? event.input : {}; + const path = event.path || input.path || input.file_path || event.file_path; + if (tool === "GitStatus") return "Checking repository status"; + if (tool === "GitDiff") return "Reading current changes"; + if (tool === "GitLog") return "Reading git history"; + if (tool === "Read") return `Reading file: ${path || "unknown"}`; + if (tool === "Write") return `Writing file: ${path || "unknown"}`; + if (tool === "Patch") return `Applying patch: ${path || "unknown"}`; + if (tool === "Bash") return "Preparing command"; + return "Villani is working..."; +} export function reduceVillaniUiState( state: VillaniUiState, event: any, ): VillaniUiState { const next = { ...state, lastEventAt: Date.now() }; if (event.type === "run_started") next.phase = "Starting Villani..."; - else if ( - event.type === "model_request_started" || - event.type === "proxy_request_started" - ) + else if (event.type === "model_request_started") { next.phase = "Villani is thinking..."; - else if ( - event.type === "model_request_completed" || - event.type === "proxy_request_completed" - ) - next.phase = "Checking result"; - else if (event.type === "approval_required") - next.phase = "Villani wants approval"; + next.lastCommand = undefined; + next.lastCommandExitCode = undefined; + next.lastCommandPreview = undefined; + } else if (event.type === "proxy_request_started") + next.phase = "Villani is thinking..."; + else if (event.type === "model_request_completed") + next.phase = "Villani is thinking..."; + else if (event.type === "proxy_request_completed") + next.phase = "Villani is thinking..."; + else if (event.type === "approval_required") next.phase = "Waiting for approval..."; + else if (event.type === "approval_resolved") next.phase = "Villani is thinking..."; + else if (event.type === "tool_started") { + const tool = String(event.tool || event.name || ""); + next.phase = + tool === "Read" || tool.startsWith("Git") + ? "Reading files..." + : tool === "Patch" || tool === "Write" + ? "Applying changes..." + : tool === "Bash" + ? "Running command..." + : "Villani is thinking..."; + } else if (event.type === "command_started") { - next.phase = "Running command"; + next.phase = "Running command..."; next.lastCommand = String(event.command || "").slice(0, 500); next.lastCommandExitCode = undefined; next.lastCommandPreview = undefined; } else if (event.type === "command_finished") { - next.phase = "Command finished"; + next.phase = "Finished command"; next.lastCommand = String(event.command || next.lastCommand || "").slice( 0, 500, ); next.lastCommandExitCode = event.exit_code; next.lastCommandPreview = preview(event); - } else if (event.type === "tool_result" || event.type === "tool_finished") { - next.phase = - event.tool === "Bash" ? "Command finished" : "Applying changes"; - } else if (event.type === "workspace_changed") { - next.phase = "Applying changes"; - if (event.path) - next.changedFiles = visibleChangedFiles([ - ...(next.changedFiles || []), - String(event.path), - ]); } else if (event.type === "verification_started") - next.phase = "Checking result"; + next.phase = "Villani is thinking..."; else if (event.type === "run_completed") { next.phase = "Completed"; next.finalSummary = event.summary; @@ -225,9 +282,9 @@ export function reduceVillaniUiState( return next; } export function widgetForState(state: VillaniUiState): any { - if (state.phase === "Running command") + if (state.phase === "Running command...") return ["Running command:", state.lastCommand || ""]; - if (state.phase === "Command finished") + if (state.phase === "Finished command") return [ `Command finished: exit ${state.lastCommandExitCode ?? "unknown"}`, state.lastCommandPreview || "", @@ -244,14 +301,35 @@ export async function renderState(ctx: any, state: VillaniUiState) { await setWidget(ctx, widgetForState(state)); } let state: VillaniUiState = { phase: "Starting Villani..." }; -let lastStreamAt = 0; export async function renderBridgeEvent( event: any, _pi: any, ctx: any, ): Promise { const debug = process.env.VILLANI_PI_DEBUG === "1"; - if (event.type === "approval_required") return; + if (!shouldRenderUserFacingEvent(event)) { + if ( + debug && + event?.type === "bridge_diagnostic" && + !/heartbeat pong/i.test(String(event.message || "")) + ) + console.error( + `[pi-villani bridge] ${event.message || event.error || "diagnostic"}`, + ); + return; + } + if (event.type === "approval_required") { + state = reduceVillaniUiState(state, event); + await setStatus(ctx, state.phase); + await setWidget(ctx, ["Pending approval", event.summary || event.message || ""]); + return; + } + if (event.type === "approval_resolved") { + state = reduceVillaniUiState(state, event); + await setStatus(ctx, state.phase); + await setWidget(ctx, undefined); + return; + } if (event.type === "bridge_diagnostic") { if (debug && !/heartbeat pong/i.test(String(event.message || ""))) console.error( @@ -264,8 +342,11 @@ export async function renderBridgeEvent( event.type === "run_completed" || event.type === "run_failed" || event.type === "run_aborted" - ) + ) { + state = reduceVillaniUiState(state, event); + await renderState(ctx, state); return; + } if (event.type === "error") { await notify( ctx, @@ -286,22 +367,41 @@ export async function renderBridgeEvent( ); return; } - if (event.type === "tool_progress") { - const msg = `Villani: ${String(event.message || "tool progress").slice(0, 500)}`; - await setStatus(ctx, msg); - return; - } if (event.type === "stream_text") { - const text = String(event.text || "") - .trim() - .slice(0, 240); - const now = Date.now(); - if (text && now - lastStreamAt > 1000) { - lastStreamAt = now; - await setStatus(ctx, `Villani: ${text}`); + const text = cleanAssistantText(event.text ?? event.content); + if (text && text !== state.lastAssistantText) { + state = { ...state, lastAssistantText: text, lastEventAt: Date.now() }; + await notify(ctx, text, "info"); } return; } + if (event.type === "tool_started") { + state = reduceVillaniUiState(state, event); + await setStatus(ctx, state.phase); + await notify(ctx, toolStartedMessage(event), "info"); + await setWidget(ctx, widgetForState(state)); + return; + } + if (event.type === "command_started") { + state = reduceVillaniUiState(state, event); + await setStatus(ctx, state.phase); + await notify(ctx, `Running command:\n${state.lastCommand || ""}`, "info"); + await setWidget(ctx, widgetForState(state)); + return; + } + if (event.type === "command_finished") { + state = reduceVillaniUiState(state, event); + const msg = [ + `Command finished: exit ${state.lastCommandExitCode ?? "unknown"}`, + state.lastCommandPreview || "", + ] + .filter(Boolean) + .join("\n\n"); + await setStatus(ctx, state.phase); + await notify(ctx, msg, "info"); + await setWidget(ctx, widgetForState(state)); + return; + } state = reduceVillaniUiState(state, event); await renderState(ctx, state); } diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py index 2cc202e9..2ce0a9b8 100644 --- a/tests/integrations/test_pi_bridge.py +++ b/tests/integrations/test_pi_bridge.py @@ -262,3 +262,23 @@ def run(self, task, execution_budget=None): es=wait_for(out,lambda e:e['type']=='run_completed') ev=[e for e in es if e['type']=='run_completed'][-1] assert ev['summary']=='All tests pass.' + +def test_map_runner_event_maps_stream_text_field(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'stream_text','text':'hello human'}) + assert ev==[{'type':'stream_text','id':'r','text':'hello human'}] + +def test_map_runner_event_extracts_assistant_content_text_blocks(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'assistant_response','content':[{'type':'text','text':'First'},{'type':'tool_use','input':{'x':1}},{'type':'text','text':'Second'}]}) + assert ev==[{'type':'stream_text','id':'r','text':'First\n\nSecond'}] + +def test_map_runner_event_does_not_emit_hidden_prompts_or_tool_json_as_stream_text(): + from villani_code.integrations.pi_bridge import map_runner_event + assert map_runner_event('r', {'type':'hidden_prompt','content':[{'type':'text','text':'secret prompt'}]})==[] + assert map_runner_event('r', {'type':'assistant_response','content':[{'type':'tool_use','input':{'command':'pytest'}}]})==[] + +def test_bridge_diagnostic_remains_diagnostic_event_for_render_suppression(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'model_request_started'}) + assert {'type':'bridge_diagnostic','id':'r','message':'model request started'} in ev diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index a4780758..66cac561 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -117,7 +117,13 @@ def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: elif t=='validation_step_started': out.append({'type':'verification_started','id':run_id,'name':event.get('name')}) elif t in {'validation_step_finished','validation_completed'}: out.append({'type':'verification_finished','id':run_id,'passed':event.get('passed')}) elif t in {'command_wandering_detected','progress_governor_redirected','governor_redirect'}: out.append({'type':'governor_redirect','id':run_id,'reason':event.get('reason')}) - elif t=='stream_text': out.append({'type':'stream_text','id':run_id,'text':_cap(event.get('text',''),240)}) + elif t in {'stream_text','assistant_text','model_text'}: + text=event.get('text') if isinstance(event.get('text'),str) else event.get('content') + extracted=text if isinstance(text,str) else _extract_text_blocks(text) + if isinstance(extracted,str) and extracted.strip(): out.append({'type':'stream_text','id':run_id,'text':_cap(extracted.strip(),240)}) + elif t in {'assistant_message','assistant_response','model_response','response_completed'}: + text=_extract_text_blocks(event.get('content')) + if text: out.append({'type':'stream_text','id':run_id,'text':_cap(text,240)}) return out def _extract_text_blocks(value:Any)->str|None: From a82da1617e203f9f9d34a6a5c77e423445bf6474 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 09:50:46 +1000 Subject: [PATCH 27/36] Fix Pi Villani result renderer --- integrations/pi-villani/package-lock.json | 44 ++++++++++++++++- integrations/pi-villani/package.json | 4 +- integrations/pi-villani/src/extension.test.ts | 49 ++++++++++++++++--- integrations/pi-villani/src/render.ts | 41 ++++++++++++++-- 4 files changed, 123 insertions(+), 15 deletions(-) diff --git a/integrations/pi-villani/package-lock.json b/integrations/pi-villani/package-lock.json index 78bf35de..72c9004d 100644 --- a/integrations/pi-villani/package-lock.json +++ b/integrations/pi-villani/package-lock.json @@ -14,6 +14,7 @@ "devDependencies": { "@earendil-works/pi-ai": "0.80.2", "@earendil-works/pi-coding-agent": "0.80.2", + "@earendil-works/pi-tui": "0.80.2", "@types/adm-zip": "0.5.7", "@types/node": "24.3.0", "typescript": "5.9.2" @@ -23,7 +24,8 @@ }, "peerDependencies": { "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*" + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" } }, "node_modules/@anthropic-ai/sdk": { @@ -2517,6 +2519,20 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.2.tgz", + "integrity": "sha512-OvOAMIbXiC9OSse17YMiXIsI9AS5XM/ZV8N/k+UzdlRpPILDQYmLElevgGW92kkXR8qHBClIdzhCjuzlBGvphA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/@google/genai": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", @@ -3002,6 +3018,19 @@ "node": ">=18" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/google-auth-library": { "version": "10.9.0", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", @@ -3112,6 +3141,19 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", diff --git a/integrations/pi-villani/package.json b/integrations/pi-villani/package.json index 063be795..12dd2430 100644 --- a/integrations/pi-villani/package.json +++ b/integrations/pi-villani/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@earendil-works/pi-ai": "0.80.2", "@earendil-works/pi-coding-agent": "0.80.2", + "@earendil-works/pi-tui": "0.80.2", "@types/adm-zip": "0.5.7", "@types/node": "24.3.0", "typescript": "5.9.2" @@ -40,6 +41,7 @@ }, "peerDependencies": { "@earendil-works/pi-ai": "*", - "@earendil-works/pi-coding-agent": "*" + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" } } diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 53647728..65927974 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -695,16 +695,49 @@ test("command lifecycle sets then clears widget through final result", async () assert.equal(sent.length, 1); }); -test("villani-result renderer displays summary text", async () => { +test("renderVillaniResultMessage returns a renderable component, not a string", async () => { const { renderVillaniResultMessage } = await import("./render.js"); - assert.match( - String( - renderVillaniResultMessage({ - content: [{ type: "text", text: "Villani completed\n\nSummary" }], - }), - ), - /Summary/, + const component = renderVillaniResultMessage({ + customType: "villani-result", + content: [{ type: "text", text: "Villani completed\n\nSummary" }], + }); + + assert.notEqual(typeof component, "string"); + assert.equal(typeof component.render, "function"); +}); + +test("renderVillaniResultMessage handles string content", async () => { + const { renderVillaniResultMessage } = await import("./render.js"); + const component = renderVillaniResultMessage({ content: "plain summary" }); + + assert.notEqual(typeof component, "string"); + assert.match(component.render(80).join("\n"), /plain summary/); +}); + +test("renderVillaniResultMessage handles text-part array content", async () => { + const { renderVillaniResultMessage } = await import("./render.js"); + const component = renderVillaniResultMessage({ + content: [{ type: "text", text: "Villani completed\n\nSummary" }], + }); + + assert.notEqual(typeof component, "string"); + assert.match(component.render(80).join("\n"), /Summary/); +}); + +test("final villani-result message rendering does not throw", async () => { + const { renderVillaniResultMessage } = await import("./render.js"); + const component = renderVillaniResultMessage( + { + customType: "villani-result", + content: [{ type: "text", text: "Villani completed\n\nSummary" }], + }, + undefined, + { fg: (_name: string, text: string) => text }, ); + + assert.doesNotThrow(() => component.render(80)); + assert.match(component.render(80).join("\n"), /\[villani-result\]/); + assert.match(component.render(80).join("\n"), /Summary/); }); test("strict render allowlist suppresses bridge plumbing", async () => { diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 0d69dfb0..7555ebb2 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -1,3 +1,5 @@ +import { Text } from "@earendil-works/pi-tui"; + export type VillaniUiState = { phase: string; lastCommand?: string; @@ -405,11 +407,40 @@ export async function renderBridgeEvent( state = reduceVillaniUiState(state, event); await renderState(ctx, state); } -export function renderVillaniResultMessage(message: any): any { - const text = Array.isArray(message?.content) - ? message.content.map((p: any) => p?.text ?? "").join("\n") - : String(message?.content ?? ""); - return text; +export function renderVillaniResultMessage( + message: any, + _options?: any, + theme?: any, +): any { + const text = extractVillaniResultText(message); + + let rendered = text; + if (theme?.fg && message?.customType) { + rendered = `${theme.fg("accent", "[villani-result]")}\n\n${text}`; + } + + return new Text(rendered, 0, 0); +} + +function extractVillaniResultText(message: any): string { + const content = message?.content; + + if (typeof content === "string") return content; + + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part.text === "string") return part.text; + return ""; + }) + .filter(Boolean) + .join("\n"); + } + + if (content && typeof content.text === "string") return content.text; + + return String(content ?? ""); } export function resetVillaniUiState() { state = { phase: "Starting Villani..." }; From 035db8e6593de31c754b2ccfd735cae7015b4056 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 09:55:50 +1000 Subject: [PATCH 28/36] Remove Pi Villani custom renderer --- integrations/pi-villani/src/extension.test.ts | 45 ------------------- integrations/pi-villani/src/index.ts | 4 -- integrations/pi-villani/src/render.ts | 37 --------------- 3 files changed, 86 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 65927974..8c7ab3e2 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -695,51 +695,6 @@ test("command lifecycle sets then clears widget through final result", async () assert.equal(sent.length, 1); }); -test("renderVillaniResultMessage returns a renderable component, not a string", async () => { - const { renderVillaniResultMessage } = await import("./render.js"); - const component = renderVillaniResultMessage({ - customType: "villani-result", - content: [{ type: "text", text: "Villani completed\n\nSummary" }], - }); - - assert.notEqual(typeof component, "string"); - assert.equal(typeof component.render, "function"); -}); - -test("renderVillaniResultMessage handles string content", async () => { - const { renderVillaniResultMessage } = await import("./render.js"); - const component = renderVillaniResultMessage({ content: "plain summary" }); - - assert.notEqual(typeof component, "string"); - assert.match(component.render(80).join("\n"), /plain summary/); -}); - -test("renderVillaniResultMessage handles text-part array content", async () => { - const { renderVillaniResultMessage } = await import("./render.js"); - const component = renderVillaniResultMessage({ - content: [{ type: "text", text: "Villani completed\n\nSummary" }], - }); - - assert.notEqual(typeof component, "string"); - assert.match(component.render(80).join("\n"), /Summary/); -}); - -test("final villani-result message rendering does not throw", async () => { - const { renderVillaniResultMessage } = await import("./render.js"); - const component = renderVillaniResultMessage( - { - customType: "villani-result", - content: [{ type: "text", text: "Villani completed\n\nSummary" }], - }, - undefined, - { fg: (_name: string, text: string) => text }, - ); - - assert.doesNotThrow(() => component.render(80)); - assert.match(component.render(80).join("\n"), /\[villani-result\]/); - assert.match(component.render(80).join("\n"), /Summary/); -}); - test("strict render allowlist suppresses bridge plumbing", async () => { const { renderBridgeEvent, shouldRenderUserFacingEvent } = await import("./render.js"); const calls: any[] = []; diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 0883a50e..6c018370 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -16,7 +16,6 @@ import { finalMessage, notify, renderBridgeEvent, - renderVillaniResultMessage, resetVillaniUiState, sendDurableVillaniMessage, setStatus, @@ -589,9 +588,6 @@ async function doctor(ctx: any) { await notify(ctx, lines.join("\n"), "info"); } export default function activate(api: any) { - try { - api.registerMessageRenderer?.("villani-result", renderVillaniResultMessage); - } catch {} const reg = ( name: string, description: string, diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 7555ebb2..5bb2d6e7 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -1,5 +1,3 @@ -import { Text } from "@earendil-works/pi-tui"; - export type VillaniUiState = { phase: string; lastCommand?: string; @@ -407,41 +405,6 @@ export async function renderBridgeEvent( state = reduceVillaniUiState(state, event); await renderState(ctx, state); } -export function renderVillaniResultMessage( - message: any, - _options?: any, - theme?: any, -): any { - const text = extractVillaniResultText(message); - - let rendered = text; - if (theme?.fg && message?.customType) { - rendered = `${theme.fg("accent", "[villani-result]")}\n\n${text}`; - } - - return new Text(rendered, 0, 0); -} - -function extractVillaniResultText(message: any): string { - const content = message?.content; - - if (typeof content === "string") return content; - - if (Array.isArray(content)) { - return content - .map((part) => { - if (typeof part === "string") return part; - if (part && typeof part.text === "string") return part.text; - return ""; - }) - .filter(Boolean) - .join("\n"); - } - - if (content && typeof content.text === "string") return content.text; - - return String(content ?? ""); -} export function resetVillaniUiState() { state = { phase: "Starting Villani..." }; } From 37995b3d87398089853f45706688daf6de44c668 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 10:20:15 +1000 Subject: [PATCH 29/36] Fix Pi Villani UI status copy --- integrations/pi-villani/src/extension.test.ts | 18 +- integrations/pi-villani/src/render.test.ts | 100 +++++++++++ integrations/pi-villani/src/render.ts | 164 +++++++++++++----- tests/integrations/test_pi_bridge.py | 39 +++++ villani_code/integrations/pi_bridge.py | 47 ++++- 5 files changed, 314 insertions(+), 54 deletions(-) create mode 100644 integrations/pi-villani/src/render.test.ts diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 8c7ab3e2..afaf822e 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -584,7 +584,7 @@ test("/villani keeps waiting after nonzero tool_finished and renders next model else process.env.VILLANI_USE_PI_MODEL = oldUsePi; } assert.doesNotMatch(notes.join("\n"), /Villani tool finished/); - assert.ok(statuses.includes("Villani is thinking...")); + assert.ok(statuses.some((s) => /^Villani|^Villani/.test(s))); assert.equal(sent.length, 1); }); @@ -600,10 +600,8 @@ test("repeated model_request_started updates status without notify spam", async }; await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); - assert.deepEqual(statuses, [ - "Villani is thinking...", - "Villani is thinking...", - ]); + assert.equal(statuses.length, 2); + assert.ok(statuses.every((s) => /^Villani/.test(s))); assert.deepEqual(notes, []); }); @@ -745,11 +743,9 @@ test("tool and command events render readable English", async () => { await renderBridgeEvent({ type: "tool_started", tool: "Bash" }, {}, ctx); await renderBridgeEvent({ type: "command_started", command: "python -m pytest -v" }, {}, ctx); await renderBridgeEvent({ type: "command_finished", command: "python -m pytest -v", exit_code: 1, stderr_preview: "boom" }, {}, ctx); - assert.ok(notes.includes("Preparing command")); - assert.ok(notes.includes("Running command:\npython -m pytest -v")); - assert.ok(notes.includes("Command finished: exit 1\n\nstderr:\nboom")); - assert.doesNotMatch(notes.join("\n"), /tool_started|command_started|command_finished/); - assert.ok(statuses.includes("Running command...")); + assert.deepEqual(notes, ["Command finished: exit 1\n\nstderr:\nboom"]); + assert.doesNotMatch(notes.join("\n"), /tool_started|command_started|command_finished|Preparing command|Running command:/); + assert.ok(statuses.some((s) => /^Villani/.test(s))); assert.ok(widgets.some((w) => String(w).includes("python -m pytest -v"))); }); @@ -771,7 +767,7 @@ test("model request clears stale command widget and final clears widget", async await renderBridgeEvent({ type: "run_completed", summary: "final assistant summary" }, {}, ctx); assert.ok(widgets.some((w) => String(w).includes("Command finished: exit 0"))); assert.ok(widgets.some((w) => w === undefined)); - assert.ok(statuses.includes("Completed")); + assert.ok(statuses.some((s) => /^Villani/.test(s))); }); test("final message includes final assistant summary", async () => { diff --git a/integrations/pi-villani/src/render.test.ts b/integrations/pi-villani/src/render.test.ts new file mode 100644 index 00000000..dcc2a640 --- /dev/null +++ b/integrations/pi-villani/src/render.test.ts @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + cleanAssistantText, + reduceVillaniUiState, + renderBridgeEvent, + resetVillaniCopyCounters, + resetVillaniUiState, + toolStartedMessage, + villaniCopy, +} from './render.js'; +import { approvalMessage, approvalTitle } from './index.js'; + +function ctxRecorder() { + const statuses: string[] = []; + const widgets: any[] = []; + const notifications: string[] = []; + return { + statuses, + widgets, + notifications, + ctx: { ui: { + setStatus: async (_key: string, value: string) => { statuses.push(value); }, + setWidget: async (_key: string, value: any) => { widgets.push(value); }, + notify: async (message: string) => { notifications.push(message); }, + } }, + }; +} + +test('toolStartedMessage for Read with path does not say unknown', () => { + const msg = toolStartedMessage({ type: 'tool_started', tool: 'Read', path: 'src/foo.py' }); + assert.match(msg, /File: src\/foo.py/); + assert.doesNotMatch(msg, /unknown/i); +}); + +test('toolStartedMessage for Read without path does not say unknown', () => { + const msg = toolStartedMessage({ type: 'tool_started', tool: 'Read' }); + assert.equal(msg, 'Villani reads file. File nervous.'); + assert.doesNotMatch(msg, /unknown/i); +}); + +test('Read event sets reading Villani copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Read', path: 'a.ts' }); + assert.equal(state.phase, 'Villani reads file. File nervous.'); +}); + +test('Write/Patch event sets writing Villani copy', () => { + resetVillaniCopyCounters(); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Write' }).phase, 'Villani makes file obey...'); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Patch' }).phase, 'Villanipatch imposed...'); +}); + +test('Bash command event sets running Villani copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Bash', input: { command: 'echo hi' } }); + assert.equal(state.phase, 'Villani gives command...'); +}); + +test('pytest command event sets testing Villani copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Bash', input: { command: 'python -m pytest -q' } }); + assert.equal(state.phase, 'Villani begins inspection...'); +}); + +test('run_completed uses complete Villani copy', () => { + resetVillaniCopyCounters(); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'run_completed' }).phase, 'Villanified. Accept result.'); +}); + +test('run_failed uses failure Villani copy', () => { + resetVillaniCopyCounters(); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'run_failed', error: 'boom' }).phase, 'Villani sees failure. Unacceptable.'); +}); + +test('villaniCopy rotates deterministically', () => { + resetVillaniCopyCounters(); + assert.equal(villaniCopy('thinking'), 'Villani is make plan...'); + assert.equal(villaniCopy('thinking'), 'Villaniplan forming...'); + assert.equal(villaniCopy('thinking'), 'Villani thinks. Nobody interrupt.'); +}); + +test('stream_text renders clean assistant text unchanged', async () => { + resetVillaniUiState(); + const rec = ctxRecorder(); + await renderBridgeEvent({ type: 'stream_text', text: "\n\nI'll start by exploring the repository structure and finding the failing tests.\n\n" }, {}, rec.ctx); + assert.deepEqual(rec.notifications, ["I'll start by exploring the repository structure and finding the failing tests."]); +}); + +test('cleanAssistantText trims without Villani prefix', () => { + assert.equal(cleanAssistantText('\n\nhello\n\n'), 'hello'); +}); + +test('approval message remains literal and readable', () => { + const request = { tool: 'Bash', summary: 'Run command', input: { command: 'python -m pytest -v' } }; + assert.equal(approvalTitle(request), 'Villani wants to run a shell command'); + const msg = approvalMessage(request); + assert.match(msg, /Command:\npython -m pytest -v/); + assert.doesNotMatch(msg, /\[object Object\]/); +}); diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 5bb2d6e7..12dc543c 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -1,8 +1,48 @@ +export type VillaniCopyCategory = + | "thinking" + | "analysis" + | "reading" + | "writing" + | "running" + | "testing" + | "debugging" + | "review" + | "failure" + | "complete"; + +const VILLANI_COPY: Record = { + thinking: ["Villani is make plan...", "Villaniplan forming...", "Villani thinks. Nobody interrupt.", "Villani has doctrine now...", "Villanithoughts classified..."], + analysis: ["Villanalysis begins...", "Villani inspect problem...", "Villani finds weak logic...", "Villanicommission investigates...", "Villani determines blame..."], + reading: ["Villani reads file. File nervous.", "Villaniread begins...", "Villani opens file for questioning...", "Villanidossier opened...", "Villani checks file loyalty..."], + writing: ["Villani makes file obey...", "Villanipatch imposed...", "Villani writes new order...", "Villanification applied...", "Villani edits without remorse..."], + running: ["Villani gives command...", "Villanicommand issued...", "Villani demands output...", "Villanirun begins...", "Villani expects obedience..."], + testing: ["Villani begins inspection...", "Villanitest begins...", "Villani demands green tests...", "Villaniverdict pending...", "Villani checks for lies..."], + debugging: ["Villani hunts weak bug...", "Villanidebug begins...", "Villani asks bug hard questions...", "Villanistack confesses...", "Villani removes instability..."], + review: ["Villanireview begins...", "Villani judges patch...", "Villanicompliance checked...", "Villani approves, reluctantly...", "Villani checks for betrayal..."], + failure: ["Villani sees failure. Unacceptable.", "Villanifailure recorded...", "Villani prepares punishment...", "Villani blames weak implementation...", "Villani demands second attempt..."], + complete: ["Villanified. Accept result.", "Villani declares victory...", "Villani restores order...", "Villanivictory logged...", "Villani permits ship..."], +}; + +const copyCounters = new Map(); + +export function villaniCopy(category: VillaniCopyCategory): string { + const options = VILLANI_COPY[category]; + const current = copyCounters.get(category) ?? 0; + copyCounters.set(category, current + 1); + return options[current % options.length]; +} + +export function resetVillaniCopyCounters(): void { + copyCounters.clear(); +} + export type VillaniUiState = { phase: string; lastCommand?: string; lastCommandExitCode?: number; lastCommandPreview?: string; + lastToolPath?: string; + lastToolKind?: "reading" | "writing"; lastAssistantText?: string; finalSummary?: string; changedFiles?: string[]; @@ -12,6 +52,7 @@ export type VillaniUiState = { const USER_FACING_EVENT_TYPES = new Set([ "stream_text", + "phase", "tool_started", "command_started", "command_finished", @@ -26,6 +67,10 @@ const USER_FACING_EVENT_TYPES = new Set([ "proxy_request_started", "proxy_request_completed", "proxy_request_failed", + "verification_started", + "verification_finished", + "validation_started", + "validation_finished", ]); export function shouldRenderUserFacingEvent(event: any): boolean { @@ -149,6 +194,44 @@ function sanitizeDetails(value: any, seen = new WeakSet()): any { } return String(value); } + +function commandCategory(command: unknown): VillaniCopyCategory { + const text = String(command || ""); + return /pytest|\btest\b|coverage|tox|unittest/i.test(text) ? "testing" : "running"; +} + +function pathFromEvent(event: any): string | undefined { + const input = event.input && typeof event.input === "object" ? event.input : {}; + const value = event.path || event.file_path || event.filepath || event.filename || event.target_file || input.path || input.file_path || input.filepath || input.filename || input.target_file; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function commandFromEvent(event: any): string | undefined { + const input = event.input && typeof event.input === "object" ? event.input : {}; + const value = event.command || input.command; + return typeof value === "string" && value.trim() ? value.slice(0, 500) : undefined; +} + +function categoryForEvent(event: any): VillaniCopyCategory | undefined { + const type = String(event.type || ""); + const phase = String(event.phase || ""); + const tool = String(event.tool || event.name || ""); + if (type === "model_request_started" || type === "proxy_request_started") return "thinking"; + if (type === "phase" && /diagnosis|planning/i.test(phase)) return "analysis"; + if (type === "tool_started") { + if (["Read", "GitStatus", "GitDiff", "GitLog"].includes(tool)) return "reading"; + if (["Write", "Patch", "Edit"].includes(tool)) return "writing"; + if (tool === "Bash") return commandCategory(commandFromEvent(event)); + } + if (type === "command_started") return commandCategory(commandFromEvent(event)); + if (type === "command_finished" && Number(event.exit_code ?? 0) !== 0) return "debugging"; + if (type === "validation_started" || type === "verification_started") return "testing"; + if (type === "validation_finished" || type === "verification_finished") return "review"; + if (type === "run_failed") return "failure"; + if (type === "run_completed") return "complete"; + return undefined; +} + function verificationStatus(event: any): string | undefined { const verification = event.verification_status ?? event.verificationStatus ?? event.verification; @@ -205,93 +288,96 @@ function preview(event: any) { parts.push(`output:\n${String(event.stdout_preview).slice(0, 500)}`); return parts.join("\n"); } -function toolStartedMessage(event: any): string { +export function toolStartedMessage(event: any): string { const tool = String(event.tool || event.name || "tool"); - const input = event.input && typeof event.input === "object" ? event.input : {}; - const path = event.path || input.path || input.file_path || event.file_path; - if (tool === "GitStatus") return "Checking repository status"; - if (tool === "GitDiff") return "Reading current changes"; - if (tool === "GitLog") return "Reading git history"; - if (tool === "Read") return `Reading file: ${path || "unknown"}`; - if (tool === "Write") return `Writing file: ${path || "unknown"}`; - if (tool === "Patch") return `Applying patch: ${path || "unknown"}`; - if (tool === "Bash") return "Preparing command"; - return "Villani is working..."; + const path = pathFromEvent(event); + if (tool === "GitStatus" || tool === "GitDiff" || tool === "GitLog" || tool === "Read") return path ? `Villani reads file. File nervous.\nFile: ${path}` : "Villani reads file. File nervous."; + if (tool === "Write" || tool === "Edit") return path ? `Villani makes file obey.\nFile: ${path}` : "Villani makes file obey."; + if (tool === "Patch") return path ? `Villanipatch imposed.\nFile: ${path}` : "Villanipatch imposed."; + if (tool === "Bash") return commandFromEvent(event) ? `Command:\n${commandFromEvent(event)}` : "Villani gives command..."; + return "Villani is make plan..."; } + export function reduceVillaniUiState( state: VillaniUiState, event: any, ): VillaniUiState { const next = { ...state, lastEventAt: Date.now() }; - if (event.type === "run_started") next.phase = "Starting Villani..."; + if (event.type === "run_started") next.phase = villaniCopy("thinking"); else if (event.type === "model_request_started") { - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); next.lastCommand = undefined; next.lastCommandExitCode = undefined; next.lastCommandPreview = undefined; } else if (event.type === "proxy_request_started") - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); else if (event.type === "model_request_completed") - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); else if (event.type === "proxy_request_completed") - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); else if (event.type === "approval_required") next.phase = "Waiting for approval..."; - else if (event.type === "approval_resolved") next.phase = "Villani is thinking..."; + else if (event.type === "approval_resolved") next.phase = villaniCopy("thinking"); else if (event.type === "tool_started") { const tool = String(event.tool || event.name || ""); - next.phase = - tool === "Read" || tool.startsWith("Git") - ? "Reading files..." - : tool === "Patch" || tool === "Write" - ? "Applying changes..." - : tool === "Bash" - ? "Running command..." - : "Villani is thinking..."; + const category = categoryForEvent(event) || "thinking"; + next.phase = villaniCopy(category); + next.lastToolPath = pathFromEvent(event); + next.lastToolKind = ["Read", "GitStatus", "GitDiff", "GitLog"].includes(tool) ? "reading" : (["Write", "Patch", "Edit"].includes(tool) ? "writing" : undefined); + if (tool === "Bash") { + next.lastCommand = commandFromEvent(event); + next.lastToolPath = undefined; + next.lastToolKind = undefined; + } } else if (event.type === "command_started") { - next.phase = "Running command..."; + next.phase = villaniCopy(commandCategory(event.command)); next.lastCommand = String(event.command || "").slice(0, 500); next.lastCommandExitCode = undefined; next.lastCommandPreview = undefined; } else if (event.type === "command_finished") { - next.phase = "Finished command"; + next.phase = villaniCopy(Number(event.exit_code ?? 0) !== 0 ? "debugging" : "review"); next.lastCommand = String(event.command || next.lastCommand || "").slice( 0, 500, ); next.lastCommandExitCode = event.exit_code; next.lastCommandPreview = preview(event); - } else if (event.type === "verification_started") - next.phase = "Villani is thinking..."; + } else if (event.type === "phase") { + next.phase = villaniCopy(categoryForEvent(event) || "thinking"); + } else if (event.type === "verification_started" || event.type === "validation_started") + next.phase = villaniCopy("testing"); + else if (event.type === "verification_finished" || event.type === "validation_finished") + next.phase = villaniCopy("review"); else if (event.type === "run_completed") { - next.phase = "Completed"; + next.phase = villaniCopy("complete"); next.finalSummary = event.summary; next.changedFiles = visibleChangedFiles( event.changed_files || event.changedFiles || next.changedFiles || [], ); next.transcriptPath = event.transcript_path || event.transcriptPath; } else if (event.type === "run_failed") { - next.phase = "Failed"; + next.phase = villaniCopy("failure"); next.finalSummary = event.summary || event.error; - } else if (event.type === "run_aborted") next.phase = "Failed"; + } else if (event.type === "run_aborted") next.phase = villaniCopy("failure"); else if ( event.type === "runner_heartbeat" && Date.now() - (state.lastEventAt || 0) > 15000 ) - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); return next; } export function widgetForState(state: VillaniUiState): any { - if (state.phase === "Running command...") - return ["Running command:", state.lastCommand || ""]; - if (state.phase === "Finished command") + if (state.lastToolPath && state.lastToolKind) + return ["File:", state.lastToolPath].join("\n"); + if (state.lastCommandExitCode !== undefined) return [ `Command finished: exit ${state.lastCommandExitCode ?? "unknown"}`, state.lastCommandPreview || "", ] .filter(Boolean) .join("\n"); - if (state.phase === "Completed" || state.phase === "Failed") return undefined; + if (state.lastCommand) + return ["Command:", state.lastCommand].join("\n"); if (state.changedFiles?.length) return `Changed files:\n${state.changedFiles.map((f) => `- ${f}`).join("\n")}`; return undefined; @@ -378,14 +464,12 @@ export async function renderBridgeEvent( if (event.type === "tool_started") { state = reduceVillaniUiState(state, event); await setStatus(ctx, state.phase); - await notify(ctx, toolStartedMessage(event), "info"); - await setWidget(ctx, widgetForState(state)); + await setWidget(ctx, widgetForState(state) || toolStartedMessage(event)); return; } if (event.type === "command_started") { state = reduceVillaniUiState(state, event); await setStatus(ctx, state.phase); - await notify(ctx, `Running command:\n${state.lastCommand || ""}`, "info"); await setWidget(ctx, widgetForState(state)); return; } diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py index 2ce0a9b8..330aa9b7 100644 --- a/tests/integrations/test_pi_bridge.py +++ b/tests/integrations/test_pi_bridge.py @@ -282,3 +282,42 @@ def test_bridge_diagnostic_remains_diagnostic_event_for_render_suppression(): from villani_code.integrations.pi_bridge import map_runner_event ev=map_runner_event('r', {'type':'model_request_started'}) assert {'type':'bridge_diagnostic','id':'r','message':'model request started'} in ev + +def test_tool_started_read_preserves_input_file_path(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Read','input':{'file_path':'src/foo.py','content':'secret'}})[0] + assert ev['type']=='tool_started' + assert ev['input']=={'file_path':'src/foo.py'} + assert ev['path']=='src/foo.py' + + +def test_tool_started_read_preserves_event_path(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Read','path':'src/bar.py','input':{}})[0] + assert ev['path']=='src/bar.py' + assert ev['input']=={} + + +def test_tool_started_bash_preserves_input_command_and_command(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Bash','input':{'command':'pytest -q','cwd':'/tmp','env':{'SECRET':'x'}}})[0] + assert ev['input']=={'command':'pytest -q','cwd':'/tmp'} + assert ev['command']=='pytest -q' + + +def test_tool_result_preserves_safe_input_path_and_command(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_result','name':'Bash','input':{'command':'python -m pytest','path':'tests/test_x.py','headers':{'authorization':'x'}},'result':{'exit_code':1,'stderr':'bad'}})[0] + assert ev['input']=={'path':'tests/test_x.py','command':'python -m pytest'} + assert ev['path']=='tests/test_x.py' + assert ev['command']=='python -m pytest' + assert ev['stderr_preview']=='bad' + + +def test_tool_started_safe_input_excludes_sensitive_and_large_fields(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Patch','input':{'path':'a.py','content':'secret file','patch':'diff --git secret','env':{'API_KEY':'x'},'headers':{'authorization':'x'},'api_key':'x','secret':'x','command':'echo ok'}})[0] + assert ev['input']=={'path':'a.py','command':'echo ok'} + dumped=json.dumps(ev).lower() + for forbidden in ['content','diff --git','env','headers','api_key','secret file']: + assert forbidden not in dumped diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index 66cac561..b6805b60 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -13,6 +13,42 @@ def _cap(v:Any,n:int=2000)->str: return str(v)[:n] def _preview(v:Any,n:int=500)->str: text='' if v is None else str(v) return text[:n] + ('...' if len(text)>n else '') + +def safe_tool_input(value): + if not isinstance(value, dict): + return {} + allowed = {} + for key in ( + "path", + "file_path", + "filepath", + "filename", + "target_file", + "command", + "cwd", + "operation", + ): + if key in value and value[key] is not None: + allowed[key] = _cap(value[key], 500) + return allowed + +def extract_tool_path(event, input): + candidates = [ + event.get("path"), + event.get("file_path"), + event.get("filepath"), + event.get("filename"), + event.get("target_file"), + input.get("path"), + input.get("file_path"), + input.get("filepath"), + input.get("filename"), + input.get("target_file"), + ] + for candidate in candidates: + if isinstance(candidate, str) and candidate.strip(): + return _cap(candidate.strip(), 500) + return None def summarize_approval_request(tool_name:str, tool_input:dict[str,Any])->tuple[str,dict[str,Any]]: if tool_name=='Write': path=str(tool_input.get('path') or tool_input.get('file_path') or '') @@ -90,15 +126,20 @@ def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: elif t=='model_request_completed': out += [{'type':'model_request_completed','id':run_id},{'type':'bridge_diagnostic','id':run_id,'message':'model response received'}] elif t=='model_request_failed': out.append({'type':'bridge_diagnostic','id':run_id,'message':t}) elif t=='tool_started': - tool=str(event.get('name') or 'tool'); inp=event.get('input') if isinstance(event.get('input'),dict) else {}; command=inp.get('command') - out += [{'type':'tool_started','id':run_id,'tool':tool, **({'command':_cap(command,500)} if command else {})},{'type':'bridge_diagnostic','id':run_id,'message':f"tool started: {tool}".strip()}] + tool=str(event.get('name') or event.get('tool') or 'tool'); inp=event.get('input') if isinstance(event.get('input'),dict) else {} + safe_input=safe_tool_input(inp); path=extract_tool_path(event,safe_input); command=safe_input.get('command') + out += [{'type':'tool_started','id':run_id,'tool':tool,'input':safe_input, **({'path':path} if path else {}), **({'command':command} if command else {})},{'type':'bridge_diagnostic','id':run_id,'message':f"tool started: {tool}".strip()}] elif t in {'tool_result','tool_finished'}: tool=str(event.get('name') or event.get('tool') or 'tool') result=event.get('result') if isinstance(event.get('result'),dict) else {} exit_code=event.get('exit_code', result.get('exit_code', result.get('returncode'))) is_error=bool(event.get('is_error')) summary=summarize_tool_result(event) - base={'type':t,'id':run_id,'tool':tool,'ok':not is_error,'is_error':is_error,'summary':summary} + inp=event.get('input') if isinstance(event.get('input'),dict) else {} + safe_input=safe_tool_input(inp); path=extract_tool_path(event,safe_input); command=safe_input.get('command') + base={'type':t,'id':run_id,'tool':tool,'ok':not is_error,'is_error':is_error,'summary':summary,'input':safe_input} + if path: base['path']=path + if command: base['command']=command if exit_code is not None: base['exit_code']=exit_code for key in ('stdout_preview','stderr_preview','truncated'): if key in event: base[key]=event[key] From 630423ae9e98bb77e2966dbfc60638fd23f01281 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 10:33:20 +1000 Subject: [PATCH 30/36] Fix Pi Villani UI status and approval copy --- integrations/pi-villani/src/extension.test.ts | 9 +- integrations/pi-villani/src/index.ts | 74 +++++++----- integrations/pi-villani/src/render.test.ts | 63 ++++++++++- integrations/pi-villani/src/render.ts | 105 ++++++++++++++---- 4 files changed, 197 insertions(+), 54 deletions(-) diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index afaf822e..43f7a4e5 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -450,7 +450,7 @@ test("approvalMessage never renders object and includes Bash command", async () assert.match(msg, /Command:\necho hi/); assert.equal( approvalTitle({ tool: "Bash" }), - "Villani wants to run a shell command", + "Villani requests command authority", ); }); @@ -512,9 +512,10 @@ test("/villani approval pending sets status/widget and accepted clears widget", else process.env.VILLANI_USE_PI_MODEL = oldUsePi; } assert.ok( - statuses.some((a) => a[0] === "villani" && a[1] === "Waiting for approval..."), + statuses.some((a) => a[0] === "villani" && /approval|authorization|authority|clearance/i.test(String(a[1]))), ); - assert.ok(widgets.some((a) => a[0] === "villani" && Array.isArray(a[1]))); + assert.ok(widgets.some((a) => a[0] === "villani" && Array.isArray(a[1]) && a[1][0] === "Villaniclearance required")); + assert.doesNotMatch(JSON.stringify(widgets), /Pending approval|Allow this operation|\[object Object\]/); assert.ok(widgets.some((a) => a[0] === "villani" && a[1] === undefined)); assert.equal(confirms[0][2].signal instanceof AbortSignal, true); }); @@ -600,7 +601,7 @@ test("repeated model_request_started updates status without notify spam", async }; await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); - assert.equal(statuses.length, 2); + assert.equal(statuses.length, 1); assert.ok(statuses.every((s) => /^Villani/.test(s))); assert.deepEqual(notes, []); }); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 6c018370..05efef5e 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -14,6 +14,7 @@ import { import { confirm, finalMessage, + nextVillaniStatus, notify, renderBridgeEvent, resetVillaniUiState, @@ -79,10 +80,23 @@ export async function safeCommand( } } export function approvalTitle(request: any) { - if (request.tool === "Bash") return "Villani wants to run a shell command"; - if (request.tool === "Write") return "Villani wants to write a file"; - if (request.tool === "Patch") return "Villani wants to apply a patch"; - return "Villani wants approval"; + const tool = String(request.tool || ""); + if (tool === "Bash") return "Villani requests command authority"; + if (tool === "Read") return "Villani requests dossier access"; + if (tool === "Write") return "Villani requests edit authority"; + if (tool === "Patch") return "Villani requests patch authority"; + if (tool === "GitStatus") return "Villani requests repository inspection"; + if (tool === "GitDiff") return "Villani requests diff inspection"; + return "Villani requests approval"; +} +function approvalLead(tool: string): string { + if (tool === "Bash") return "Villani requests command authority."; + if (tool === "Read") return "Villani requests dossier access."; + if (tool === "Write") return "Villani requests edit authority."; + if (tool === "Patch") return "Villani requests patch authority."; + if (tool === "GitStatus") return "Villani requests repository inspection."; + if (tool === "GitDiff") return "Villani requests diff inspection."; + return "Villaniclearance required."; } export function approvalMessage(request: any) { const input = @@ -91,16 +105,23 @@ export function approvalMessage(request: any) { !Array.isArray(request.input) ? request.input : {}; - const path = typeof input.path === "string" ? input.path : undefined; + + const tool = String(request.tool || "operation"); const command = typeof input.command === "string" ? input.command : undefined; - const safeSummary = - typeof request.summary === "string" && request.summary.trim() - ? request.summary - : "Approval required"; - const lines = [safeSummary, ""]; + const path = + typeof input.path === "string" ? input.path : + typeof input.file_path === "string" ? input.file_path : + typeof request.path === "string" ? request.path : + undefined; + + const lines = [approvalLead(tool), ""]; + + lines.push("Operation:", tool, ""); + if (command) lines.push("Command:", command, ""); - else if (path) lines.push("File:", path, ""); - lines.push("Allow this operation?"); + if (path) lines.push("File:", path, ""); + + lines.push("Approve this Villani action?"); return lines.join("\n"); } async function handleApproval(run: ActiveRun, ctx: any, e: any) { @@ -111,8 +132,8 @@ async function handleApproval(run: ActiveRun, ctx: any, e: any) { let approved = false; const message = approvalMessage(e); try { - await setStatus(ctx, "Waiting for approval..."); - await setWidget(ctx, ["Pending approval", message]); + await setStatus(ctx, nextVillaniStatus("approval", requestId) ?? "Villani pauses for approval..."); + await setWidget(ctx, ["Villaniclearance required", message]); approved = await confirm(ctx, approvalTitle(e), message, { signal: run.abort.signal, }); @@ -128,7 +149,7 @@ async function handleApproval(run: ActiveRun, ctx: any, e: any) { } if (run.pending.get(requestId) !== false) return; run.pending.set(requestId, true); - await setStatus(ctx, "Villani is thinking..."); + await setStatus(ctx, nextVillaniStatus("thinking", "approval-resolved") ?? "Villanithoughts classified..."); run.bridge?.respondToApproval(run.id, requestId, approved); if (process.env.VILLANI_PI_DEBUG === "1") console.error("[pi-villani bridge] approval response sent"); @@ -232,7 +253,7 @@ export async function runVillani( ): Promise { resetVillaniUiState(); await notify(ctx, "Villani starting...", "info"); - await setStatus(ctx, "Starting Villani..."); + await setStatus(ctx, nextVillaniStatus("thinking", "startup") ?? "Villaniplan forming..."); if (!task?.trim()) { await notify(ctx, "/villani requires a task argument", "warn"); return; @@ -271,10 +292,10 @@ export async function runVillani( let config: any; let env: any; if (usePi) { - await setStatus(ctx, "Preparing model connection..."); + await setStatus(ctx, nextVillaniStatus("analysis", "model-connection") ?? "Villanalysis begins..."); model = await resolvePiModel(ctx); const auth = await resolveModelAuth(ctx, model); - await setStatus(ctx, "Preparing model connection..."); + await setStatus(ctx, nextVillaniStatus("analysis", "model-connection") ?? "Villanalysis begins..."); proxy = await startModelProxyFromPiModel({ model, apiKey: auth.apiKey, @@ -296,13 +317,13 @@ export async function runVillani( config = envConfig(); env = process.env; } - await setStatus(ctx, "Starting Villani..."); + await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming..."); const executable = await resolveVillaniRuntime({ signal: abort.signal }); if (process.env.VILLANI_COMMAND) { - await setStatus(ctx, "Starting Villani..."); + await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming..."); await assertBridgePing(executable, ctx, env, abort.signal); } - await setStatus(ctx, "Starting Villani..."); + await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming..."); const bridge = new VillaniBridgeProcess(executable, { env, startupTimeoutMs: 30000, @@ -311,16 +332,19 @@ export async function runVillani( cwd: ctx.cwd ?? process.cwd(), }); run.bridge = bridge; - await setStatus(ctx, "Starting Villani..."); + await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming..."); await bridge.waitUntilReady(undefined, abort.signal); - await setStatus(ctx, "Villani is thinking..."); + await setStatus(ctx, nextVillaniStatus("thinking", "approval-resolved") ?? "Villanithoughts classified..."); const startPostToolTimer = (eventType: string) => { clearPostToolTimer(); postToolTimer = setTimeout(async () => { try { bridge.send({ type: "ping", id: `${runId}-post-tool-ping` }); const pong = await bridge.waitForEvent("pong", 3000, abort.signal); - if (pong) await setStatus(ctx, "Villani is thinking..."); + if (pong) { + const status = nextVillaniStatus("thinking", "post-tool-ping"); + if (status) await setStatus(ctx, status); + } else await notify( ctx, @@ -419,7 +443,7 @@ export async function runVillani( mode: "runner", config, }); - await setStatus(ctx, "Villani is thinking..."); + await setStatus(ctx, nextVillaniStatus("thinking", "approval-resolved") ?? "Villanithoughts classified..."); try { await runStartedPromise; } catch (e) { diff --git a/integrations/pi-villani/src/render.test.ts b/integrations/pi-villani/src/render.test.ts index dcc2a640..8eac3f10 100644 --- a/integrations/pi-villani/src/render.test.ts +++ b/integrations/pi-villani/src/render.test.ts @@ -4,6 +4,7 @@ import { cleanAssistantText, reduceVillaniUiState, renderBridgeEvent, + nextVillaniStatus, resetVillaniCopyCounters, resetVillaniUiState, toolStartedMessage, @@ -93,8 +94,68 @@ test('cleanAssistantText trims without Villani prefix', () => { test('approval message remains literal and readable', () => { const request = { tool: 'Bash', summary: 'Run command', input: { command: 'python -m pytest -v' } }; - assert.equal(approvalTitle(request), 'Villani wants to run a shell command'); + assert.equal(approvalTitle(request), 'Villani requests command authority'); const msg = approvalMessage(request); + assert.match(msg, /Villani requests command authority\./); assert.match(msg, /Command:\npython -m pytest -v/); + assert.match(msg, /Approve this Villani action\?/); + assert.doesNotMatch(msg, /Allow this operation\?/); assert.doesNotMatch(msg, /\[object Object\]/); }); + + +test('repeated model_request_started only updates status once within 12 seconds', () => { + resetVillaniCopyCounters(); + const first = reduceVillaniUiState({ phase: 'x' }, { type: 'model_request_started' }); + const second = reduceVillaniUiState(first, { type: 'model_request_started' }); + assert.equal(first.phase, 'Villani is make plan...'); + assert.equal(second.phase, first.phase); +}); + +test('proxy_request_started does not spam new thinking copy when already thinking', () => { + resetVillaniCopyCounters(); + const first = reduceVillaniUiState({ phase: 'x' }, { type: 'model_request_started' }); + const second = reduceVillaniUiState(first, { type: 'proxy_request_started' }); + assert.equal(second.phase, first.phase); +}); + +test('approval_required uses approval category copy and widget avoids pending approval', async () => { + resetVillaniCopyCounters(); + const rec = ctxRecorder(); + await renderBridgeEvent({ type: 'approval_required', request_id: 'r1', tool: 'Read', summary: 'Read' }, {}, rec.ctx); + assert.equal(rec.statuses.at(-1), 'Villani requires authorization...'); + assert.doesNotMatch(JSON.stringify(rec.widgets), /Pending approval/); + assert.match(JSON.stringify(rec.widgets), /Villaniclearance required/); +}); + +test('approval titles use Villani-style copy for Read and Bash', () => { + assert.equal(approvalTitle({ tool: 'Read' }), 'Villani requests dossier access'); + assert.equal(approvalTitle({ tool: 'Bash' }), 'Villani requests command authority'); +}); + +test('approval message for Read without path is readable', () => { + const msg = approvalMessage({ tool: 'Read', input: {} }); + assert.match(msg, /Villani requests dossier access\./); + assert.match(msg, /Operation:\nRead/); + assert.match(msg, /Approve this Villani action\?/); + assert.doesNotMatch(msg, /unknown|\[object Object\]|Allow this operation\?/i); +}); + +test('after approval_resolved status does not hardcode old thinking copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'approval' }, { type: 'approval_resolved' }); + assert.notEqual(state.phase, 'Villani is thinking...'); +}); + +test('villaniCopy approval rotates deterministically', () => { + resetVillaniCopyCounters(); + assert.equal(villaniCopy('approval'), 'Villani requires authorization...'); + assert.equal(villaniCopy('approval'), 'Villaniclearance required...'); +}); + +test('nextVillaniStatus suppresses repeated status until detail changes', () => { + resetVillaniCopyCounters(); + assert.equal(nextVillaniStatus('thinking', 'same'), 'Villani is make plan...'); + assert.equal(nextVillaniStatus('thinking', 'same'), undefined); + assert.equal(nextVillaniStatus('thinking', 'different'), 'Villaniplan forming...'); +}); diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 12dc543c..ec6a96a6 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -7,6 +7,7 @@ export type VillaniCopyCategory = | "testing" | "debugging" | "review" + | "approval" | "failure" | "complete"; @@ -19,11 +20,26 @@ const VILLANI_COPY: Record = { testing: ["Villani begins inspection...", "Villanitest begins...", "Villani demands green tests...", "Villaniverdict pending...", "Villani checks for lies..."], debugging: ["Villani hunts weak bug...", "Villanidebug begins...", "Villani asks bug hard questions...", "Villanistack confesses...", "Villani removes instability..."], review: ["Villanireview begins...", "Villani judges patch...", "Villanicompliance checked...", "Villani approves, reluctantly...", "Villani checks for betrayal..."], + approval: [ + "Villani requires authorization...", + "Villaniclearance required...", + "Villani requests permission. Briefly.", + "Villani pauses for approval...", + "Villani awaits command authority...", + "Villani demands signed order...", + "Villanipermission pending...", + "Villani asks council. Reluctantly.", + ], failure: ["Villani sees failure. Unacceptable.", "Villanifailure recorded...", "Villani prepares punishment...", "Villani blames weak implementation...", "Villani demands second attempt..."], complete: ["Villanified. Accept result.", "Villani declares victory...", "Villani restores order...", "Villanivictory logged...", "Villani permits ship..."], }; const copyCounters = new Map(); +let lastStatusCategory: VillaniCopyCategory | undefined; +let lastStatusText: string | undefined; +let lastStatusAt = 0; +let lastStatusDetailKey: string | undefined; +let lastRenderedStatus: string | undefined; export function villaniCopy(category: VillaniCopyCategory): string { const options = VILLANI_COPY[category]; @@ -34,6 +50,37 @@ export function villaniCopy(category: VillaniCopyCategory): string { export function resetVillaniCopyCounters(): void { copyCounters.clear(); + resetVillaniStatusManager(); +} + +export function resetVillaniStatusManager(): void { + lastStatusCategory = undefined; + lastStatusText = undefined; + lastStatusAt = 0; + lastStatusDetailKey = undefined; + lastRenderedStatus = undefined; +} + +function shouldUpdateStatus(category: VillaniCopyCategory, detailKey?: string): boolean { + const now = Date.now(); + if (category !== lastStatusCategory) return true; + if (detailKey && detailKey !== lastStatusDetailKey) return true; + if (now - lastStatusAt > 12000) return true; + return false; +} + +export function nextVillaniStatus(category: VillaniCopyCategory, detailKey?: string): string | undefined { + if (!shouldUpdateStatus(category, detailKey)) return undefined; + const text = villaniCopy(category); + lastStatusCategory = category; + lastStatusText = text; + lastStatusAt = Date.now(); + lastStatusDetailKey = detailKey; + return text; +} + +export function currentStatusFallback(category: VillaniCopyCategory): string { + return lastStatusCategory === category && lastStatusText ? lastStatusText : villaniCopy(category); } export type VillaniUiState = { @@ -217,6 +264,7 @@ function categoryForEvent(event: any): VillaniCopyCategory | undefined { const phase = String(event.phase || ""); const tool = String(event.tool || event.name || ""); if (type === "model_request_started" || type === "proxy_request_started") return "thinking"; + if (type === "approval_required") return "approval"; if (type === "phase" && /diagnosis|planning/i.test(phase)) return "analysis"; if (type === "tool_started") { if (["Read", "GitStatus", "GitDiff", "GitLog"].includes(tool)) return "reading"; @@ -303,24 +351,28 @@ export function reduceVillaniUiState( event: any, ): VillaniUiState { const next = { ...state, lastEventAt: Date.now() }; - if (event.type === "run_started") next.phase = villaniCopy("thinking"); + const applyStatus = (category: VillaniCopyCategory, detailKey?: string) => { + const status = nextVillaniStatus(category, detailKey); + if (status) next.phase = status; + }; + if (event.type === "run_started") applyStatus("thinking", "run-started"); else if (event.type === "model_request_started") { - next.phase = villaniCopy("thinking"); + applyStatus("thinking", "model-request"); next.lastCommand = undefined; next.lastCommandExitCode = undefined; next.lastCommandPreview = undefined; } else if (event.type === "proxy_request_started") - next.phase = villaniCopy("thinking"); - else if (event.type === "model_request_completed") - next.phase = villaniCopy("thinking"); - else if (event.type === "proxy_request_completed") - next.phase = villaniCopy("thinking"); - else if (event.type === "approval_required") next.phase = "Waiting for approval..."; - else if (event.type === "approval_resolved") next.phase = villaniCopy("thinking"); + applyStatus("thinking", "model-request"); + else if (event.type === "model_request_completed") { + if (!state.phase) applyStatus("review", "model-completed"); + } else if (event.type === "proxy_request_completed") { + // Keep existing status; proxy completion is heartbeat-like UI noise. + } else if (event.type === "approval_required") applyStatus("approval", event.request_id || event.requestId); + else if (event.type === "approval_resolved") applyStatus("thinking", "approval-resolved"); else if (event.type === "tool_started") { const tool = String(event.tool || event.name || ""); const category = categoryForEvent(event) || "thinking"; - next.phase = villaniCopy(category); + applyStatus(category, `${tool}:${pathFromEvent(event) || commandFromEvent(event) || ""}`); next.lastToolPath = pathFromEvent(event); next.lastToolKind = ["Read", "GitStatus", "GitDiff", "GitLog"].includes(tool) ? "reading" : (["Write", "Patch", "Edit"].includes(tool) ? "writing" : undefined); if (tool === "Bash") { @@ -330,12 +382,13 @@ export function reduceVillaniUiState( } } else if (event.type === "command_started") { - next.phase = villaniCopy(commandCategory(event.command)); - next.lastCommand = String(event.command || "").slice(0, 500); + const command = String(event.command || "").slice(0, 500); + applyStatus(commandCategory(event.command), command); + next.lastCommand = command; next.lastCommandExitCode = undefined; next.lastCommandPreview = undefined; } else if (event.type === "command_finished") { - next.phase = villaniCopy(Number(event.exit_code ?? 0) !== 0 ? "debugging" : "review"); + applyStatus(Number(event.exit_code ?? 0) !== 0 ? "debugging" : "review", String(event.command || next.lastCommand || "")); next.lastCommand = String(event.command || next.lastCommand || "").slice( 0, 500, @@ -343,27 +396,27 @@ export function reduceVillaniUiState( next.lastCommandExitCode = event.exit_code; next.lastCommandPreview = preview(event); } else if (event.type === "phase") { - next.phase = villaniCopy(categoryForEvent(event) || "thinking"); + applyStatus(categoryForEvent(event) || "thinking", String(event.phase || "")); } else if (event.type === "verification_started" || event.type === "validation_started") - next.phase = villaniCopy("testing"); + applyStatus("testing", event.type); else if (event.type === "verification_finished" || event.type === "validation_finished") - next.phase = villaniCopy("review"); + applyStatus("review", event.type); else if (event.type === "run_completed") { - next.phase = villaniCopy("complete"); + applyStatus("complete", "run-completed"); next.finalSummary = event.summary; next.changedFiles = visibleChangedFiles( event.changed_files || event.changedFiles || next.changedFiles || [], ); next.transcriptPath = event.transcript_path || event.transcriptPath; } else if (event.type === "run_failed") { - next.phase = villaniCopy("failure"); + applyStatus("failure", "run-failed"); next.finalSummary = event.summary || event.error; - } else if (event.type === "run_aborted") next.phase = villaniCopy("failure"); + } else if (event.type === "run_aborted") applyStatus("failure", "run-aborted"); else if ( event.type === "runner_heartbeat" && Date.now() - (state.lastEventAt || 0) > 15000 ) - next.phase = villaniCopy("thinking"); + applyStatus("thinking", "heartbeat"); return next; } export function widgetForState(state: VillaniUiState): any { @@ -383,10 +436,13 @@ export function widgetForState(state: VillaniUiState): any { return undefined; } export async function renderState(ctx: any, state: VillaniUiState) { - await setStatus(ctx, state.phase); + if (state.phase && state.phase !== lastRenderedStatus) { + await setStatus(ctx, state.phase); + lastRenderedStatus = state.phase; + } await setWidget(ctx, widgetForState(state)); } -let state: VillaniUiState = { phase: "Starting Villani..." }; +let state: VillaniUiState = { phase: "" }; export async function renderBridgeEvent( event: any, _pi: any, @@ -407,7 +463,7 @@ export async function renderBridgeEvent( if (event.type === "approval_required") { state = reduceVillaniUiState(state, event); await setStatus(ctx, state.phase); - await setWidget(ctx, ["Pending approval", event.summary || event.message || ""]); + await setWidget(ctx, ["Villaniclearance required", event.summary || event.message || ""]); return; } if (event.type === "approval_resolved") { @@ -490,5 +546,6 @@ export async function renderBridgeEvent( await renderState(ctx, state); } export function resetVillaniUiState() { - state = { phase: "Starting Villani..." }; + state = { phase: "" }; + resetVillaniStatusManager(); } From f968fe6e3baa20b6c28377d124999fc7e9d7be3c Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Mon, 29 Jun 2026 11:31:35 +1000 Subject: [PATCH 31/36] Updated Villani-isms --- integrations/pi-villani/src/render.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index ec6a96a6..2c662915 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -12,14 +12,14 @@ export type VillaniCopyCategory = | "complete"; const VILLANI_COPY: Record = { - thinking: ["Villani is make plan...", "Villaniplan forming...", "Villani thinks. Nobody interrupt.", "Villani has doctrine now...", "Villanithoughts classified..."], - analysis: ["Villanalysis begins...", "Villani inspect problem...", "Villani finds weak logic...", "Villanicommission investigates...", "Villani determines blame..."], - reading: ["Villani reads file. File nervous.", "Villaniread begins...", "Villani opens file for questioning...", "Villanidossier opened...", "Villani checks file loyalty..."], - writing: ["Villani makes file obey...", "Villanipatch imposed...", "Villani writes new order...", "Villanification applied...", "Villani edits without remorse..."], - running: ["Villani gives command...", "Villanicommand issued...", "Villani demands output...", "Villanirun begins...", "Villani expects obedience..."], - testing: ["Villani begins inspection...", "Villanitest begins...", "Villani demands green tests...", "Villaniverdict pending...", "Villani checks for lies..."], - debugging: ["Villani hunts weak bug...", "Villanidebug begins...", "Villani asks bug hard questions...", "Villanistack confesses...", "Villani removes instability..."], - review: ["Villanireview begins...", "Villani judges patch...", "Villanicompliance checked...", "Villani approves, reluctantly...", "Villani checks for betrayal..."], + thinking: ["Villani is make plan...", "Villaniplan forming...", "Villani thinks. Nobody interrupt.", "Villani has doctrine now...", "Villanithoughts classified...","Villani think. Room become unsafe.","Plan is weak. Villani improve.","Villani make brain do labour...","Villani is decide what reality means...","Villani has thought. Other thoughts dismissed...","Villani is make superior plan..."], + analysis: ["Villanalysis begins...", "Villani inspect problem...", "Villani finds weak logic...", "Villanicommission investigates...", "Villani determines blame...","Villani is inspect problem...","Analysis now under state control...","Villani is find where truth escaped...","Villani is diagnose amateur logic...","Villani discovers obvious failure. Embarrassing."], + reading: ["Villani reads file. File nervous.", "Villaniread begins...", "Villani open file for questioning...", "Villanidossier opened...", "Villani check file loyalty...","Villani is read file...","File contents now under suspicion...","File summoned for questioning...","Villani is see what file hide...","File classified. Villani has access...","Villaninterrogation begins...","Villani reads. File should be grateful.","Villani study file. File not impressive."], + writing: ["Villani makes file obey...", "Villanipatch imposed...", "Villani writes new order...", "Villanification applied...", "Villani edits without remorse...","Villani patches. Code say thank you.","Villani is make file obey...","File corrected by authority...","Villani is fixing file attitude..."], + running: ["Villani gives command...", "Villanicommand issued...", "Villani demands output...", "Villanirun begins...", "Villani expects obedience...","Machine must now explain itself...","Villani is launch operation..."], + testing: ["Villani begins inspection...", "Villanitest begins...", "Villani demands green tests...", "Villaniverdict pending...", "Villani checks for lies...","Villanitest begins...","Villani is ask why fail...","Villaninspection. Code scared...","Code must prove usefulness...","Villani hunt fake success..."], + debugging: ["Villani hunts weak bug...", "Villanidebug begins...", "Villani asks bug hard questions...", "Villanistack confesses...", "Villani removes instability...","Villani is hunt bug...","Villani correct disorder...","Villani is make error confess...","Bug hide. Villani interrogate..."], + review: ["Villanireview begins...", "Villani judges patch...", "Villanicompliance checked...", "Villani approves, reluctantly...", "Villani check for betrayal...","Villani judge patch...","Villanicompliance checked...","Patch must justify existence..."], approval: [ "Villani requires authorization...", "Villaniclearance required...", @@ -29,9 +29,11 @@ const VILLANI_COPY: Record = { "Villani demands signed order...", "Villanipermission pending...", "Villani asks council. Reluctantly.", + "Villani is need approval...", + "Villani want signed order...", ], - failure: ["Villani sees failure. Unacceptable.", "Villanifailure recorded...", "Villani prepares punishment...", "Villani blames weak implementation...", "Villani demands second attempt..."], - complete: ["Villanified. Accept result.", "Villani declares victory...", "Villani restores order...", "Villanivictory logged...", "Villani permits ship..."], + failure: ["Villani sees failure. Unacceptable.", "Villanifailure recorded...", "Villani prepare punishment...", "Villani blames weak implementation...", "Villani demand second attempt...","Result is shameful. Villani informed...","Failure reported to Villani ministry...","Villani is record disgrace...","Villani blames weak architecture. Correctly."], + complete: ["Villanified. Accept result.", "Villani declares victory...", "Villani restores order...", "Villanivictory logged...", "Villani permits ship...","Villani declare victory. Obviously.","Villani accept outcome as adequate tribute...","Villani create history. New world order established."], }; const copyCounters = new Map(); From b6a39dee7c11864d5aae70afb80257b78aa050e8 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 11:41:26 +1000 Subject: [PATCH 32/36] Fix Pi approval UI duplication --- integrations/pi-villani/README.md | 6 +- integrations/pi-villani/src/extension.test.ts | 76 ++++--------------- integrations/pi-villani/src/index.ts | 65 +++------------- integrations/pi-villani/src/render.test.ts | 21 +++-- integrations/pi-villani/src/render.ts | 4 +- 5 files changed, 47 insertions(+), 125 deletions(-) diff --git a/integrations/pi-villani/README.md b/integrations/pi-villani/README.md index eff11073..57794a51 100644 --- a/integrations/pi-villani/README.md +++ b/integrations/pi-villani/README.md @@ -3,10 +3,10 @@ Install with: ```bash pi install npm:@mmprotest/pi-villani ``` -Provides `/villani `, `/villani-abort`, and `/villani-confirm-test`. +Provides `/villani `. Runtime version: `v0.1.3`. -## Abort semantics +## Usage -When `/villani-abort` is invoked, the extension first aborts the Pi model proxy signal and sends a bridge `{ "type": "abort" }` command for the active run. Pending Villani approval requests are denied by the bridge immediately so approved writes do not continue after abort. The extension waits a short grace period for `run_aborted`; if it is not observed, the bridge subprocess is killed. Active child commands launched by the runtime may continue until that subprocess is killed, so abort guarantees no orphan bridge process rather than instant termination of every child process. +Run Villani with `/villani `. Active runs are still cleaned up automatically on process/session cancellation or error cleanup. diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 43f7a4e5..e9a474e3 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -11,72 +11,19 @@ function api() { }, }; } -test("registers Villani Pi commands", () => { +test("registers only the public /villani command", () => { const a = api(); activate(a); - assert.deepEqual(Object.keys(a.commands), [ - "villani", + assert.deepEqual(Object.keys(a.commands), ["villani"]); + for (const removed of [ "villani-abort", "villani-confirm-test", "villani-ping", "villani-doctor", "villani-proxy-test", "villani-bridge-ping", - ]); -}); -test("/villani-ping only notifies OK", async () => { - const a = api(); - activate(a); - const notes: any[] = []; - await a.commands["villani-ping"].handler("", { - ui: { notify: (m: string) => notes.push(m) }, - }); - assert.deepEqual(notes, ["Villani ping OK"]); -}); -test("/villani-confirm-test accepted, rejected, and missing UI are visible", async () => { - for (const value of [true, false]) { - const a = api(); - activate(a); - const notes: string[] = []; - await a.commands["villani-confirm-test"].handler("", { - ui: { notify: (m: string) => notes.push(m), confirm: async () => value }, - }); - assert.equal(notes[0], "Villani confirmation test starting..."); - assert.equal( - notes.at(-1), - value ? "Villani confirmation accepted" : "Villani confirmation rejected", - ); - } - const a = api(); - activate(a); - const notes: string[] = []; - await a.commands["villani-confirm-test"].handler("", { - ui: { notify: (m: string) => notes.push(m) }, - }); - assert.deepEqual(notes, [ - "Villani confirmation test starting...", - "Villani confirmation UI unavailable", - ]); -}); -test("/villani-doctor prints diagnostics without secrets", async () => { - const a = api(); - activate(a); - const notes: string[] = []; - process.env.VILLANI_API_KEY = "secret"; - try { - await a.commands["villani-doctor"].handler("", { - cwd: "/tmp/repo", - model: { id: "m" }, - modelRegistry: { getApiKeyAndHeaders() {} }, - ui: { notify: (m: string) => notes.push(m) }, - }); - const msg = notes.join("\n"); - assert.match(msg, /package version: 0.1.4/); - assert.match(msg, /runtime version: 0.1.3/); - assert.match(msg, /ctx.model exists: true/); - assert.doesNotMatch(msg, /secret|VILLANI_API_KEY/); - } finally { - delete process.env.VILLANI_API_KEY; + ]) { + assert.equal(a.commands[removed], undefined); } }); import { @@ -447,7 +394,7 @@ test("approvalMessage never renders object and includes Bash command", async () input: { command: "echo hi" }, }); assert.doesNotMatch(msg, /\[object Object\]/); - assert.match(msg, /Command:\necho hi/); + assert.match(msg, /Command: echo hi/); assert.equal( approvalTitle({ tool: "Bash" }), "Villani requests command authority", @@ -475,7 +422,7 @@ test("confirm passes signal option to ctx.ui.confirm", async () => { assert.equal(args[2].signal, signal); }); -test("/villani approval pending sets status/widget and accepted clears widget", async () => { +test("/villani approval pending clears widget, sets status, confirms, and accepted clears widget", async () => { const old = process.env.VILLANI_COMMAND; const oldUsePi = process.env.VILLANI_USE_PI_MODEL; const p = bridgeScript( @@ -514,8 +461,13 @@ test("/villani approval pending sets status/widget and accepted clears widget", assert.ok( statuses.some((a) => a[0] === "villani" && /approval|authorization|authority|clearance/i.test(String(a[1]))), ); - assert.ok(widgets.some((a) => a[0] === "villani" && Array.isArray(a[1]) && a[1][0] === "Villaniclearance required")); - assert.doesNotMatch(JSON.stringify(widgets), /Pending approval|Allow this operation|\[object Object\]/); + assert.ok(widgets.length > 0); + assert.equal(widgets[0][0], "villani"); + assert.equal(widgets[0][1], undefined); + assert.equal(confirms[0][0], "Villani requests command authority"); + assert.match(confirms[0][1], /Command: echo hi/); + assert.match(confirms[0][1], /Approve this Villani action\?/); + assert.doesNotMatch(JSON.stringify(widgets), /Villani requests command authority|Command: echo hi|Pending approval|Allow this operation|\[object Object\]/); assert.ok(widgets.some((a) => a[0] === "villani" && a[1] === undefined)); assert.equal(confirms[0][2].signal instanceof AbortSignal, true); }); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index 05efef5e..f1acaf6e 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -89,15 +89,6 @@ export function approvalTitle(request: any) { if (tool === "GitDiff") return "Villani requests diff inspection"; return "Villani requests approval"; } -function approvalLead(tool: string): string { - if (tool === "Bash") return "Villani requests command authority."; - if (tool === "Read") return "Villani requests dossier access."; - if (tool === "Write") return "Villani requests edit authority."; - if (tool === "Patch") return "Villani requests patch authority."; - if (tool === "GitStatus") return "Villani requests repository inspection."; - if (tool === "GitDiff") return "Villani requests diff inspection."; - return "Villaniclearance required."; -} export function approvalMessage(request: any) { const input = request.input && @@ -114,12 +105,15 @@ export function approvalMessage(request: any) { typeof request.path === "string" ? request.path : undefined; - const lines = [approvalLead(tool), ""]; - - lines.push("Operation:", tool, ""); + const lines: string[] = []; - if (command) lines.push("Command:", command, ""); - if (path) lines.push("File:", path, ""); + if (tool === "Bash" && command) lines.push(`Command: ${command}`, ""); + else if ((tool === "Write" || tool === "Patch") && path) lines.push(`File: ${path}`, ""); + else { + lines.push("Operation:", tool, ""); + if (command) lines.push(`Command: ${command}`, ""); + if (path) lines.push(`File: ${path}`, ""); + } lines.push("Approve this Villani action?"); return lines.join("\n"); @@ -132,8 +126,8 @@ async function handleApproval(run: ActiveRun, ctx: any, e: any) { let approved = false; const message = approvalMessage(e); try { - await setStatus(ctx, nextVillaniStatus("approval", requestId) ?? "Villani pauses for approval..."); - await setWidget(ctx, ["Villaniclearance required", message]); + await setWidget(ctx, undefined); + await setStatus(ctx, nextVillaniStatus("approval", requestId) ?? "Villaniclearance required..."); approved = await confirm(ctx, approvalTitle(e), message, { signal: run.abort.signal, }); @@ -149,7 +143,7 @@ async function handleApproval(run: ActiveRun, ctx: any, e: any) { } if (run.pending.get(requestId) !== false) return; run.pending.set(requestId, true); - await setStatus(ctx, nextVillaniStatus("thinking", "approval-resolved") ?? "Villanithoughts classified..."); + await setStatus(ctx, approved ? "Villani resumes operation..." : "Villani records denial..."); run.bridge?.respondToApproval(run.id, requestId, approved); if (process.env.VILLANI_PI_DEBUG === "1") console.error("[pi-villani bridge] approval response sent"); @@ -261,7 +255,7 @@ export async function runVillani( if (activeRun) { await notify( ctx, - "Villani is already running. Use /villani-abort first.", + "Villani is already running. Wait for the active run to finish or cancel the session.", "warn", ); return; @@ -620,41 +614,6 @@ export default function activate(api: any) { reg("villani", "Run Villani Code on a task", async (args, ctx) => safeCommand(ctx, "Villani", () => runVillani(args, ctx?.pi ?? api, ctx)), ); - reg("villani-abort", "Abort the active Villani run", async (_args, ctx) => - safeCommand(ctx, "Villani abort", async () => { - await abortVillani(ctx); - }), - ); - reg("villani-confirm-test", "Test Villani approval UI", async (_args, ctx) => - safeCommand(ctx, "Villani confirmation test", async () => { - await notify(ctx, "Villani confirmation test starting..."); - if (!ctx?.ui?.confirm) { - await notify(ctx, "Villani confirmation UI unavailable", "warn"); - return; - } - const ok = await confirm( - ctx, - "Villani confirmation test", - "Confirm Villani test?", - ); - await notify( - ctx, - ok ? "Villani confirmation accepted" : "Villani confirmation rejected", - ); - }), - ); - reg("villani-ping", "Check Villani extension loading", async (_args, ctx) => - safeCommand(ctx, "Villani ping", () => notify(ctx, "Villani ping OK")), - ); - reg("villani-doctor", "Show Villani diagnostics", async (_args, ctx) => - safeCommand(ctx, "Villani doctor", () => doctor(ctx)), - ); - reg("villani-proxy-test", "Test Villani Pi model proxy", async (_args, ctx) => - safeCommand(ctx, "Villani proxy test", () => proxyTest(ctx)), - ); - reg("villani-bridge-ping", "Ping Villani bridge stdio", async (_args, ctx) => - safeCommand(ctx, "Villani bridge ping", () => bridgePing(ctx)), - ); try { api.onSessionStart?.(async (ctx: any) => notify(ctx, "Villani extension loaded"), diff --git a/integrations/pi-villani/src/render.test.ts b/integrations/pi-villani/src/render.test.ts index 8eac3f10..f25c1030 100644 --- a/integrations/pi-villani/src/render.test.ts +++ b/integrations/pi-villani/src/render.test.ts @@ -96,8 +96,8 @@ test('approval message remains literal and readable', () => { const request = { tool: 'Bash', summary: 'Run command', input: { command: 'python -m pytest -v' } }; assert.equal(approvalTitle(request), 'Villani requests command authority'); const msg = approvalMessage(request); - assert.match(msg, /Villani requests command authority\./); - assert.match(msg, /Command:\npython -m pytest -v/); + assert.doesNotMatch(msg, /Villani requests command authority/); + assert.match(msg, /Command: python -m pytest -v/); assert.match(msg, /Approve this Villani action\?/); assert.doesNotMatch(msg, /Allow this operation\?/); assert.doesNotMatch(msg, /\[object Object\]/); @@ -124,8 +124,8 @@ test('approval_required uses approval category copy and widget avoids pending ap const rec = ctxRecorder(); await renderBridgeEvent({ type: 'approval_required', request_id: 'r1', tool: 'Read', summary: 'Read' }, {}, rec.ctx); assert.equal(rec.statuses.at(-1), 'Villani requires authorization...'); - assert.doesNotMatch(JSON.stringify(rec.widgets), /Pending approval/); - assert.match(JSON.stringify(rec.widgets), /Villaniclearance required/); + assert.deepEqual(rec.widgets, [undefined]); + assert.doesNotMatch(JSON.stringify(rec.widgets), /Pending approval|Villaniclearance required|Allow this operation|\[object Object\]/); }); test('approval titles use Villani-style copy for Read and Bash', () => { @@ -135,12 +135,23 @@ test('approval titles use Villani-style copy for Read and Bash', () => { test('approval message for Read without path is readable', () => { const msg = approvalMessage({ tool: 'Read', input: {} }); - assert.match(msg, /Villani requests dossier access\./); + assert.doesNotMatch(msg, /Villani requests dossier access/); assert.match(msg, /Operation:\nRead/); assert.match(msg, /Approve this Villani action\?/); assert.doesNotMatch(msg, /unknown|\[object Object\]|Allow this operation\?/i); }); + +test('approval_resolved accepted and denied clear widget with short continuation status', async () => { + for (const [approved, expected] of [[true, 'Villani resumes operation...'], [false, 'Villani records denial...']] as const) { + resetVillaniUiState(); + const rec = ctxRecorder(); + await renderBridgeEvent({ type: 'approval_resolved', approved }, {}, rec.ctx); + assert.equal(rec.statuses.at(-1), expected); + assert.deepEqual(rec.widgets, [undefined]); + } +}); + test('after approval_resolved status does not hardcode old thinking copy', () => { resetVillaniCopyCounters(); const state = reduceVillaniUiState({ phase: 'approval' }, { type: 'approval_resolved' }); diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 2c662915..3036fb51 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -370,7 +370,7 @@ export function reduceVillaniUiState( } else if (event.type === "proxy_request_completed") { // Keep existing status; proxy completion is heartbeat-like UI noise. } else if (event.type === "approval_required") applyStatus("approval", event.request_id || event.requestId); - else if (event.type === "approval_resolved") applyStatus("thinking", "approval-resolved"); + else if (event.type === "approval_resolved") next.phase = event.approved === false ? "Villani records denial..." : "Villani resumes operation..."; else if (event.type === "tool_started") { const tool = String(event.tool || event.name || ""); const category = categoryForEvent(event) || "thinking"; @@ -464,8 +464,8 @@ export async function renderBridgeEvent( } if (event.type === "approval_required") { state = reduceVillaniUiState(state, event); + await setWidget(ctx, undefined); await setStatus(ctx, state.phase); - await setWidget(ctx, ["Villaniclearance required", event.summary || event.message || ""]); return; } if (event.type === "approval_resolved") { From 8144b9680271e20372f10b5e5ce3dd3b8a286969 Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Mon, 29 Jun 2026 11:48:35 +1000 Subject: [PATCH 33/36] Changes --- .github/workflows/build-pi-villani-runtime-release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml index c5dc1135..6c3f1d6e 100644 --- a/.github/workflows/build-pi-villani-runtime-release.yml +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -32,7 +32,7 @@ jobs: - run: python packaging/smoke_test_runtime.py dist-runtime/villani-code/villani-code${{ runner.os == 'Windows' && '.exe' || '' }} - shell: bash run: | - name="villani-runtime-v0.1.3-${{ matrix.key }}.${{ matrix.ext }}" + name="villani-runtime-v0.1.4-${{ matrix.key }}.${{ matrix.ext }}" cd dist-runtime if [[ "${{ matrix.ext }}" == "zip" ]]; then 7z a ../$name villani-code; else tar -czf ../$name villani-code; fi cd .. @@ -61,7 +61,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: runtime-${{ matrix.key }} - path: "villani-runtime-v0.1.3-${{ matrix.key }}.${{ matrix.ext }}*" + path: "villani-runtime-v0.1.4-${{ matrix.key }}.${{ matrix.ext }}*" release: needs: build runs-on: ubuntu-latest @@ -73,5 +73,5 @@ jobs: - uses: softprops/action-gh-release@v2 with: files: | - artifacts/**/villani-runtime-v0.1.3-* + artifacts/**/villani-runtime-v0.1.4-* checksums.txt From e5abf250c534cc1e34aefdbe878297c770c03734 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 11:55:23 +1000 Subject: [PATCH 34/36] Fix Pi release test bridge launch --- integrations/pi-villani/README.md | 2 +- integrations/pi-villani/docs/release.md | 2 +- integrations/pi-villani/src/extension.test.ts | 26 +++++++++++++------ integrations/pi-villani/src/index.ts | 9 ++++++- integrations/pi-villani/src/process.ts | 4 +-- integrations/pi-villani/src/runtimeConfig.ts | 2 +- 6 files changed, 31 insertions(+), 14 deletions(-) diff --git a/integrations/pi-villani/README.md b/integrations/pi-villani/README.md index 57794a51..e2ae68ec 100644 --- a/integrations/pi-villani/README.md +++ b/integrations/pi-villani/README.md @@ -5,7 +5,7 @@ pi install npm:@mmprotest/pi-villani ``` Provides `/villani `. -Runtime version: `v0.1.3`. +Runtime version: `v0.1.4`. ## Usage diff --git a/integrations/pi-villani/docs/release.md b/integrations/pi-villani/docs/release.md index b15606be..d0f95e8a 100644 --- a/integrations/pi-villani/docs/release.md +++ b/integrations/pi-villani/docs/release.md @@ -1,5 +1,5 @@ # Release checklist -1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.3`. +1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.4`. 2. Verify assets and checksums. 3. Publish `@mmprotest/pi-villani`. Install: `pi install npm:@mmprotest/pi-villani`. diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index e9a474e3..daceb80e 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -45,6 +45,12 @@ function bridgeScript(body: string) { } const readyPrelude = "process.stdout.write(JSON.stringify({type:'ready'})+'\\n');\nconst send=e=>process.stdout.write(JSON.stringify(e)+'\\n');\n"; +function fakeBridgeLaunch(fakeBridgeScriptPath: string) { + return { + executableOverride: process.execPath, + bridgeArgsOverride: [fakeBridgeScriptPath], + }; +} test("/villani sends command notification and only reports run started after run_started event", async () => { const old = process.env.VILLANI_COMMAND; @@ -61,6 +67,7 @@ test("/villani sends command notification and only reports run started after run { sendMessage: (m: string) => notes.push(m) }, { ui: { notify: (m: string) => notes.push(m) }, + bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp", model: { id: "m" }, }, @@ -93,7 +100,7 @@ test("/villani missing run_started timeout reports visible error instead of star runVillani( "task", {}, - { ui: { notify: (m: string) => notes.push(m) }, cwd: "/tmp" }, + { ui: { notify: (m: string) => notes.push(m) }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp" }, ), /did not acknowledge run command within 10 seconds.*no ack/s, ); @@ -145,7 +152,7 @@ setInterval(()=>{},1000); runVillani( "task", {}, - { ui: { notify: (m: string) => notes.push(m) }, cwd: "/tmp" }, + { ui: { notify: (m: string) => notes.push(m) }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp" }, ), /did not acknowledge run command within 10 seconds.*cleanup no ack/s, ); @@ -168,7 +175,7 @@ test("bridge exit before ready with ModuleNotFoundError produces pip install dia process.env.VILLANI_COMMAND = p; try { await assert.rejects( - () => bridgePing({ ui: { notify: () => {} } }), + () => bridgePing({ ui: { notify: () => {} }, bridgeLaunchOptions: fakeBridgeLaunch(p) }), /pip install -e/, ); } finally { @@ -186,7 +193,7 @@ test("/villani-bridge-ping succeeds with fake bridge", async () => { process.env.VILLANI_COMMAND = p; const notes: string[] = []; try { - await bridgePing({ ui: { notify: (m: string) => notes.push(m) } }); + await bridgePing({ ui: { notify: (m: string) => notes.push(m) }, bridgeLaunchOptions: fakeBridgeLaunch(p) }); } finally { if (old === undefined) delete process.env.VILLANI_COMMAND; else process.env.VILLANI_COMMAND = old; @@ -200,7 +207,7 @@ test("/villani-bridge-ping surfaces stderr on failure", async () => { process.env.VILLANI_COMMAND = p; try { await assert.rejects( - () => bridgePing({ ui: { notify: () => {} } }), + () => bridgePing({ ui: { notify: () => {} }, bridgeLaunchOptions: fakeBridgeLaunch(p) }), /boom stderr/, ); } finally { @@ -225,7 +232,7 @@ test("/villani surfaces bridge error before run_started without waiting 10 secon runVillani( "task", {}, - { ui: { notify: (m: string) => notes.push(m) }, cwd: "/tmp" }, + { ui: { notify: (m: string) => notes.push(m) }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp" }, ), /bad run command/, ); @@ -253,7 +260,7 @@ test("/villani launches bridge with ctx cwd", async () => { await runVillani( "task", { sendMessage: () => {} }, - { ui: { notify: () => {} }, cwd: d, model: { id: "m" } }, + { ui: { notify: () => {} }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: d, model: { id: "m" } }, ); assert.equal(readFileSync(cwdFile, "utf8"), d); } finally { @@ -359,7 +366,7 @@ test("/villani sends exactly one iterable final durable message with summary met await runVillani( "task", { sendMessage: (m: any) => sent.push(m) }, - { ui: { notify: () => {} }, cwd: "/tmp", model: { id: "m" } }, + { ui: { notify: () => {} }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp", model: { id: "m" } }, ); } finally { if (old === undefined) delete process.env.VILLANI_COMMAND; @@ -448,6 +455,7 @@ test("/villani approval pending clears widget, sets status, confirms, and accept return true; }, }, + bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp", model: { id: "m" }, }, @@ -526,6 +534,7 @@ test("/villani keeps waiting after nonzero tool_finished and renders next model notify: (m: string) => notes.push(m), setStatus: (_: string, m: string) => statuses.push(m), }, + bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp", model: { id: "m" }, }, @@ -632,6 +641,7 @@ test("command lifecycle sets then clears widget through final result", async () setWidget: (...a: any[]) => widgets.push(a), setStatus: () => {}, }, + bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp", model: { id: "m" }, }, diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index f1acaf6e..cabdf5e4 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -5,7 +5,7 @@ import { VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION, } from "./runtimeConfig.js"; -import { VillaniBridgeProcess } from "./process.js"; +import { VillaniBridgeProcess, type BridgeLaunchOptions } from "./process.js"; import { resolvePiModel, sanitizeError, @@ -159,6 +159,10 @@ function denyPending(run: ActiveRun) { function bridgeStderr(bridge: VillaniBridgeProcess) { return bridge.getRecentStderr?.() ?? bridge.stderr ?? ""; } + +function bridgeLaunchOptions(ctx: any): BridgeLaunchOptions { + return ctx?.bridgeLaunchOptions ?? ctx?.villaniBridgeLaunchOptions ?? {}; +} function bridgeDiagnosticMessage(e: unknown) { const msg = sanitizeError(e); if (/ModuleNotFoundError: No module named ['"]villani_code['"]/.test(msg)) @@ -174,8 +178,10 @@ async function assertBridgePing( ctx: any, env?: NodeJS.ProcessEnv, signal?: AbortSignal, + launchOptions: BridgeLaunchOptions = bridgeLaunchOptions(ctx), ) { const bridge = new VillaniBridgeProcess(executable, { + ...launchOptions, env, startupTimeoutMs: 30000, cwd: ctx.cwd ?? process.cwd(), @@ -319,6 +325,7 @@ export async function runVillani( } await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming..."); const bridge = new VillaniBridgeProcess(executable, { + ...bridgeLaunchOptions(ctx), env, startupTimeoutMs: 30000, proxyMode: usePi, diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts index 885a8c7c..f8247d3e 100644 --- a/integrations/pi-villani/src/process.ts +++ b/integrations/pi-villani/src/process.ts @@ -1,8 +1,8 @@ import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; import { EventEmitter } from 'node:events'; import type { BridgeEvent } from './protocol.js'; -export interface BridgeProcessOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; cwd?:string; startupTimeoutMs?:number; bridgeArgs?:string[]} export type LaunchOptions=BridgeProcessOptions; +export interface BridgeLaunchOptions{executableOverride?:string; bridgeArgsOverride?:string[]} export interface BridgeProcessOptions extends BridgeLaunchOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; cwd?:string; startupTimeoutMs?:number; bridgeArgs?:string[]} export type LaunchOptions=BridgeProcessOptions; export function sanitizedEnv(opts:BridgeProcessOptions={}):NodeJS.ProcessEnv{const e={...(opts.env||process.env)}; if(opts.proxyMode&&!opts.explicitConfigMode){for(const k of Object.keys(e)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k)) delete e[k];}} return e;} export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; startupTimeoutMs:number; readonly shell=false; finals=new Map(); private exitInfo?:{code:number|null;signal:NodeJS.Signals|null}; - constructor(executable:string, opts:BridgeProcessOptions={}){super(); const args=opts.bridgeArgs??['bridge','--stdio']; this.startupTimeoutMs=opts.startupTimeoutMs??30000; this.proc=spawn(executable,args,{shell:false,env:sanitizedEnv(opts),cwd:opts.cwd}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e)); this.proc.once('exit',(code,signal)=>{this.exitInfo={code,signal}; this.emit('exit',{code,signal});});} + constructor(executable:string, opts:BridgeProcessOptions={}){super(); const launchExecutable=opts.executableOverride??executable; const args=opts.bridgeArgsOverride??opts.bridgeArgs??['bridge','--stdio']; this.startupTimeoutMs=opts.startupTimeoutMs??30000; this.proc=spawn(launchExecutable,args,{shell:false,env:sanitizedEnv(opts),cwd:opts.cwd}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e)); this.proc.once('exit',(code,signal)=>{this.exitInfo={code,signal}; this.emit('exit',{code,signal});});} getRecentStderr(){return this.stderr;} onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id)this.finals.set(e.id,e); this.emit('event',e);}catch{const err=new Error('Malformed JSONL from bridge'); this.kill(); this.emit('error',err);}}} send(c:unknown){try{if(this.proc.stdin.destroyed||!this.proc.stdin.writable)return false; return this.proc.stdin.write(JSON.stringify(c)+'\n');}catch(e){this.emit('error',e); return false;}} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} diff --git a/integrations/pi-villani/src/runtimeConfig.ts b/integrations/pi-villani/src/runtimeConfig.ts index 0e46ada6..fa3cbcf4 100644 --- a/integrations/pi-villani/src/runtimeConfig.ts +++ b/integrations/pi-villani/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -export const VILLANI_RUNTIME_VERSION = "0.1.3"; +export const VILLANI_RUNTIME_VERSION = "0.1.4"; export const VILLANI_RUNTIME_REPOSITORY = "mmprotest/villani-code"; export const VILLANI_RUNTIME_TAG = `pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`; export type PlatformKey = "win32-x64"|"darwin-arm64"|"darwin-x64"|"linux-x64"; From fcdf90c6cb7998f47d057793d8b15b020934f66b Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Mon, 29 Jun 2026 11:58:23 +1000 Subject: [PATCH 35/36] Changes --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index ef4d1203..b92092f2 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,21 @@ Autonomous pass: villani-code --villani-mode --base-url http://127.0.0.1:1234 --model your-model --repo /path/to/repo ``` +## Use Villani Code in Pi + +# @mmprotest/pi-villani +Install with: +```bash +pi install npm:@mmprotest/pi-villani +``` +Provides `/villani `. + +Runtime version: `v0.1.4`. + +## Pi Usage + +Run Villani with `/villani `. Active runs are still cleaned up automatically on process/session cancellation or error cleanup. + ## Reports - [Villani Code Terminal-Bench 2.0 Qwen3.6 27B Report](docs/Villani_Code_Terminal_Bench_2_Qwen27B_Report.pdf) From 103ca334c0640a2d6332d1bac2ee6cf9fefc971d Mon Sep 17 00:00:00 2001 From: Benchmark Bot Date: Mon, 29 Jun 2026 12:25:02 +1000 Subject: [PATCH 36/36] Release Pi Villani v0.1.5 --- .../build-pi-villani-runtime-release.yml | 6 +- README.md | 2 +- integrations/pi-villani/README.md | 76 ++++++++++++++++++- integrations/pi-villani/docs/release.md | 2 +- integrations/pi-villani/package-lock.json | 11 ++- integrations/pi-villani/package.json | 5 +- integrations/pi-villani/src/index.ts | 2 +- integrations/pi-villani/src/runtimeConfig.ts | 2 +- 8 files changed, 93 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml index 6c3f1d6e..1ff51f5f 100644 --- a/.github/workflows/build-pi-villani-runtime-release.yml +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -32,7 +32,7 @@ jobs: - run: python packaging/smoke_test_runtime.py dist-runtime/villani-code/villani-code${{ runner.os == 'Windows' && '.exe' || '' }} - shell: bash run: | - name="villani-runtime-v0.1.4-${{ matrix.key }}.${{ matrix.ext }}" + name="villani-runtime-v0.1.5-${{ matrix.key }}.${{ matrix.ext }}" cd dist-runtime if [[ "${{ matrix.ext }}" == "zip" ]]; then 7z a ../$name villani-code; else tar -czf ../$name villani-code; fi cd .. @@ -61,7 +61,7 @@ jobs: - uses: actions/upload-artifact@v4 with: name: runtime-${{ matrix.key }} - path: "villani-runtime-v0.1.4-${{ matrix.key }}.${{ matrix.ext }}*" + path: "villani-runtime-v0.1.5-${{ matrix.key }}.${{ matrix.ext }}*" release: needs: build runs-on: ubuntu-latest @@ -73,5 +73,5 @@ jobs: - uses: softprops/action-gh-release@v2 with: files: | - artifacts/**/villani-runtime-v0.1.4-* + artifacts/**/villani-runtime-v0.1.5-* checksums.txt diff --git a/README.md b/README.md index b92092f2..4843e80f 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ pi install npm:@mmprotest/pi-villani ``` Provides `/villani `. -Runtime version: `v0.1.4`. +Runtime version: `v0.1.5`. ## Pi Usage diff --git a/integrations/pi-villani/README.md b/integrations/pi-villani/README.md index e2ae68ec..0f2fd512 100644 --- a/integrations/pi-villani/README.md +++ b/integrations/pi-villani/README.md @@ -5,8 +5,82 @@ pi install npm:@mmprotest/pi-villani ``` Provides `/villani `. -Runtime version: `v0.1.4`. +Runtime version: `v0.1.5`. ## Usage Run Villani with `/villani `. Active runs are still cleaned up automatically on process/session cancellation or error cleanup. + +# Villani Code + +**Flagship coding-agent performance from small local models.** + +Villani Code is a local-first coding-agent runtime designed to make smaller open models do real repository work: navigate files, run commands, make patches, survive verification, and keep working through messy terminal environments. + +The thesis is simple: small models do not just need better weights. They need a better runtime. + +## Terminal-Bench 2.0: Qwen3.6 27B full-suite run + +Villani Code achieved a **196/445 lower-bound score** on the full Terminal-Bench 2.0 suite using **Qwen3.6 27B**. + +That is **44.0%** across **89 tasks** with **5 attempts per task**. + +### Headline result + +| System | Model | Terminal-Bench 2.0 accuracy | +|---|---|---:| +| Codex CLI | GPT-5-Codex | 44.3% | +| **Villani Code** | **Qwen3.6 27B** | **44.0%** | +| Mini-SWE-Agent | GPT-5-Codex | 41.3% | +| Claude Code | Claude Sonnet 4.5 | 40.1% | +| Dakou Agent | Qwen 3 Coder 480B | 27.2% | +| little-coder | Qwen3.6-35B-A3B | 24.6% | +| Bash Agent | TermiGen-32B | 19.3% | +| little-coder | Qwen3.5-9B | 9.2% | + +## Qwen3.5 9B same-model runtime comparison + +Villani Code was also tested against Claude Code using the same model: **Qwen3.5 9B**. + +Same model. Same tasks. Different agent runtime. + +Villani Code won. + + +| Runner | Score | Success rate | +|---|---:|---:| +| **Villani Code + Qwen3.5 9B** | **38/60** | **63.3%** | +| Claude Code + Qwen3.5 9B | 26/60 | 43.3% | + +**Villani Code delivered a 46% relative performance improvement over Claude Code.** + +This comparison covers **12 overlapping Terminal-Bench tasks**, with **5 runs per task**, for **60 runs per agent**. + +Villani Code won **6 tasks**, tied **6 tasks**, and lost **0**. + +## What Villani Code is + +Villani Code is a terminal-first coding agent for: + +- bounded bug fixes +- repo navigation and localization +- command-driven debugging +- test-guided patching +- local inference setups +- privacy-sensitive codebases +- smaller open model backends + +It is built for the environment where most coding agents start to fall apart: smaller models, hard verification, constrained context, terminal noise, failed commands, and real repositories. + +## What changed in the upgraded runtime + +The latest Villani Code upgrade includes: + +- new execution loop +- better local model integration +- cleaner tool handling +- improved failure recovery +- task-scoped memory system +- better state tracking across long-running coding tasks + +The benchmark comparison evaluates the upgraded runtime as a whole. \ No newline at end of file diff --git a/integrations/pi-villani/docs/release.md b/integrations/pi-villani/docs/release.md index d0f95e8a..a0391856 100644 --- a/integrations/pi-villani/docs/release.md +++ b/integrations/pi-villani/docs/release.md @@ -1,5 +1,5 @@ # Release checklist -1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.4`. +1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.5`. 2. Verify assets and checksums. 3. Publish `@mmprotest/pi-villani`. Install: `pi install npm:@mmprotest/pi-villani`. diff --git a/integrations/pi-villani/package-lock.json b/integrations/pi-villani/package-lock.json index 72c9004d..2018f88e 100644 --- a/integrations/pi-villani/package-lock.json +++ b/integrations/pi-villani/package-lock.json @@ -1,13 +1,14 @@ { "name": "@mmprotest/pi-villani", - "version": "0.1.4", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mmprotest/pi-villani", - "version": "0.1.4", + "version": "0.1.5", "dependencies": { + "@earendil-works/pi-ai": "*", "adm-zip": "0.5.16", "tar": "7.4.3" }, @@ -1094,7 +1095,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" @@ -1368,6 +1369,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2505,6 +2507,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -2597,6 +2600,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3442,6 +3446,7 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/integrations/pi-villani/package.json b/integrations/pi-villani/package.json index 12dd2430..787d4b20 100644 --- a/integrations/pi-villani/package.json +++ b/integrations/pi-villani/package.json @@ -1,6 +1,6 @@ { "name": "@mmprotest/pi-villani", - "version": "0.1.4", + "version": "0.1.5", "description": "Pi extension for Villani Code", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -37,7 +37,8 @@ }, "dependencies": { "adm-zip": "0.5.16", - "tar": "7.4.3" + "tar": "7.4.3", + "@earendil-works/pi-ai": "*" }, "peerDependencies": { "@earendil-works/pi-ai": "*", diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts index cabdf5e4..0dd25ff0 100644 --- a/integrations/pi-villani/src/index.ts +++ b/integrations/pi-villani/src/index.ts @@ -587,7 +587,7 @@ async function doctor(ctx: any) { } })(); const lines = [ - `package version: 0.1.4`, + `package version: 0.1.5`, `runtime version: ${VILLANI_RUNTIME_VERSION}`, `cwd: ${ctx.cwd ?? process.cwd()}`, `ctx.model exists: ${!!ctx.model}`, diff --git a/integrations/pi-villani/src/runtimeConfig.ts b/integrations/pi-villani/src/runtimeConfig.ts index fa3cbcf4..ccbfc5c9 100644 --- a/integrations/pi-villani/src/runtimeConfig.ts +++ b/integrations/pi-villani/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -export const VILLANI_RUNTIME_VERSION = "0.1.4"; +export const VILLANI_RUNTIME_VERSION = "0.1.5"; export const VILLANI_RUNTIME_REPOSITORY = "mmprotest/villani-code"; export const VILLANI_RUNTIME_TAG = `pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`; export type PlatformKey = "win32-x64"|"darwin-arm64"|"darwin-x64"|"linux-x64";