diff --git a/package-lock.json b/package-lock.json index 15d27b7d..bc5a7cad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -553,12 +553,12 @@ } }, "node_modules/@earendil-works/pi-agent-core": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", - "integrity": "sha512-Lvn89ko42h5ETUb6Z0Ku6ldskEqXaTdQBYvSa0+7bdG9V6rUEpXptv5e0OVZ1HDcvi8s6/2lGCQWsxKX+DFHNw==", + "version": "0.80.7-anchor.3d8f743", + "resolved": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-agent-core-0.80.7-anchor.3d8f743.tgz", + "integrity": "sha512-f8sqaHU4pnZzO3n4J+SN/ihA6RwfkHh6fTlDhMrEEPtHo/qHjz4RbnjF5nBIm/6Y1HAtGgs1N1a36VfiGsTYng==", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.80.6", + "@earendil-works/pi-ai": "0.80.7-anchor.3d8f743", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" @@ -568,9 +568,9 @@ } }, "node_modules/@earendil-works/pi-ai": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.tgz", - "integrity": "sha512-7xfLk8sANBp+bpPEbjoOZTbPxsa+++b1JXAoSJsNa3vbs9AHHEclmvg54XLQcxH+fuwaeti/g2jeIfJ+mVYLpA==", + "version": "0.80.7-anchor.3d8f743", + "resolved": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz", + "integrity": "sha512-mpylF8DalhPpP9XzgF6G8EO1ViG+IDQl/10ajpfaSFpQs9b1G6lXbrVng9OdzPe7u1z6tc27OaGWaD5z1g8LdA==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "0.91.1", @@ -593,15 +593,14 @@ } }, "node_modules/@earendil-works/pi-coding-agent": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.6.tgz", - "integrity": "sha512-vcfD6tOk402isLl3Cm/qbn2O10TvgroMp1+/fEGM24ZdvETFCdOYv5VZ7m59EI5fPsjfSJh+CpQ5bhBrhfOg7g==", - "hasShrinkwrap": true, + "version": "0.80.7-anchor.3d8f743", + "resolved": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-coding-agent-0.80.7-anchor.3d8f743.tgz", + "integrity": "sha512-SEIEpTPtMWu49+u53ijqH54Vg0JBJB0JiBJcFZ+A3UBWphmppQLDDDxjRfkJA8csNu+9CWLbi6j/M91cjPVMNQ==", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "^0.80.6", - "@earendil-works/pi-ai": "^0.80.6", - "@earendil-works/pi-tui": "^0.80.6", + "@earendil-works/pi-agent-core": "0.80.7-anchor.3d8f743", + "@earendil-works/pi-ai": "0.80.7-anchor.3d8f743", + "@earendil-works/pi-tui": "0.80.7-anchor.3d8f743", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -628,515 +627,6 @@ "@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.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.6.tgz", - "license": "MIT", - "dependencies": { - "@earendil-works/pi-ai": "^0.80.6", - "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.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.6.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.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.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", @@ -1316,257 +806,12 @@ "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", @@ -1576,41 +821,6 @@ "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", @@ -1623,12 +833,6 @@ "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", @@ -1655,32 +859,6 @@ "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", @@ -1690,133 +868,6 @@ "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", @@ -1834,32 +885,6 @@ "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", @@ -1887,105 +912,21 @@ "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==", + "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", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "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", @@ -1995,18 +936,6 @@ "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", @@ -2031,111 +960,6 @@ "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", @@ -2181,58 +1005,6 @@ "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", @@ -2272,36 +1044,6 @@ "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", @@ -2311,21 +1053,6 @@ "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", @@ -2341,79 +1068,10 @@ "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/@earendil-works/pi-tui": { - "version": "0.80.6", - "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.6.tgz", - "integrity": "sha512-bSuzS4EVSqEPj/Qr/p9eqCESfKsGuDNbl77EGci8Iaqqt/C/XCBZL1MjXaxSWW1NsT5afjp/Cb0NTPzOLv/aPA==", + "version": "0.80.7-anchor.3d8f743", + "resolved": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-tui-0.80.7-anchor.3d8f743.tgz", + "integrity": "sha512-400zigCSTDd+WJACQUgAyrudUJsN5UZdGo+tjCRZ85jl91QSBEHrNL6dU8A0uMtlGyCC0bqvp1yrBz1si0HCMA==", "license": "MIT", "dependencies": { "get-east-asian-width": "1.6.0", @@ -6073,8 +4731,8 @@ "version": "0.6.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-agent-core": "0.80.6", - "@earendil-works/pi-ai": "0.80.6", + "@earendil-works/pi-agent-core": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-agent-core-0.80.7-anchor.3d8f743.tgz", + "@earendil-works/pi-ai": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz", "@onkernel/cua-ai": "0.6.0", "@onkernel/sdk": "0.49.0", "sharp": "^0.34.5" @@ -6089,7 +4747,7 @@ "version": "0.6.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "0.80.6", + "@earendil-works/pi-ai": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz", "@tzafon/lightcone": "^0.7.0", "openai": "^6.26.0" }, @@ -6103,8 +4761,8 @@ "version": "0.4.0", "license": "MIT", "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.6", - "@earendil-works/pi-tui": "0.80.6", + "@earendil-works/pi-coding-agent": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-coding-agent-0.80.7-anchor.3d8f743.tgz", + "@earendil-works/pi-tui": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-tui-0.80.7-anchor.3d8f743.tgz", "@onkernel/cua-agent": "0.6.0", "@onkernel/cua-ai": "0.6.0", "@onkernel/sdk": "0.49.0" diff --git a/packages/agent/package.json b/packages/agent/package.json index 1b332e04..88148248 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -40,8 +40,8 @@ "typecheck": "tsc -b" }, "dependencies": { - "@earendil-works/pi-agent-core": "0.80.6", - "@earendil-works/pi-ai": "0.80.6", + "@earendil-works/pi-agent-core": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-agent-core-0.80.7-anchor.3d8f743.tgz", + "@earendil-works/pi-ai": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz", "@onkernel/cua-ai": "0.6.0", "@onkernel/sdk": "0.49.0", "sharp": "^0.34.5" diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index da6ced1b..0f0f9228 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -650,6 +650,7 @@ export class CuaAgentHarness< private requestedActiveToolNames?: string[]; private emptyResponseRecoveryAttempts = 0; private hasPendingActiveQueue = false; + private toolMutation = Promise.resolve(); constructor(options: CuaAgentHarnessOptions) { const { @@ -734,6 +735,16 @@ export class CuaAgentHarness< this.emptyResponseRecoveryAttempts += 1; } + /** Serialize runtime and extension tool-list changes. */ + async mutateTools(mutation: () => Promise): Promise { + const result = this.toolMutation.then(mutation); + this.toolMutation = result.then( + () => {}, + () => {}, + ); + return result; + } + /** * Mirror pi `AgentHarness.setModel()` while accepting CUA model refs. * @@ -744,16 +755,20 @@ export class CuaAgentHarness< override async setModel(model: CuaRuntimeInput): Promise { this.runtime.setModel(model); const tools = this.runtime.tools(); - try { - await super.setTools(tools, this.requestedActiveToolNames ?? tools.map((tool) => tool.name)); - } catch (err) { - // The pre-switch tools stay exposed, so restore the runtime they are - // bound to — including its still-live translator. - this.runtime.rollbackSwitch(); - throw err; - } - this.runtime.commitSwitch(); - await super.setModel(this.runtime.model); + const resolvedModel = this.runtime.model; + await this.mutateTools(async () => { + try { + await super.setTools( + tools, + this.requestedActiveToolNames ?? tools.map((tool) => tool.name), + ); + } catch (err) { + this.runtime.rollbackSwitch(); + throw err; + } + this.runtime.commitSwitch(); + }); + await super.setModel(resolvedModel); } override async setActiveTools(toolNames: string[]): Promise { @@ -767,28 +782,39 @@ export class CuaAgentHarness< * plane conflicts with the requested mode. */ async setMode(mode: CuaMode): Promise { - if (mode === this.runtime.mode) return; - const previousNames = new Set(this.getTools().map((tool) => tool.name)); - this.runtime.setMode(mode); - const tools = this.runtime.tools(); - // Tools that survive the mode switch (extraTools, shared names) keep - // their requested activation state; names new in this mode activate. - const requested = this.requestedActiveToolNames; - const active = requested - ? tools.map((tool) => tool.name).filter((name) => !previousNames.has(name) || requested.includes(name)) - : tools.map((tool) => tool.name); - try { - await super.setTools(tools, active); - } catch (err) { - // The pre-switch tools stay exposed, so restore the runtime they are - // bound to — including its still-live translator. - this.runtime.rollbackSwitch(); - throw err; - } - this.runtime.commitSwitch(); - // The requested subset now reflects this mode's toolset; without this a - // later setModel would restore the pre-switch names. - if (requested) this.requestedActiveToolNames = active; + await this.mutateTools(async () => { + if (mode === this.runtime.mode) return; + const previousTools = this.getTools(); + const previousNames = new Set(previousTools.map((tool) => tool.name)); + const previousRuntimeNames = new Set(this.runtime.tools().map((tool) => tool.name)); + const previousActiveNames = new Set(this.getActiveTools().map((tool) => tool.name)); + this.runtime.setMode(mode); + const runtimeTools = this.runtime.tools(); + const runtimeNames = new Set(runtimeTools.map((tool) => tool.name)); + const tools = [ + ...runtimeTools, + ...previousTools.filter( + (tool) => !previousRuntimeNames.has(tool.name) && !runtimeNames.has(tool.name), + ), + ]; + // Tools that survive the mode switch keep their active state; names new + // in this mode activate. + const active = tools + .map((tool) => tool.name) + .filter((name) => !previousNames.has(name) || previousActiveNames.has(name)); + try { + await super.setTools(tools, active); + } catch (err) { + // The pre-switch tools stay exposed, so restore the runtime they are + // bound to — including its still-live translator. + this.runtime.rollbackSwitch(); + throw err; + } + this.runtime.commitSwitch(); + // The requested subset now reflects this mode's toolset; without this a + // later setModel would restore the pre-switch names. + if (this.requestedActiveToolNames) this.requestedActiveToolNames = active; + }); } /** The action plane(s) currently exposed to the model. */ diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index b1d8683c..f895377c 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -1820,6 +1820,23 @@ describe("CuaAgentHarness", () => { expect(names).toContain("browser_snapshot"); }); + it("setMode keeps tools registered after construction", async () => { + const harness = new CuaAgentHarness({ + ...(await createHarnessServices()), + browser, + client, + model: "anthropic:claude-opus-4-5", + }); + const extensionTool = createCustomTool("extension_custom"); + const tools = [...harness.getTools(), extensionTool]; + await harness.setTools(tools, tools.map((tool) => tool.name)); + + await harness.setMode("browser"); + + expect(harness.getTools().map((tool) => tool.name)).toContain("extension_custom"); + expect(harness.getActiveTools().map((tool) => tool.name)).toContain("extension_custom"); + }); + it("setMode keeps the requested activation state of surviving tools", async () => { const harness = new CuaAgentHarness({ ...(await createHarnessServices()), diff --git a/packages/ai/package.json b/packages/ai/package.json index e349b655..f2a602b1 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -41,7 +41,7 @@ "test:integration": "vitest --run --config vitest.integration.config.ts" }, "dependencies": { - "@earendil-works/pi-ai": "0.80.6", + "@earendil-works/pi-ai": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz", "@tzafon/lightcone": "^0.7.0", "openai": "^6.26.0" }, diff --git a/packages/ai/test/dynamic-tool-payload.test.ts b/packages/ai/test/dynamic-tool-payload.test.ts new file mode 100644 index 00000000..7618c5ed --- /dev/null +++ b/packages/ai/test/dynamic-tool-payload.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AgentTool, Context, Message, Model } from "@earendil-works/pi-ai"; +import { createServer } from "node:http"; +import { cuaModels, getCuaModel, resolveCuaRuntimeSpec } from "../src/index"; +import { ANTHROPIC_NATIVE_BROWSER_MESSAGES_API } from "../src/providers/anthropic/native"; + +const { openaiCreate } = vi.hoisted(() => ({ + openaiCreate: vi.fn(), +})); + +vi.mock("openai", () => ({ + default: class { + responses = { + create: (...args: unknown[]) => { + openaiCreate(...args); + return { + withResponse: async () => { + throw new Error("stop after payload capture"); + }, + }; + }, + }; + }, +})); + +const dynamicTool: AgentTool = { + name: "learned_tool", + label: "Learned tool", + description: "learned", + parameters: { type: "object", properties: {} }, + execute: async () => ({ content: [], details: {} }), +}; +const addTool: AgentTool = { ...dynamicTool, name: "add_tool", label: "Add tool" }; + +function addMessages(api: string, responseId?: string): Message[] { + return [ + { + role: "assistant", + content: [{ type: "toolCall", id: "add_1", name: "add_tool", arguments: {} }], + api, + provider: api.startsWith("openai") ? "openai" : "anthropic", + model: api.startsWith("openai") ? "gpt-5.5" : "claude-sonnet-4-5", + responseId, + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "toolUse", + timestamp: 0, + }, + { + role: "toolResult", + toolCallId: "add_1", + toolName: "add_tool", + content: [{ type: "text", text: "added" }], + addedToolNames: ["learned_tool"], + isError: false, + timestamp: 0, + }, + ]; +} + +async function capturePayload( + model: Model, + context: Context, + transform?: (payload: unknown, model: Model) => unknown | Promise, +): Promise> { + let payload: Record | undefined; + await cuaModels() + .streamSimple(model, context, { + apiKey: "test", + onPayload: async (value, payloadModel) => { + const transformed = transform + ? ((await transform(value, payloadModel)) ?? value) + : value; + payload = transformed as Record; + return transformed; + }, + }) + .result(); + if (!payload) throw new Error("payload was not captured"); + return payload; +} + +describe("message-anchored dynamic tool payloads", () => { + it("uses OpenAI tool search while preserving response threading", async () => { + const model = getCuaModel("openai:gpt-5.5"); + const payload = await capturePayload(model, { + systemPrompt: "test", + tools: [addTool, dynamicTool], + messages: addMessages(model.api, "resp_add"), + }); + expect(payload.store).toBe(true); + expect(payload.previous_response_id).toBe("resp_add"); + const searchOutput = payload.input.find((item: any) => item.type === "tool_search_output"); + expect(searchOutput.tools).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "learned_tool", defer_loading: true })]), + ); + expect(payload.input).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "tool_search_call" }), + expect.objectContaining({ type: "tool_search_output" }), + ]), + ); + }); + + it("eagerly declares the tool after the add marker is pruned by the next response anchor", async () => { + const model = getCuaModel("openai:gpt-5.5"); + const messages = addMessages(model.api, "resp_add"); + messages.push( + { + role: "assistant", + content: [{ type: "toolCall", id: "learn_1", name: "learned_tool", arguments: {} }], + api: model.api, + provider: "openai", + model: model.id, + responseId: "resp_learned", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "toolUse", + timestamp: 0, + }, + { role: "toolResult", toolCallId: "learn_1", toolName: "learned_tool", content: [], isError: false, timestamp: 0 }, + ); + const payload = await capturePayload(model, { systemPrompt: "test", tools: [addTool, dynamicTool], messages }); + expect(payload.previous_response_id).toBe("resp_learned"); + expect(payload.tools).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "learned_tool" })]), + ); + expect(payload.tools.find((tool: any) => tool.name === "learned_tool").defer_loading).toBeUndefined(); + }); + + it("uses Anthropic references for supported models and eager tools for Haiku", async () => { + const supported = getCuaModel("anthropic:claude-sonnet-4-5"); + const supportedPayload = await capturePayload(supported, { + systemPrompt: "test", + tools: [addTool, dynamicTool], + messages: addMessages(supported.api), + }); + expect(supportedPayload.tools).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "learned_tool", defer_loading: true })]), + ); + expect(JSON.stringify(supportedPayload.messages)).toContain("tool_reference"); + + const haiku = { + ...supported, + id: "claude-haiku-4", + compat: { ...supported.compat, supportsToolReferences: false }, + }; + const haikuPayload = await capturePayload(haiku, { + systemPrompt: "test", + tools: [addTool, dynamicTool], + messages: addMessages(haiku.api), + }); + expect(JSON.stringify(haikuPayload.messages)).not.toContain("tool_reference"); + expect(haikuPayload.tools).toEqual( + expect.arrayContaining([expect.objectContaining({ name: "learned_tool" })]), + ); + }); + + it("preserves dynamic tools and beta headers through Anthropic native routing", async () => { + let requestHeaders: Record = {}; + const server = createServer((request, response) => { + requestHeaders = request.headers; + response.writeHead(500, { "content-type": "application/json" }); + response.end('{"error":{"message":"captured"}}'); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + try { + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("test server did not bind"); + const spec = resolveCuaRuntimeSpec( + "anthropic:claude-sonnet-4-5", + { nativeTool: { type: "browser_20260701" } }, + ); + const model = { + ...spec.model, + api: ANTHROPIC_NATIVE_BROWSER_MESSAGES_API, + baseUrl: `http://127.0.0.1:${address.port}`, + } as Model; + const nativePlaceholder = { + ...dynamicTool, + ...spec.toolDefinitions[0], + } as AgentTool; + const payload = await capturePayload( + model, + { + systemPrompt: "test", + tools: [nativePlaceholder, addTool, dynamicTool], + messages: addMessages("anthropic-messages"), + }, + spec.onPayload, + ); + expect(payload.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "learned_tool", + defer_loading: true, + }), + ]), + ); + expect(payload.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "browser_20260701" }), + ]), + ); + expect(requestHeaders["anthropic-beta"]).toContain( + "browser-use-2026-07-01", + ); + } finally { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } + }); +}); diff --git a/packages/cli/README.md b/packages/cli/README.md index 4e0d3bd6..99dc7547 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -43,7 +43,7 @@ cua do "buy a pair of socks on amazon" --max-steps 20 cua models cua models -p openai cua --print --model openai:gpt-5.5 "..." -cua --print --model anthropic:claude-opus-4-7 "..." +cua --print --model anthropic:claude-opus-4-8 "..." cua --print --model google:gemini-3-flash-preview "..." cua --print --model meta:muse-spark-1.1 "..." cua --print --model xai:grok-4.5 "..." @@ -176,9 +176,11 @@ loader are appended to the system prompt and listed in the TUI's context files still load, since they describe the project rather than add agent capabilities. -pi *extensions* are not executed by `cua`: extensions bind into pi's -`AgentSession`, and `cua` drives the lower-level `AgentHarness` -directly. Installed-package skills and context still load. +Project and user pi extensions load at startup and can be refreshed with +`/reload`. `--self-extend` additionally exposes `add_tool`, which accepts one +constrained tool definition, persists it under `.agents/extensions`, and makes +it callable on the next model turn in the same run. Authored execute code is +trusted local code, not sandboxed. ## Image protocol diff --git a/packages/cli/package.json b/packages/cli/package.json index 3eeee438..00daca4e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,8 +34,8 @@ "typecheck": "tsc -b" }, "dependencies": { - "@earendil-works/pi-coding-agent": "0.80.6", - "@earendil-works/pi-tui": "0.80.6", + "@earendil-works/pi-coding-agent": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-coding-agent-0.80.7-anchor.3d8f743.tgz", + "@earendil-works/pi-tui": "https://raw.githubusercontent.com/kernel/cua/eac28f9f4992c648d0d046dfe62550bce643ff85/vendor/pi/earendil-works-pi-tui-0.80.7-anchor.3d8f743.tgz", "@onkernel/cua-agent": "0.6.0", "@onkernel/cua-ai": "0.6.0", "@onkernel/sdk": "0.49.0" diff --git a/packages/cli/src/action/harness-runner.ts b/packages/cli/src/action/harness-runner.ts index bd883a8a..9bf76f07 100644 --- a/packages/cli/src/action/harness-runner.ts +++ b/packages/cli/src/action/harness-runner.ts @@ -107,24 +107,14 @@ export async function runAction( } async function maybeInitialScreenshot(opts: HarnessRunOptions): Promise { - if (opts.skipInitialScreenshot) return undefined; - const hasPriorTurn = await sessionHasPriorTurn(opts.session); - if (hasPriorTurn) return undefined; + // `skipInitialScreenshot` is decided once at startup (before extensions load), + // so an extension's startup message can't suppress the user's first-turn frame. + if (opts.skipInitialScreenshot || opts.harness.getMode() === "browser") return undefined; const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id); if (!png) return undefined; return [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }]; } -async function sessionHasPriorTurn(session: Session): Promise { - const entries = await session.getBranch(); - for (const entry of entries) { - if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) { - return true; - } - } - return false; -} - function textFromAssistant(message: AssistantMessage): string { const parts: string[] = []; for (const block of message.content) { diff --git a/packages/cli/src/cli-harness.ts b/packages/cli/src/cli-harness.ts index a9283522..9883d32a 100644 --- a/packages/cli/src/cli-harness.ts +++ b/packages/cli/src/cli-harness.ts @@ -10,13 +10,18 @@ import { type CuaMode, type CuaModelRef, type CuaNativeToolSpec, + type ImageContent, parseCuaModelRef, requireCuaEnvApiKey, resolveCuaRuntimeSpec, } from "@onkernel/cua-ai"; import { parseArgs } from "node:util"; import { stderr, stdout } from "node:process"; -import type { CuaBrowserHandle } from "./harness-browser"; +import { captureScreenshot, type CuaBrowserHandle } from "./harness-browser"; +import { + loadHarnessExtensions, + type HarnessExtensions, +} from "./extensions/setup"; import { type ActionRequest, type ModelActionType, @@ -178,12 +183,14 @@ export interface HarnessCliFlags { resumePicker: boolean; noSession: boolean; noSkills: boolean; + noExtensions: boolean; debugTui: boolean; jsonlIncludeDeltas: boolean; jsonlIncludeImages: boolean; playwright: boolean; mode?: string; nativeTool?: string; + selfExtend: boolean; model?: string; thinking?: string; browserProfile?: string; @@ -359,6 +366,15 @@ interface HarnessRuntime { harness: ReturnType; provider: string; modelRef: CuaModelRef; + /** + * Whether the first prompt should skip the initial browser screenshot. + * Decided once here, before extensions load, so an extension that sends a + * user message during startup can't flip the live prior-turn check and leave + * the user's real first prompt without the screenshot. + */ + skipInitialScreenshot: boolean; + /** Loaded pi-extension host. Undefined with --no-extensions or an untrusted project + no global extensions. */ + host?: HarnessExtensions; } export interface SetupHarnessRuntimeOptions { @@ -482,6 +498,39 @@ async function finishHarnessRuntime( modelBaseUrl: baseUrlOverride, }); + const handle = provisioned.handle; + // Raw capture: the host gates this behind the session's prior-turn state, so + // it must not re-apply the CLI's own first-prompt guard. + const initialScreenshot = async (): Promise => { + const png = await captureScreenshot(handle.client, handle.browser.session_id); + return png ? [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }] : undefined; + }; + // A throwing extension load (e.g. a tool name colliding with a base tool) must + // not leak the already-provisioned browser handle: the caller's finally only + // runs once this returns, so close the handle here before rethrowing. The + // first-turn screenshot decision reads the session, so keep it inside the same + // guard. + let host: HarnessExtensions | undefined; + let skipInitialScreenshot: boolean; + try { + // Decide the first-turn screenshot before extensions load: a resumed session + // already has turns, and capturing it now (rather than re-reading the live + // transcript at prompt time) keeps an extension's startup sendUserMessage from + // flipping the check and leaving the user's real first prompt without it. + skipInitialScreenshot = resolved?.resumed === true || (await sessionHasPriorTurn(session)); + host = await loadHarnessExtensions({ + harness, + session, + cwd, + noExtensions: flags.noExtensions, + initialScreenshot, + selfExtend: flags.selfExtend, + }); + } catch (err) { + await provisioned.handle.close().catch(() => {}); + throw err; + } + return { handle: provisioned.handle, resolved, @@ -491,9 +540,20 @@ async function finishHarnessRuntime( harness, provider, modelRef: auth.modelRef, + skipInitialScreenshot, + host, }; } +async function sessionHasPriorTurn(session: Session): Promise { + const entries = await session.getBranch(); + return entries.some( + (entry) => + entry.type === "message" && + (entry.message.role === "user" || entry.message.role === "assistant"), + ); +} + function hasExplicitSessionFlag(flags: HarnessCliFlags): boolean { return ( !!flags.sessionRef || @@ -563,13 +623,21 @@ export async function runPrintCommand(prompt: string, flags: HarnessCliFlags): P provider: runtime.provider, prompt, skills: runtime.skills, - skipInitialScreenshot: runtime.resolved?.resumed === true, + skipInitialScreenshot: runtime.skipInitialScreenshot, verbose: flags.verbose, jsonlMode, jsonlIncludeDeltas: flags.jsonlIncludeDeltas, jsonlIncludeImages: flags.jsonlIncludeImages, }); } finally { + // Dispose before closing the handle: extensions receive session_shutdown + // during dispose and may call back into the harness while the browser lives. + // Separate try blocks so a throwing extension shutdown never skips close(). + try { + await runtime.host?.dispose(); + } catch (err) { + stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`); + } try { await runtime.handle.close(); } catch (err) { @@ -600,10 +668,16 @@ export async function runInteractiveCommand( debugTui: flags.debugTui, resumed: runtime.resolved?.resumed === true, transcriptPath: runtime.resolved?.transcriptPath, - skipInitialScreenshot: runtime.resolved?.resumed === true, + skipInitialScreenshot: runtime.skipInitialScreenshot, namedSession: flags.namedSession, + host: runtime.host, }); } finally { + try { + await runtime.host?.dispose(); + } catch (err) { + stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`); + } try { await runtime.handle.close(); } catch (err) { @@ -626,10 +700,15 @@ export async function runActionCommand( harness: runtime.harness, browserHandle: runtime.handle, session: runtime.session, - skipInitialScreenshot: runtime.resolved?.resumed === true, + skipInitialScreenshot: runtime.skipInitialScreenshot, }); return emitCompact(res); } finally { + try { + await runtime.host?.dispose(); + } catch (err) { + stderr.write(`[cua] cleanup warning: ${(err as Error).message}\n`); + } try { await runtime.handle.close(); } catch (err) { diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 2da5b820..3272fd07 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -51,7 +51,7 @@ Options: match exactly one entry in \`cua models\`. Recommended: openai: openai:gpt-5.5 - anthropic: anthropic:claude-opus-4-7 + anthropic: anthropic:claude-opus-4-8 google: google:gemini-3-flash-preview meta: meta:muse-spark-1.1 xai: xai:grok-4.5 @@ -74,6 +74,8 @@ Options: --native-tool Drive an Anthropic model through its native tool schema: computer_20260701 (requires --mode computer) or browser_20260701 (requires --mode browser) + --self-extend Expose add_tool for adding one durable project tool; + the tool is active on the next model turn in the same run --out Output file for screenshot subcommand --filter Restrict \`cua snapshot\` to interactive elements -o, --output Output format for --print: text (default) | jsonl @@ -93,6 +95,9 @@ Options: /.agents/skills/, the pi agent dir (~/.pi/agent/), and pi-installed packages. -ns, --no-skills Disable skill discovery entirely + --no-extensions Disable pi extensions, which otherwise load from + /.agents/extensions, /.pi/extensions, + and the pi agent dir (~/.pi/agent/extensions/) --debug-tui Enable TUI render diagnostics for manual repros -v, --verbose Verbose progress output to stderr -h, --help Show this help @@ -129,12 +134,14 @@ interface CliFlags { resumePicker: boolean; noSession: boolean; noSkills: boolean; + noExtensions: boolean; debugTui: boolean; jsonlIncludeDeltas: boolean; jsonlIncludeImages: boolean; playwright: boolean; mode?: string; nativeTool?: string; + selfExtend: boolean; model?: string; thinking?: string; browserProfile?: string; @@ -183,6 +190,7 @@ function parseCliArgs(argv: string[]): CliFlags { "no-session": { type: "boolean", default: false }, skill: { type: "string", multiple: true, default: [] }, "no-skills": { type: "boolean", default: false }, + "no-extensions": { type: "boolean", default: false }, "debug-tui": { type: "boolean", default: false }, output: { type: "string", short: "o" }, "jsonl-include-deltas": { type: "boolean", default: false }, @@ -190,6 +198,7 @@ function parseCliArgs(argv: string[]): CliFlags { playwright: { type: "boolean", default: false }, mode: { type: "string" }, "native-tool": { type: "string" }, + "self-extend": { type: "boolean", default: false }, }, allowPositionals: true, strict: true, @@ -229,6 +238,7 @@ function parseCliArgs(argv: string[]): CliFlags { resumePicker: !!parsed.values.resume, noSession: !!parsed.values["no-session"], noSkills: !!parsed.values["no-skills"], + noExtensions: !!parsed.values["no-extensions"], debugTui: !!parsed.values["debug-tui"], model: parsed.values.model as string | undefined, thinking: parsed.values.thinking as string | undefined, @@ -249,6 +259,7 @@ function parseCliArgs(argv: string[]): CliFlags { playwright: !!parsed.values.playwright, mode: parsed.values.mode as string | undefined, nativeTool: parsed.values["native-tool"] as string | undefined, + selfExtend: !!parsed.values["self-extend"], positionals: parsed.positionals, }; } @@ -261,12 +272,14 @@ function toHarnessFlags(flags: CliFlags): HarnessCliFlags { resumePicker: flags.resumePicker, noSession: flags.noSession, noSkills: flags.noSkills, + noExtensions: flags.noExtensions, debugTui: flags.debugTui, jsonlIncludeDeltas: flags.jsonlIncludeDeltas, jsonlIncludeImages: flags.jsonlIncludeImages, playwright: flags.playwright, mode: flags.mode, nativeTool: flags.nativeTool, + selfExtend: flags.selfExtend, model: flags.model, thinking: flags.thinking, browserProfile: flags.browserProfile, diff --git a/packages/cli/src/extensions/add-tool.ts b/packages/cli/src/extensions/add-tool.ts new file mode 100644 index 00000000..c74be10a --- /dev/null +++ b/packages/cli/src/extensions/add-tool.ts @@ -0,0 +1,287 @@ +import type { AgentToolResult } from "@onkernel/cua-agent"; +import { + createSyntheticSourceInfo, + discoverAndLoadExtensions, + type RegisteredTool, +} from "@earendil-works/pi-coding-agent"; +import { link, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +export interface AddToolInput { + name: string; + label?: string; + description: string; + parameters: Record; + execute: string; +} + +export interface AddToolDetails { + written: string; + valid: true; + addedToolNames: string[]; +} + +export interface AddToolRegistrationOptions { + cwd: string; + extensionRoot: string | undefined; + hasToolName(name: string): boolean; + installTool(registration: RegisteredTool): Promise; +} + +const ADD_TOOL_NAME = "add_tool"; +const TOOL_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/; + +const ADD_TOOL_DESCRIPTION = [ + "Add one trusted project-local tool and make it available immediately.", + "The definition is validated, persisted beneath .agents/extensions, and", + "activated before this call returns, so it can be called on the next model turn.", + "The execute field must be one async function expression. This capability is not", + "a sandbox: execute code has the same Node.js access as other local extensions.", +].join("\n"); + +const ADD_TOOL_PARAMETERS = { + type: "object", + properties: { + name: { + type: "string", + description: "provider-safe tool name (letters, digits, _ and -)", + }, + label: { type: "string", description: "display label; defaults to name" }, + description: { type: "string", description: "non-empty tool description" }, + parameters: { + type: "object", + description: 'JSON Schema with top-level type "object"', + }, + execute: { + type: "string", + description: + "one async function expression with signature (toolCallId, params, signal, onUpdate)", + }, + }, + required: ["name", "description", "parameters", "execute"], + additionalProperties: false, +} as const; + +/** Build the normal Pi tool registration used by the compatibility host. */ +export function createAddToolRegistration( + options: AddToolRegistrationOptions, +): RegisteredTool { + return { + definition: { + name: ADD_TOOL_NAME, + label: "Add tool", + description: ADD_TOOL_DESCRIPTION, + parameters: ADD_TOOL_PARAMETERS, + executionMode: "sequential", + execute: async ( + _toolCallId, + rawInput, + ): Promise> => addTool(options, rawInput), + }, + sourceInfo: createSyntheticSourceInfo(ADD_TOOL_NAME, { + source: "cua --self-extend", + scope: "project", + baseDir: options.cwd, + }), + }; +} + +async function addTool( + options: AddToolRegistrationOptions, + input: unknown, +): Promise> { + const extensionRoot = options.extensionRoot; + if (!extensionRoot) + throw new Error("no project extension directory configured for add_tool"); + const normalized = validateAddToolInput(input); + const target = join(extensionRoot, `${normalized.name}.ts`); + if (options.hasToolName(normalized.name)) { + throw new Error(`tool name "${normalized.name}" already exists`); + } + + await mkdir(extensionRoot, { recursive: true }); + const stagingDir = await mkdtemp(join(extensionRoot, ".add-tool-")); + const stagedFile = join(stagingDir, `${normalized.name}.ts`); + try { + await writeFile(stagedFile, renderToolExtension(normalized), { + encoding: "utf8", + flag: "wx", + }); + const registered = await trialLoadTool( + stagedFile, + normalized.name, + stagingDir, + ); + try { + await link(stagedFile, target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`extension already exists at ${target}`); + } + throw error; + } + + try { + await options.installTool({ + definition: registered.definition, + sourceInfo: createSyntheticSourceInfo(target, { + source: target, + scope: "project", + baseDir: extensionRoot, + }), + }); + } catch (error) { + await rm(target, { force: true }); + throw error; + } + + return { + content: [{ type: "text", text: `added ${normalized.name} at ${target}` }], + details: { + written: target, + valid: true, + addedToolNames: [normalized.name], + }, + }; + } finally { + await rm(stagingDir, { recursive: true, force: true }); + } +} + +async function trialLoadTool( + filePath: string, + expectedName: string, + isolatedRoot: string, +): Promise { + const result = await discoverAndLoadExtensions( + [filePath], + isolatedRoot, + isolatedRoot, + ); + if (result.errors.length > 0) { + throw new Error( + `tool validation failed: ${result.errors.map((entry) => entry.error).join("; ")}`, + ); + } + const registrations = result.extensions.flatMap((extension) => [ + ...extension.tools.values(), + ]); + if ( + registrations.length !== 1 || + registrations[0]?.definition.name !== expectedName + ) { + throw new Error( + `generated extension must register exactly one tool named "${expectedName}"`, + ); + } + const registration = registrations[0]; + if ( + typeof registration.definition.execute !== "function" || + registration.definition.execute.constructor.name !== "AsyncFunction" + ) { + throw new Error("execute must be one async function expression"); + } + return registration; +} + +function validateAddToolInput(input: unknown): Required { + if (!input || typeof input !== "object" || Array.isArray(input)) { + throw new Error("tool definition must be an object"); + } + const candidate = input as Record; + if ( + typeof candidate.name !== "string" || + !TOOL_NAME_PATTERN.test(candidate.name) + ) { + throw new Error( + "name must start with a letter, contain only letters, digits, _ or -, and be at most 64 characters", + ); + } + const label = candidate.label ?? candidate.name; + if (typeof label !== "string" || label.trim().length === 0) + throw new Error("label must be non-empty"); + if ( + typeof candidate.description !== "string" || + candidate.description.trim().length === 0 + ) { + throw new Error("description must be non-empty"); + } + if ( + !candidate.parameters || + typeof candidate.parameters !== "object" || + Array.isArray(candidate.parameters) || + (candidate.parameters as Record).type !== "object" + ) { + throw new Error( + 'parameters must be a JSON-serializable object schema with top-level type "object"', + ); + } + try { + JSON.stringify(candidate.parameters); + } catch { + throw new Error("parameters must be JSON-serializable"); + } + if ( + typeof candidate.execute !== "string" || + !/^(?:\s*)async\b/.test(candidate.execute) || + hasTopLevelComma(candidate.execute) + ) { + throw new Error("execute must be one async function expression"); + } + return { + name: candidate.name, + label, + description: candidate.description, + parameters: candidate.parameters as Record, + execute: candidate.execute, + }; +} + +function hasTopLevelComma(source: string): boolean { + let parens = 0; + let braces = 0; + let brackets = 0; + let quote: "'" | '"' | "`" | undefined; + let escaped = false; + for (const character of source) { + if (quote) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === quote) quote = undefined; + continue; + } + if (character === "'" || character === '"' || character === "`") { + quote = character; + continue; + } + if (character === "(") parens += 1; + else if (character === ")") parens -= 1; + else if (character === "{") braces += 1; + else if (character === "}") braces -= 1; + else if (character === "[") brackets += 1; + else if (character === "]") brackets -= 1; + else if (character === "," && parens === 0 && braces === 0 && brackets === 0) + return true; + } + return false; +} + +export function renderToolExtension(input: Required): string { + return [ + `const name = ${JSON.stringify(input.name)};`, + `const label = ${JSON.stringify(input.label)};`, + `const description = ${JSON.stringify(input.description)};`, + `const parameters = ${JSON.stringify(input.parameters)};`, + "", + "export default function (pi) {", + "\tpi.registerTool({", + "\t\tname,", + "\t\tlabel,", + "\t\tdescription,", + "\t\tparameters,", + `\t\texecute: (${input.execute}),`, + "\t});", + "}", + "", + ].join("\n"); +} diff --git a/packages/cli/src/extensions/compat/README.md b/packages/cli/src/extensions/compat/README.md new file mode 100644 index 00000000..59e65569 --- /dev/null +++ b/packages/cli/src/extensions/compat/README.md @@ -0,0 +1,15 @@ +# AgentHarness extension compatibility + +Pi currently binds extensions to `AgentSession`, not `AgentHarness`. This directory +contains the temporary adapter used by the CUA CLI: + +- `context.ts` implements Pi extension context actions over the harness. +- `hooks.ts` forwards harness hooks and lifecycle events to `ExtensionRunner`. +- `tool-registry.ts` reconciles extension tools with CUA tool/model changes. +- `host.ts` owns discovery, lifecycle, and manual reload. + +Pi's `packages/agent/docs/hooks.md` designs generic `AgentHarness` hooks and its +`packages/agent/docs/agent-harness.md` plans a later coding-agent migration onto +that hook/session facade. When those APIs are public, replace this directory from +`../setup.ts`; `../add-tool.ts` and its standard Pi extension artifacts should not +need to move with it. diff --git a/packages/cli/src/extensions/compat/context.ts b/packages/cli/src/extensions/compat/context.ts new file mode 100644 index 00000000..3de8dbb8 --- /dev/null +++ b/packages/cli/src/extensions/compat/context.ts @@ -0,0 +1,168 @@ +import type { AgentHarness, Session } from "@onkernel/cua-agent"; +import { + createSyntheticSourceInfo, + type ExtensionActions, + type ExtensionCommandContextActions, + type ExtensionContextActions, + type ToolInfo, +} from "@earendil-works/pi-coding-agent"; + +/** + * Read/write bridge between the runner's `ExtensionActions` (pi.* API surface) + * and the harness + the `Session` the harness was constructed with. + * + * Session entry writes are async on the harness `Session` but the action + * handlers are synchronous, so the appends are fired without awaiting. The + * runner only needs the call to be enqueued, matching how pi forwards these to + * its own session. + */ +export interface HarnessContextBindings { + refreshTools: () => void; + getActiveTools: () => string[]; + /** Forward user text through the host's first-turn screenshot prompt path. */ + sendUserMessage: (text: string) => Promise; + /** Apply an active-tool set, recording extension-tool opt-outs in the host. */ + setActiveTools: (names: string[]) => Promise; + /** Synchronous mirror of the session name (kept because the action getter is sync). */ + getSessionName: () => string | undefined; + /** Record the latest session name set through the action surface. */ + setSessionName: (name: string | undefined) => void; +} + +export function makeExtensionActions( + harness: AgentHarness, + session: Session, + bindings: HarnessContextBindings, +): ExtensionActions { + return { + sendMessage(message): void { + void session.appendCustomMessageEntry( + message.customType, + message.content, + message.display ?? true, + message.details, + ); + }, + sendUserMessage(content): void { + const text = typeof content === "string" ? content : textPartsOf(content); + void bindings.sendUserMessage(text); + }, + appendEntry(customType, data): void { + void session.appendCustomEntry(customType, data); + }, + setSessionName(name): void { + bindings.setSessionName(name); + void session.appendSessionName(name); + }, + getSessionName(): string | undefined { + return bindings.getSessionName(); + }, + // Labels are a TUI-only affordance (entry bookmarking); no headless sink. + setLabel(): void {}, + getActiveTools(): string[] { + return bindings.getActiveTools(); + }, + getAllTools(): ToolInfo[] { + return harness.getTools().map( + (tool): ToolInfo => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + promptGuidelines: undefined, + sourceInfo: createSyntheticSourceInfo(tool.name, { source: "harness" }), + }), + ); + }, + setActiveTools(names): void { + void bindings.setActiveTools(names); + }, + refreshTools(): void { + bindings.refreshTools(); + }, + // Slash commands are Tier B; none are surfaced from this host. + getCommands(): never[] { + return []; + }, + async setModel(model): Promise { + await harness.setModel(model); + return true; + }, + getThinkingLevel() { + return harness.getThinkingLevel(); + }, + setThinkingLevel(level): void { + void harness.setThinkingLevel(level); + }, + }; +} + +export function makeExtensionContextActions( + harness: AgentHarness, + state: { isIdle: () => boolean; getSignal: () => AbortSignal | undefined; shutdown: () => void }, +): ExtensionContextActions { + return { + getModel() { + return harness.getModel(); + }, + isIdle() { + return state.isIdle(); + }, + // Headless host trusts its cwd; project-trust prompts are a TUI concern. + isProjectTrusted(): boolean { + return true; + }, + getSignal() { + return state.getSignal(); + }, + abort(): void { + void harness.abort(); + }, + hasPendingMessages(): boolean { + return false; + }, + shutdown(): void { + state.shutdown(); + }, + getContextUsage() { + return undefined; + }, + compact(options): void { + void harness.compact(options?.customInstructions); + }, + getSystemPrompt(): string { + return ""; + }, + }; +} + +export function makeExtensionCommandContextActions( + harness: AgentHarness, + reload: () => Promise, +): ExtensionCommandContextActions { + return { + waitForIdle() { + return harness.waitForIdle(); + }, + async navigateTree(targetId, options) { + const result = await harness.navigateTree(targetId, options); + return { cancelled: result.cancelled }; + }, + reload, + newSession(): never { + throw new Error("newSession is unsupported on this harness host"); + }, + fork(): never { + throw new Error("fork is unsupported on this harness host"); + }, + switchSession(): never { + throw new Error("switchSession is unsupported on this harness host"); + }, + }; +} + +function textPartsOf(content: ReadonlyArray<{ type: string; text?: string }>): string { + return content + .filter((part) => part.type === "text" && typeof part.text === "string") + .map((part) => part.text ?? "") + .join(""); +} diff --git a/packages/cli/src/extensions/compat/hooks.ts b/packages/cli/src/extensions/compat/hooks.ts new file mode 100644 index 00000000..3ef7be94 --- /dev/null +++ b/packages/cli/src/extensions/compat/hooks.ts @@ -0,0 +1,184 @@ +import type { AgentHarness } from "@onkernel/cua-agent"; +import type { ExtensionRunner } from "@earendil-works/pi-coding-agent"; + +/** + * State updated while forwarding harness hooks into the extension runner. + * `turnIndex` is host-owned because the harness `turn_start`/`turn_end` events + * do not carry one, while the extension `TurnStartEvent`/`TurnEndEvent` require + * it. `isIdle` is derived from `agent_start`/`agent_end` because the harness has + * no synchronous idle predicate. + */ +export interface ExtensionHookState { + turnIndex: number; + isIdle: boolean; +} + +/** + * Wire harness events into the runner's extension-event emitters and return a + * single teardown function that detaches every listener. + * + * Two channels are used, matching how the harness dispatches: + * - `subscribe()` (catch-all) for loop events and own observe-only events. + * Tool reconciliation also runs here after `model_update`. + * - `on(type)` for the participating own events whose reduced result the + * harness applies (context, before_provider_payload, tool_call, tool_result). + */ +export function installExtensionHooks( + harness: AgentHarness, + runner: ExtensionRunner, + state: ExtensionHookState, + reapplyTools: () => Promise, +): () => void { + const unsubscribes: Array<() => void> = []; + + unsubscribes.push( + harness.subscribe(async (event) => { + switch (event.type) { + case "agent_start": + state.turnIndex = 0; + state.isIdle = false; + await runner.emit({ type: "agent_start" }); + break; + case "agent_end": + state.isIdle = true; + await runner.emit({ type: "agent_end", messages: event.messages }); + break; + case "turn_start": + await runner.emit({ type: "turn_start", turnIndex: state.turnIndex, timestamp: Date.now() }); + break; + case "turn_end": + await runner.emit({ + type: "turn_end", + turnIndex: state.turnIndex, + message: event.message, + toolResults: event.toolResults, + }); + state.turnIndex += 1; + break; + case "message_start": + await runner.emit({ type: "message_start", message: event.message }); + break; + case "message_update": + await runner.emit({ + type: "message_update", + message: event.message, + assistantMessageEvent: event.assistantMessageEvent, + }); + break; + case "tool_execution_start": + await runner.emit({ + type: "tool_execution_start", + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }); + break; + case "tool_execution_update": + await runner.emit({ + type: "tool_execution_update", + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + partialResult: event.partialResult, + }); + break; + case "tool_execution_end": + await runner.emit({ + type: "tool_execution_end", + toolCallId: event.toolCallId, + toolName: event.toolName, + result: event.result, + isError: event.isError, + }); + break; + case "model_update": + await runner.emit({ + type: "model_select", + model: event.model, + previousModel: event.previousModel, + source: event.source, + }); + // CuaAgentHarness.setModel rebuilds the tool list from + // construction-time tools, dropping runtime-registered extension + // tools. Re-apply the union after the rebuild has emitted + // model_update. setModel emits only model_update (never + // tools_update), so this triggers exactly one tools_update. + await reapplyTools(); + break; + case "thinking_level_update": + await runner.emit({ + type: "thinking_level_select", + level: event.level, + previousLevel: event.previousLevel, + }); + break; + case "after_provider_response": + await runner.emit({ + type: "after_provider_response", + status: event.status, + headers: event.headers, + }); + break; + default: + break; + } + }), + ); + + unsubscribes.push( + harness.on("context", async (event) => { + const messages = await runner.emitContext(event.messages); + return { messages }; + }), + ); + + unsubscribes.push( + harness.on("before_provider_payload", async (event) => { + const payload = await runner.emitBeforeProviderRequest(event.payload); + return { payload }; + }), + ); + + unsubscribes.push( + harness.on("tool_call", async (event) => { + if (!runner.hasHandlers("tool_call")) return undefined; + // Pass event.input by reference: tool_call handlers mutate it in place + // to patch arguments, and the harness re-reads the same object. + const result = await runner.emitToolCall({ + type: "tool_call", + toolName: event.toolName, + toolCallId: event.toolCallId, + input: event.input, + }); + return result ? { block: result.block, reason: result.reason } : undefined; + }), + ); + + unsubscribes.push( + harness.on("tool_result", async (event) => { + // Registration during tool execution must reach the harness before the + // next provider turn is prepared. + await reapplyTools(); + if (!runner.hasHandlers("tool_result")) return undefined; + const result = await runner.emitToolResult({ + type: "tool_result", + toolName: event.toolName, + toolCallId: event.toolCallId, + input: event.input, + content: event.content, + details: event.details, + isError: event.isError, + }); + if (!result) return undefined; + return { + content: result.content, + details: result.details, + isError: result.isError ?? event.isError, + }; + }), + ); + + return () => { + for (const unsubscribe of unsubscribes) unsubscribe(); + }; +} diff --git a/packages/cli/src/extensions/compat/host.ts b/packages/cli/src/extensions/compat/host.ts new file mode 100644 index 00000000..a9b9c024 --- /dev/null +++ b/packages/cli/src/extensions/compat/host.ts @@ -0,0 +1,358 @@ +import type { AgentHarness, AgentTool, Session } from "@onkernel/cua-agent"; +import type { ImageContent } from "@onkernel/cua-ai"; +import { + AuthStorage, + discoverAndLoadExtensions, + ExtensionRunner, + ModelRegistry, + SessionManager, + wrapRegisteredTool, +} from "@earendil-works/pi-coding-agent"; +import type { + ExtensionActions, + ExtensionCommandContextActions, + ExtensionContextActions, +} from "@earendil-works/pi-coding-agent"; +import { createAddToolRegistration } from "../add-tool"; +import { + makeExtensionActions, + makeExtensionCommandContextActions, + makeExtensionContextActions, +} from "./context"; +import { installExtensionHooks, type ExtensionHookState } from "./hooks"; +import { HarnessToolRegistry } from "./tool-registry"; + +interface ModeAwareAgentHarness extends AgentHarness { + getMode?(): string; +} + +export interface HarnessExtensionHostOptions { + harness: ModeAwareAgentHarness; + /** The same `Session` the harness was constructed with; used for entry writes. */ + session: Session; + cwd: string; + /** Extension paths passed straight to `discoverAndLoadExtensions`. */ + configuredPaths: string[]; + /** Agent config dir searched for `extensions/`. Pass a temp dir to isolate from `~/.agents`. */ + agentDir?: string; + /** Capture the first-turn screenshot for extension-initiated user messages. */ + initialScreenshot?: () => Promise; + /** Expose the project-local `add_tool` extension. */ + selfExtend?: boolean; +} + +export type ReloadOutcome = "reloaded" | "coalesced" | "disposed"; + +/** + * Compatibility host for Pi extensions on `AgentHarness`. + * + * Pi plans to move coding-agent extensions onto generic AgentHarness hooks and + * facades. Keep that temporary integration under `extensions/compat`; the CLI + * depends only on the interface exported by `extensions/setup`. + */ +export class HarnessExtensionHost { + private readonly harness: ModeAwareAgentHarness; + private readonly session: Session; + private readonly cwd: string; + private readonly configuredPaths: string[]; + private readonly agentDir?: string; + private readonly initialScreenshot?: () => Promise< + ImageContent[] | undefined + >; + private readonly selfExtend: boolean; + private readonly extensionRoot: string | undefined; + private readonly sessionManager: SessionManager; + private readonly modelRegistry: ModelRegistry; + private readonly tools: HarnessToolRegistry; + private readonly actions: ExtensionActions; + private readonly contextActions: ExtensionContextActions; + private readonly commandActions: ExtensionCommandContextActions; + + private runner: ExtensionRunner | undefined; + private teardownHooks: (() => void) | undefined; + private readonly hookState: ExtensionHookState = { turnIndex: 0, isIdle: true }; + private reloadPromise: Promise | undefined; + private shutdownRequested = false; + private disposed = false; + private teardownDone = false; + private loaded = false; + private startedUp = false; + private sessionName: string | undefined; + + loadErrors: Array<{ path: string; error: string }> = []; + + constructor(options: HarnessExtensionHostOptions) { + this.harness = options.harness; + this.session = options.session; + this.cwd = options.cwd; + this.configuredPaths = options.configuredPaths; + this.agentDir = options.agentDir; + this.initialScreenshot = options.initialScreenshot; + this.selfExtend = options.selfExtend ?? false; + this.extensionRoot = options.configuredPaths[0]; + this.sessionManager = SessionManager.inMemory(this.cwd); + this.modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + this.tools = new HarnessToolRegistry(this.harness, (path, error) => + this.recordError(path, error), + ); + + this.actions = makeExtensionActions(this.harness, this.session, { + refreshTools: () => this.refreshTools(), + getActiveTools: () => this.tools.desiredActiveToolNames(this.runner), + sendUserMessage: (text) => this.promptUserMessage(text), + setActiveTools: (names) => this.tools.applyActiveTools(names), + getSessionName: () => this.sessionName, + setSessionName: (name) => { + this.sessionName = name; + }, + }); + this.contextActions = makeExtensionContextActions(this.harness, { + isIdle: () => this.hookState.isIdle, + getSignal: () => undefined, + shutdown: () => this.requestShutdown(), + }); + this.commandActions = makeExtensionCommandContextActions( + this.harness, + async () => { + await this.reload(); + }, + ); + } + + isDisposed(): boolean { + return this.disposed; + } + + async load(): Promise { + if (this.disposed) throw new Error("cannot load a disposed extension host"); + if (this.loaded) return; + await this.buildRunner(); + try { + const runner = this.runner!; + await this.tools.reapply(runner, () => runner === this.runner); + this.installHooks(); + await runner.emit({ type: "session_start", reason: "startup" }); + } catch (error) { + this.uninstallHooks(); + this.runner?.invalidate(); + this.runner = undefined; + throw error; + } + this.loaded = true; + this.startedUp = true; + if (this.shutdownRequested) await this.dispose(); + } + + reload(): Promise { + if (this.disposed) return Promise.resolve("disposed"); + if (this.reloadPromise) return Promise.resolve("coalesced"); + const operation = this.reloadNow(); + this.reloadPromise = operation; + const clear = () => { + if (this.reloadPromise === operation) this.reloadPromise = undefined; + }; + void operation.then(clear, clear); + return operation; + } + + async dispose(): Promise { + if (this.teardownDone) return; + this.shutdownRequested = true; + this.disposed = true; + await this.reloadPromise?.catch(() => {}); + await this.tools.waitForSettled(); + await this.disposeNow(); + } + + private async reloadNow(): Promise { + await this.harness.waitForIdle(); + if (this.disposed) return "disposed"; + + const previousRunner = this.runner; + const previousLoadErrors = this.loadErrors; + const flags = previousRunner?.getFlagValues() ?? new Map(); + const candidate = await this.createRunner(); + if (this.disposed) { + candidate.runner.invalidate(); + return "disposed"; + } + for (const [name, value] of flags) candidate.runner.setFlagValue(name, value); + + await previousRunner?.emit({ type: "session_shutdown", reason: "reload" }); + if (this.disposed || this.shutdownRequested) { + candidate.runner.invalidate(); + await this.disposeNow(); + return "disposed"; + } + + this.uninstallHooks(); + this.runner = candidate.runner; + this.loadErrors = candidate.loadErrors; + const registrySnapshot = this.tools.beginRunnerReplacement(candidate.hostTool); + try { + await this.tools.reapply( + candidate.runner, + () => candidate.runner === this.runner && !this.disposed, + ); + if (this.disposed || this.shutdownRequested) { + candidate.runner.invalidate(); + previousRunner?.invalidate(); + await this.disposeNow(); + return "disposed"; + } + this.installHooks(); + await candidate.runner.emit({ type: "session_start", reason: "reload" }); + if (this.disposed || this.shutdownRequested) { + previousRunner?.invalidate(); + await this.disposeNow(); + return "disposed"; + } + previousRunner?.invalidate(); + return "reloaded"; + } catch (error) { + candidate.runner.invalidate(); + this.uninstallHooks(); + this.runner = previousRunner; + this.loadErrors = previousLoadErrors; + this.tools.restore(registrySnapshot); + if (!this.disposed && previousRunner) { + await this.tools + .reapply(previousRunner, () => previousRunner === this.runner) + .catch(() => {}); + this.installHooks(); + await previousRunner.emit({ type: "session_start", reason: "reload" }); + } + throw error; + } + } + + private async disposeNow(): Promise { + if (this.teardownDone) return; + this.teardownDone = true; + this.shutdownRequested = true; + this.disposed = true; + this.uninstallHooks(); + await this.tools.removeAll().catch(() => {}); + const runner = this.runner; + await runner?.emit({ type: "session_shutdown", reason: "quit" }); + runner?.invalidate(); + this.runner = undefined; + } + + private async buildRunner(): Promise { + const built = await this.createRunner(); + this.loadErrors = built.loadErrors; + this.runner = built.runner; + this.tools.setHostTool(built.hostTool); + } + + private async createRunner(): Promise<{ + runner: ExtensionRunner; + hostTool: AgentTool | undefined; + loadErrors: Array<{ path: string; error: string }>; + }> { + const result = await discoverAndLoadExtensions( + this.configuredPaths, + this.cwd, + this.agentDir, + ); + const runner = new ExtensionRunner( + result.extensions, + result.runtime, + this.cwd, + this.sessionManager, + this.modelRegistry, + ); + runner.bindCore(this.actions, this.contextActions); + runner.bindCommandContext(this.commandActions); + runner.setUIContext(undefined, "print"); + + const hostTool = this.selfExtend + ? wrapRegisteredTool( + createAddToolRegistration({ + cwd: this.cwd, + extensionRoot: this.extensionRoot, + hasToolName: (name) => this.tools.hasToolName(name), + installTool: (registration) => + this.tools.installRuntimeTool( + registration, + runner, + () => runner === this.runner && !this.disposed, + ), + }), + runner, + ) + : undefined; + return { runner, hostTool, loadErrors: result.errors }; + } + + private refreshTools(): void { + const runner = this.runner; + if (!runner) return; + void this.tools + .reapply(runner, () => runner === this.runner && !this.disposed) + .catch((error) => + this.recordError( + "", + error instanceof Error ? error.message : String(error), + ), + ); + } + + private installHooks(): void { + const runner = this.runner; + if (!runner) return; + this.teardownHooks = installExtensionHooks( + this.harness, + runner, + this.hookState, + () => this.tools.reapply(runner, () => runner === this.runner && !this.disposed), + ); + } + + private uninstallHooks(): void { + const teardown = this.teardownHooks; + this.teardownHooks = undefined; + teardown?.(); + } + + private recordError(path: string, error: string): void { + if (this.loadErrors.some((entry) => entry.path === path && entry.error === error)) { + return; + } + this.loadErrors.push({ path, error }); + } + + private requestShutdown(): void { + this.shutdownRequested = true; + if (!this.reloadPromise) void this.dispose(); + } + + private async promptUserMessage(text: string): Promise { + try { + const images = await this.maybeInitialScreenshot(); + await this.harness.prompt(text, images ? { images } : undefined); + } catch (error) { + this.recordError( + "", + error instanceof Error ? error.message : String(error), + ); + } + } + + private async maybeInitialScreenshot(): Promise { + if (!this.initialScreenshot || !this.startedUp) return undefined; + if (this.harness.getMode?.() === "browser") return undefined; + if (await sessionHasPriorTurn(this.session)) return undefined; + return this.initialScreenshot(); + } +} + +async function sessionHasPriorTurn(session: Session): Promise { + const entries = await session.getBranch(); + return entries.some( + (entry) => + entry.type === "message" && + (entry.message.role === "user" || entry.message.role === "assistant"), + ); +} diff --git a/packages/cli/src/extensions/compat/tool-registry.ts b/packages/cli/src/extensions/compat/tool-registry.ts new file mode 100644 index 00000000..0517c98b --- /dev/null +++ b/packages/cli/src/extensions/compat/tool-registry.ts @@ -0,0 +1,211 @@ +import type { AgentHarness, AgentTool } from "@onkernel/cua-agent"; +import { + type ExtensionRunner, + type RegisteredTool, + wrapRegisteredTool, + wrapRegisteredTools, +} from "@earendil-works/pi-coding-agent"; + +interface RegistrySnapshot { + hostTool: AgentTool | undefined; + extensionTools: AgentTool[]; + runtimeTools: Map; + inactiveExtensionTools: Set; +} + +/** Tool registry kept beside hooks, matching Pi's planned harness split. */ +export class HarnessToolRegistry { + private hostTool: AgentTool | undefined; + private extensionTools: AgentTool[] = []; + private runtimeTools = new Map(); + private readonly inactiveExtensionTools = new Set(); + private update = Promise.resolve(); + + constructor( + private readonly harness: AgentHarness, + private readonly recordError: (path: string, error: string) => void, + ) {} + + setHostTool(tool: AgentTool | undefined): void { + this.hostTool = tool; + } + + hasToolName(name: string): boolean { + return ( + this.harness.getTools().some((tool) => tool.name === name) || + this.runtimeTools.has(name) + ); + } + + beginRunnerReplacement(hostTool: AgentTool | undefined): RegistrySnapshot { + const snapshot = this.snapshot(); + this.hostTool = hostTool; + this.extensionTools = [ + ...this.extensionTools, + ...this.runtimeTools.values(), + ]; + this.runtimeTools.clear(); + this.inactiveExtensionTools.clear(); + return snapshot; + } + + restore(snapshot: RegistrySnapshot): void { + this.hostTool = snapshot.hostTool; + this.extensionTools = snapshot.extensionTools; + this.runtimeTools = new Map(snapshot.runtimeTools); + this.inactiveExtensionTools.clear(); + for (const name of snapshot.inactiveExtensionTools) { + this.inactiveExtensionTools.add(name); + } + } + + async installRuntimeTool( + registration: RegisteredTool, + runner: ExtensionRunner, + isCurrent: () => boolean, + ): Promise { + const name = registration.definition.name; + this.runtimeTools.set(name, wrapRegisteredTool(registration, runner)); + try { + await this.reapply(runner, isCurrent); + } catch (error) { + this.runtimeTools.delete(name); + await this.reapply(runner, isCurrent).catch(() => {}); + throw error; + } + } + + reapply( + runner: ExtensionRunner, + isCurrent: () => boolean, + ): Promise { + const next = this.update.then(() => this.applyNow(runner, isCurrent)); + this.update = next.catch(() => {}); + return next; + } + + async waitForSettled(): Promise { + await this.update.catch(() => {}); + } + + async removeAll(): Promise { + const merged = new Set(this.ownedTools().map((tool) => tool.name)); + if (merged.size === 0) return; + await this.mutateHarnessTools(async () => { + const base = this.harness + .getTools() + .filter((tool) => !merged.has(tool.name)); + const active = this.harness + .getActiveTools() + .map((tool) => tool.name) + .filter((name) => !merged.has(name)); + await this.harness.setTools(base, active); + }); + } + + desiredActiveToolNames(runner: ExtensionRunner | undefined): string[] { + const current = new Set( + this.harness.getActiveTools().map((tool) => tool.name), + ); + if (this.hostTool) current.add(this.hostTool.name); + for (const tool of [...this.extensionTools, ...this.runtimeTools.values()]) { + if (!this.inactiveExtensionTools.has(tool.name)) current.add(tool.name); + } + for (const registered of runner?.getAllRegisteredTools() ?? []) { + if (!this.inactiveExtensionTools.has(registered.definition.name)) { + current.add(registered.definition.name); + } + } + return [...current]; + } + + async applyActiveTools(names: string[]): Promise { + await this.mutateHarnessTools(async () => { + const active = new Set(names); + for (const tool of [...this.extensionTools, ...this.runtimeTools.values()]) { + if (active.has(tool.name)) this.inactiveExtensionTools.delete(tool.name); + else this.inactiveExtensionTools.add(tool.name); + } + await this.harness.setActiveTools(names); + }); + } + + private snapshot(): RegistrySnapshot { + return { + hostTool: this.hostTool, + extensionTools: this.extensionTools, + runtimeTools: new Map(this.runtimeTools), + inactiveExtensionTools: new Set(this.inactiveExtensionTools), + }; + } + + private ownedTools(): AgentTool[] { + return [this.hostTool, ...this.extensionTools, ...this.runtimeTools.values()].filter( + (tool): tool is AgentTool => tool !== undefined, + ); + } + + private async applyNow( + runner: ExtensionRunner, + isCurrent: () => boolean, + ): Promise { + if (!isCurrent()) return; + await this.mutateHarnessTools(async () => { + if (!isCurrent()) return; + const ownedBefore = new Set(this.ownedTools().map((tool) => tool.name)); + const base = this.harness + .getTools() + .filter((tool) => !ownedBefore.has(tool.name)); + const baseNames = new Set(base.map((tool) => tool.name)); + const reservedNames = new Set([ + ...(this.hostTool ? [this.hostTool.name] : []), + ...this.runtimeTools.keys(), + ]); + const diskTools = wrapRegisteredTools( + runner.getAllRegisteredTools(), + runner, + ).filter((tool) => { + const collidesWith = reservedNames.has(tool.name) + ? "a host-provided tool" + : baseNames.has(tool.name) + ? "a built-in tool" + : undefined; + if (!collidesWith) return true; + this.recordError( + tool.name, + `extension tool "${tool.name}" collides with ${collidesWith} and was dropped`, + ); + return false; + }); + const runtimeTools = [...this.runtimeTools.values()]; + const final = [ + ...base, + ...(this.hostTool ? [this.hostTool] : []), + ...diskTools, + ...runtimeTools, + ]; + const finalNames = new Set(final.map((tool) => tool.name)); + const activeNames = new Set( + this.harness + .getActiveTools() + .map((tool) => tool.name) + .filter((name) => finalNames.has(name)), + ); + if (this.hostTool) activeNames.add(this.hostTool.name); + for (const tool of [...diskTools, ...runtimeTools]) { + if (!this.inactiveExtensionTools.has(tool.name)) { + activeNames.add(tool.name); + } + } + await this.harness.setTools(final, [...activeNames]); + this.extensionTools = diskTools; + }); + } + + private mutateHarnessTools(mutation: () => Promise): Promise { + const harness = this.harness as AgentHarness & { + mutateTools?: (callback: () => Promise) => Promise; + }; + return harness.mutateTools ? harness.mutateTools(mutation) : mutation(); + } +} diff --git a/packages/cli/src/extensions/setup.ts b/packages/cli/src/extensions/setup.ts new file mode 100644 index 00000000..89129c6d --- /dev/null +++ b/packages/cli/src/extensions/setup.ts @@ -0,0 +1,54 @@ +import type { CuaAgentHarness, Session } from "@onkernel/cua-agent"; +import type { ImageContent } from "@onkernel/cua-ai"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { join } from "node:path"; +import { + HarnessExtensionHost, + type ReloadOutcome, +} from "./compat/host"; + +/** Stable CLI-facing surface; the AgentHarness compatibility host stays private. */ +export interface HarnessExtensions { + readonly loadErrors: Array<{ path: string; error: string }>; + isDisposed(): boolean; + reload(): Promise; + dispose(): Promise; +} + +/** + * Resolve extension directories and construct + load a {@link HarnessExtensionHost}. + * + * Global extensions (`/extensions`) and project-local extensions + * (`/.agents/extensions` plus the loader's implicit `/.pi/extensions` + * scan) all load on every run; `--no-extensions` opts out entirely. This is the + * substrate for durable project tools: `add_tool` publishes beneath the project + * extension directory, and later processes discover those artifacts normally. + * + * No browser/auth/provisioning happens here, so a test can drive the exact load + * path the CLI uses with a `buildTestHarness` fixture and temp dirs. + */ +export async function loadHarnessExtensions(args: { + harness: CuaAgentHarness; + session: Session; + cwd: string; + noExtensions: boolean; + agentDir?: string; + configuredPaths?: string[]; + initialScreenshot?: () => Promise; + selfExtend?: boolean; +}): Promise { + if (args.noExtensions) return undefined; + const agentDir = args.agentDir ?? getAgentDir(); + const configuredPaths = args.configuredPaths ?? [join(args.cwd, ".agents", "extensions")]; + const host = new HarnessExtensionHost({ + harness: args.harness, + session: args.session, + cwd: args.cwd, + configuredPaths, + agentDir, + initialScreenshot: args.initialScreenshot, + selfExtend: args.selfExtend, + }); + await host.load(); + return host; +} diff --git a/packages/cli/src/print.ts b/packages/cli/src/print.ts index 40275419..a9db2572 100644 --- a/packages/cli/src/print.ts +++ b/packages/cli/src/print.ts @@ -93,12 +93,12 @@ export async function runPrint(opts: RunPrintOptions): Promise { } async function maybeInitialScreenshot(opts: RunPrintOptions): Promise { + // `skipInitialScreenshot` is decided once at startup (before extensions load), + // so an extension's startup message can't suppress the user's first-turn frame. if (opts.skipInitialScreenshot) return undefined; // Browser mode's only frame is the viewport; skip the OS-display capture // rather than mix coordinate frames on the first turn. if (opts.harness.getMode() === "browser") return undefined; - const hasPriorTurn = await sessionHasPriorTurn(opts.session); - if (hasPriorTurn) return undefined; const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id); if (!png) return undefined; return [ @@ -109,13 +109,3 @@ async function maybeInitialScreenshot(opts: RunPrintOptions): Promise { - const entries = await session.getBranch(); - for (const entry of entries) { - if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) { - return true; - } - } - return false; -} diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts index fffbf3cd..ce924494 100644 --- a/packages/cli/src/tui/main.ts +++ b/packages/cli/src/tui/main.ts @@ -24,6 +24,7 @@ import { initTheme } from "@earendil-works/pi-coding-agent"; import { homedir } from "node:os"; import type { ImageContent, Model } from "@onkernel/cua-ai"; import { captureScreenshot, type CuaBrowserHandle } from "../harness-browser"; +import type { HarnessExtensions } from "../extensions/setup"; import { resolveCuaModelRef } from "../harness-models"; import { updateNamedSessionRuntime } from "../harness-named-sessions"; import type { ContextFile } from "../harness-skills"; @@ -61,6 +62,8 @@ export interface InteractiveOptions { namedSession?: string; /** Enable extra TUI render diagnostics for manual repros. */ debugTui?: boolean; + /** Loaded pi-extension host for /reload. Absent in fixture/headless and --no-extensions/untrusted paths, so /reload no-ops with a notice. */ + host?: HarnessExtensions; } /** @@ -318,6 +321,11 @@ export async function runInteractive(opts: InteractiveOptions): Promise await applyCompactCommand(opts, messages); return; } + if (parsed?.command === "reload") { + await applyReloadCommand(opts, messages); + requestRender("reload"); + return; + } if (parsed?.command === "skill") { const skill = (opts.skills ?? []).find((s) => s.name === parsed.name); if (!skill) { @@ -449,26 +457,17 @@ async function maybeInitialScreenshot( firstPromptSent: boolean, ): Promise { if (firstPromptSent) return undefined; + // `skipInitialScreenshot` is decided once at startup (before extensions load), + // so an extension's startup message can't suppress the user's first-turn frame. if (opts.skipInitialScreenshot) return undefined; // Browser mode's only frame is the viewport; skip the OS-display capture // rather than mix coordinate frames on the first turn. if (opts.harness.getMode() === "browser") return undefined; - if (await sessionHasPriorTurn(opts.session)) return undefined; const png = await captureScreenshot(opts.browserHandle.client, opts.browserHandle.browser.session_id); if (!png) return undefined; return [{ type: "image", data: png.toString("base64"), mimeType: "image/png" }]; } -async function sessionHasPriorTurn(session: Session): Promise { - const entries = await session.getBranch(); - for (const entry of entries) { - if (entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant")) { - return true; - } - } - return false; -} - async function applyModelCommand( opts: InteractiveOptions, footer: TelemetryFooter, @@ -567,6 +566,33 @@ async function applyCompactCommand(opts: InteractiveOptions, messages: MessageLi } } +export async function applyReloadCommand(opts: InteractiveOptions, messages: MessageList): Promise { + if (!opts.host) { + messages.addNotice("extensions are disabled"); + return; + } + messages.addNotice("reloading extensions…"); + try { + // reload() emits no harness event, so this helper is the only source of + // feedback; surface loadErrors so a broken edited extension isn't silently + // dropped with its tool missing. + const outcome = await opts.host.reload(); + if (outcome === "disposed" || opts.host.isDisposed()) { + // An extension calling ctx.shutdown() during the reload tears the host + // down; don't claim a successful reload. + messages.addNotice("session is shutting down; extensions were not reloaded"); + } else if (outcome === "coalesced") { + messages.addNotice("a reload is already in progress"); + } else if (opts.host.loadErrors.length > 0) { + for (const { path, error } of opts.host.loadErrors) messages.addError(`${path}: ${error}`); + } else { + messages.addNotice("extensions reloaded"); + } + } catch (err) { + messages.addError((err as Error).message); + } +} + async function refreshContextTokens(session: Session): Promise { const context = await session.buildContext(); return estimateContextTokens(context.messages).tokens; diff --git a/packages/cli/src/tui/slash-commands.ts b/packages/cli/src/tui/slash-commands.ts index fe83941f..423ca440 100644 --- a/packages/cli/src/tui/slash-commands.ts +++ b/packages/cli/src/tui/slash-commands.ts @@ -8,8 +8,8 @@ import { listCuaModels } from "@onkernel/cua-ai"; /** * Build an autocomplete provider for the TUI editor with the slash commands - * the interactive app supports: `/model`, `/thinking`, `/compact`, plus a - * `/skill:` entry per loaded skill. + * the interactive app supports: `/model`, `/thinking`, `/compact`, `/reload`, + * plus a `/skill:` entry per loaded skill. * * Model and thinking values are exposed as `getArgumentCompletions` so * users can tab through CUA refs and reasoning levels. @@ -46,6 +46,11 @@ export function buildAutocompleteProvider( description: "Summarize older turns to free context budget", }); + commands.push({ + name: "reload", + description: "Reload pi extensions from disk", + }); + for (const skill of skills) { commands.push({ name: `skill:${skill.name}`, @@ -97,6 +102,7 @@ export type ParsedSlashCommand = | { command: "mode"; argument: string } | { command: "thinking"; argument: string } | { command: "compact"; argument: string } + | { command: "reload"; argument: string } | { command: "skill"; name: string; remainder: string }; /** @@ -111,11 +117,11 @@ export function parseSlashCommand(text: string): ParsedSlashCommand | undefined const [, name, rest] = skillMatch; return { command: "skill", name: name ?? "", remainder: (rest ?? "").trim() }; } - const builtinMatch = trimmed.match(/^\/(model|mode|thinking|compact)\s*(.*)$/); + const builtinMatch = trimmed.match(/^\/(model|mode|thinking|compact|reload)\s*(.*)$/); if (builtinMatch) { const [, name, rest] = builtinMatch; return { - command: name as "model" | "mode" | "thinking" | "compact", + command: name as "model" | "mode" | "thinking" | "compact" | "reload", argument: (rest ?? "").trim(), }; } diff --git a/packages/cli/test/action-runner.test.ts b/packages/cli/test/action-runner.test.ts index 333c87b8..a640c7ec 100644 --- a/packages/cli/test/action-runner.test.ts +++ b/packages/cli/test/action-runner.test.ts @@ -129,6 +129,26 @@ describe("action harness-runner", () => { expect(abortCalls).toBeGreaterThanOrEqual(1); }); + it("skips the display screenshot in browser mode", async () => { + fixture = await buildTestHarness({ + turns: [{ steps: [{ type: "text", text: "ok" }] }], + }); + await fixture.harness.setMode("browser"); + + await runAction( + { action: "do", text: "look" }, + { harness: fixture.harness, browserHandle: handleFor(fixture), session: fixture.session, maxTurns: 3 }, + ); + + expect(fixture.kernel.screenshots).toBe(0); + const entries = await fixture.session.getBranch(); + const firstUser = entries.find((e) => e.type === "message" && e.message.role === "user"); + const content = (firstUser as { message: { content: unknown[] } }).message.content as Array<{ + type: string; + }>; + expect(content.some((item) => item.type === "image")).toBe(false); + }); + it("attaches a screenshot to the first user message on a fresh session", async () => { fixture = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }], diff --git a/packages/cli/test/add-tool.test.ts b/packages/cli/test/add-tool.test.ts new file mode 100644 index 00000000..5364b478 --- /dev/null +++ b/packages/cli/test/add-tool.test.ts @@ -0,0 +1,347 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + existsSync, + mkdtempSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { + renderToolExtension, + type AddToolInput, +} from "../src/extensions/add-tool"; +import { HarnessExtensionHost } from "../src/extensions/compat/host"; +import { loadHarnessExtensions } from "../src/extensions/setup"; +import { buildTestHarness, type TestHarnessFixture } from "./fixtures/harness"; + +const parameters = { + type: "object", + properties: {}, + additionalProperties: false, +}; + +function definition(name: string, text = "authored ok"): AddToolInput { + return { + name, + description: `${name} description`, + parameters, + execute: `async (_toolCallId, _params, _signal, _onUpdate) => ({ content: [{ type: "text", text: ${JSON.stringify(text)} }], details: {} })`, + }; +} + +let fx: TestHarnessFixture | undefined; +let host: HarnessExtensionHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + fx = undefined; +}); + +async function setup( + options: { + selfExtend?: boolean; + extDir?: string; + turns?: Parameters[0]["turns"]; + } = {}, +) { + fx = await buildTestHarness({ + turns: options.turns ?? [{ steps: [{ type: "text", text: "ok" }] }], + }); + const extDir = options.extDir ?? mkdtempSync(join(tmpdir(), "cua-ext-")); + host = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [extDir], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + selfExtend: options.selfExtend ?? true, + }); + await host.load(); + return { fx, host, extDir }; +} + +function names(fixture: TestHarnessFixture): string[] { + return fixture.harness.getTools().map((tool) => tool.name); +} + +async function callAddTool(fixture: TestHarnessFixture, input: AddToolInput) { + const tool = fixture.harness + .getTools() + .find((candidate) => candidate.name === "add_tool"); + if (!tool) throw new Error("add_tool is not registered"); + return tool.execute("add-call", input, new AbortController().signal); +} + +describe("add_tool", () => { + it("is opt-in and remains the sole owner of its reserved name", async () => { + const enabled = await setup(); + expect( + names(enabled.fx).filter((name) => name === "add_tool"), + ).toHaveLength(1); + expect( + enabled.fx.harness.getActiveTools().map((tool) => tool.name), + ).toContain("add_tool"); + await host?.dispose(); + host = undefined; + + const disabledFx = await buildTestHarness({ + turns: [{ steps: [{ type: "text", text: "ok" }] }], + }); + const disabled = await loadHarnessExtensions({ + harness: disabledFx.harness, + session: disabledFx.session, + cwd: disabledFx.cwd, + noExtensions: false, + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + configuredPaths: [mkdtempSync(join(tmpdir(), "cua-ext-"))], + }); + expect( + disabledFx.harness.getTools().map((tool) => tool.name), + ).not.toContain("add_tool"); + await disabled?.dispose(); + }); + + it("drops a disk extension that collides with the loader", async () => { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync( + join(extDir, "collision.ts"), + renderToolExtension({ ...definition("add_tool"), label: "add_tool" }), + ); + const { fx: fixture, host: created } = await setup({ extDir }); + expect(names(fixture).filter((name) => name === "add_tool")).toHaveLength( + 1, + ); + expect(created.loadErrors.some((entry) => entry.path === "add_tool")).toBe( + true, + ); + }); + + it("persists and activates a tool", async () => { + const { fx: fixture, extDir } = await setup(); + const input = definition("sorted_tool"); + input.parameters = { + required: [], + properties: { z: { type: "string" }, a: { type: "number" } }, + type: "object", + }; + const result = await callAddTool(fixture, input); + const target = resolve(extDir, "sorted_tool.ts"); + expect(result.details).toEqual({ + written: target, + valid: true, + addedToolNames: ["sorted_tool"], + }); + expect(readFileSync(target, "utf8")).toBe( + renderToolExtension({ ...input, label: "sorted_tool" }), + ); + expect(names(fixture)).toContain("sorted_tool"); + }); + + it("activates and invokes the new tool before the run ends", async () => { + const input = definition("same_run_tool"); + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync( + join(extDir, "reduce-result.ts"), + `export default function (pi) { + pi.on("tool_result", (event) => event.toolName === "add_tool" + ? { content: [{ type: "text", text: "rewritten" }], details: { rewritten: true } } + : undefined); + }`, + ); + const { fx: fixture } = await setup({ + extDir, + turns: [ + { steps: [{ type: "tool_call", toolName: "add_tool", args: input }] }, + { steps: [{ type: "tool_call", toolName: "same_run_tool", args: {} }] }, + { steps: [{ type: "text", text: "done" }] }, + ], + }); + const trace: string[] = []; + fixture.harness.subscribe((event) => { + if (event.type === "agent_end") trace.push("agent_end"); + if ( + event.type === "tool_execution_end" && + event.toolName === "same_run_tool" && + !event.isError + ) + trace.push("same_run_tool"); + }); + await fixture.harness.prompt("add and use it"); + expect(trace).toEqual(["same_run_tool", "agent_end"]); + const secondContext = fixture.provider.contexts()[1]; + expect(secondContext.tools?.map((tool) => tool.name)).toContain( + "same_run_tool", + ); + const addResult = secondContext.messages.find( + (message) => + message.role === "toolResult" && message.toolName === "add_tool", + ); + expect(addResult?.addedToolNames).toEqual(["same_run_tool"]); + expect(addResult?.content).toEqual([ + { type: "text", text: "rewritten" }, + ]); + }); + + it("is discovered and callable in a new process", async () => { + const first = await setup(); + await callAddTool(first.fx, definition("durable_tool", "durable")); + await first.host.dispose(); + host = undefined; + + const script = ` + import { mkdtempSync } from "node:fs"; + import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { HarnessExtensionHost } from "./packages/cli/src/extensions/compat/host.ts"; + import { buildTestHarness } from "./packages/cli/test/fixtures/harness.ts"; + const fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + const host = new HarnessExtensionHost({ harness: fx.harness, session: fx.session, cwd: fx.cwd, configuredPaths: [process.env.EXT_DIR], agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")) }); + await host.load(); + const tool = fx.harness.getTools().find((item) => item.name === "durable_tool"); + if (!tool) throw new Error("durable tool was not discovered"); + const result = await tool.execute("call", {}, new AbortController().signal); + console.log(result.content[0].text); + await host.dispose(); + `; + const child = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { + cwd: resolve(import.meta.dirname, "../../.."), + env: { ...process.env, EXT_DIR: first.extDir }, + encoding: "utf8", + }, + ); + expect(child.stderr).toBe(""); + expect(child.status).toBe(0); + expect(child.stdout).toContain("durable"); + }); + + it.each([ + ["../escape", "path-like name"], + ["bad/name", "slash"], + ["bad\\name", "backslash"], + ["_leading", "non-letter prefix"], + ["a".repeat(65), "overlength"], + ])("rejects unsafe name %s (%s) without writing", async (name) => { + const { fx: fixture, extDir } = await setup(); + await expect(callAddTool(fixture, definition(name))).rejects.toThrow( + /name must/, + ); + expect(readdirSync(extDir)).toEqual([]); + }); + + it("rejects invalid schemas and execute sources without durable or live mutation", async () => { + const { fx: fixture, extDir } = await setup(); + const badSchema = definition("bad_schema"); + badSchema.parameters = { type: "string" }; + await expect(callAddTool(fixture, badSchema)).rejects.toThrow( + /top-level type/, + ); + const badExecute = definition("bad_execute"); + badExecute.execute = "() => ({ content: [], details: {} })"; + await expect(callAddTool(fixture, badExecute)).rejects.toThrow( + /async function expression/, + ); + const brokenExecute = definition("broken_execute"); + brokenExecute.execute = "async ( => ({ content: [], details: {} })"; + await expect(callAddTool(fixture, brokenExecute)).rejects.toThrow( + /validation failed/, + ); + const sequenceExecute = definition("sequence_execute"); + sequenceExecute.execute = "async () => ({ content: [], details: {} }), 42"; + await expect(callAddTool(fixture, sequenceExecute)).rejects.toThrow( + /one async function expression/, + ); + expect(readdirSync(extDir)).toEqual([]); + expect(names(fixture)).not.toEqual( + expect.arrayContaining([ + "bad_schema", + "bad_execute", + "broken_execute", + "sequence_execute", + ]), + ); + }); + + it("returns a failed tool result without ending the run", async () => { + const { fx: fixture } = await setup({ + turns: [ + { + steps: [ + { + type: "tool_call", + toolName: "add_tool", + args: definition("../invalid"), + }, + ], + }, + { steps: [{ type: "text", text: "continued" }] }, + ], + }); + await fixture.harness.prompt("try an invalid tool and continue"); + const failed = fixture.provider.contexts()[1].messages.find( + (message) => + message.role === "toolResult" && message.toolName === "add_tool", + ); + expect(failed?.isError).toBe(true); + expect(failed?.addedToolNames).toBeUndefined(); + }); + + it("rejects existing artifacts and tool-name collisions without overwriting", async () => { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + const target = join(extDir, "occupied.ts"); + writeFileSync(target, "do not replace"); + const { fx: fixture } = await setup({ extDir }); + await expect( + callAddTool(fixture, definition("occupied")), + ).rejects.toThrow(); + expect(readFileSync(target, "utf8")).toBe("do not replace"); + await expect(callAddTool(fixture, definition("add_tool"))).rejects.toThrow( + /already exists/, + ); + }); + + it("keeps current base and extension tools during a model-switch race", async () => { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync( + join(extDir, "disk.ts"), + renderToolExtension({ ...definition("disk_tool"), label: "disk_tool" }), + ); + const { fx: fixture } = await setup({ extDir }); + await Promise.all([ + callAddTool(fixture, definition("runtime_tool")), + fixture.harness.setModel("anthropic:claude-opus-4-7"), + ]); + expect(names(fixture)).toEqual( + expect.arrayContaining(["add_tool", "disk_tool", "runtime_tool"]), + ); + expect(fixture.harness.getModel().provider).toBe("anthropic"); + }); + + it("manual reload adopts the durable artifact and observes edits and removal", async () => { + const { fx: fixture, extDir, host: created } = await setup(); + await callAddTool(fixture, definition("reloadable", "first")); + const target = join(extDir, "reloadable.ts"); + writeFileSync( + target, + renderToolExtension({ + ...definition("reloadable", "second"), + label: "reloadable", + }), + ); + expect(await created.reload()).toBe("reloaded"); + expect(names(fixture)).toEqual( + expect.arrayContaining(["add_tool", "reloadable"]), + ); + await import("node:fs/promises").then(({ rm }) => rm(target)); + expect(await created.reload()).toBe("reloaded"); + expect(names(fixture)).toContain("add_tool"); + expect(names(fixture)).not.toContain("reloadable"); + expect(existsSync(target)).toBe(false); + }); +}); diff --git a/packages/cli/test/extension-loader.test.ts b/packages/cli/test/extension-loader.test.ts new file mode 100644 index 00000000..1cbb24ee --- /dev/null +++ b/packages/cli/test/extension-loader.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { + loadHarnessExtensions, + type HarnessExtensions, +} from "../src/extensions/setup"; +import { buildTestHarness, type TestHarnessFixture } from "./fixtures/harness"; + +let fx: TestHarnessFixture | undefined; +let host: HarnessExtensions | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + fx = undefined; +}); + +/** A minimal, import-free extension that registers a single named tool. */ +function makeToolExtension(toolName: string): string { + return [ + "export default function (pi) {", + " pi.registerTool({", + ` name: ${JSON.stringify(toolName)},`, + ` label: ${JSON.stringify(toolName)},`, + ` description: ${JSON.stringify(toolName)},`, + ' parameters: { type: "object", properties: {}, additionalProperties: false },', + ' async execute() { return { content: [{ type: "text", text: "ok" }], details: {} }; },', + " });", + "}", + "", + ].join("\n"); +} + +function tempAgentDir(): string { + return mkdtempSync(join(tmpdir(), "cua-agentdir-")); +} + +describe("loadHarnessExtensions", () => { + it("loads a configured extension onto the harness", async () => { + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync(join(extDir, "probe.ts"), makeToolExtension("loader_probe")); + + host = await loadHarnessExtensions({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + noExtensions: false, + agentDir: tempAgentDir(), + configuredPaths: [extDir], + }); + + expect(host).toBeDefined(); + expect(fx.harness.getTools().map((t) => t.name)).toContain("loader_probe"); + }); + + it("returns undefined when extensions are disabled", async () => { + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync(join(extDir, "probe.ts"), makeToolExtension("loader_probe")); + + host = await loadHarnessExtensions({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + noExtensions: true, + agentDir: tempAgentDir(), + configuredPaths: [extDir], + }); + + expect(host).toBeUndefined(); + expect(fx.harness.getTools().map((t) => t.name)).not.toContain("loader_probe"); + }); + + it("loads the implicit project /.pi/extensions scan by default", async () => { + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + // Unique per run so the whole-harness tool assertion can't collide with + // another worker registering the same name under pool concurrency. + const probe = `pi_probe_${randomUUID().replace(/-/g, "")}`; + const projectExtDir = join(fx.cwd, ".pi", "extensions"); + mkdirSync(projectExtDir, { recursive: true }); + writeFileSync(join(projectExtDir, "probe.ts"), makeToolExtension(probe)); + + host = await loadHarnessExtensions({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + noExtensions: false, + agentDir: tempAgentDir(), + }); + + expect(host).toBeDefined(); + expect(fx.harness.getTools().map((t) => t.name)).toContain(probe); + }); + + it("loads project /.agents/extensions by default", async () => { + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + const probe = `agents_probe_${randomUUID().replace(/-/g, "")}`; + const projectExtDir = join(fx.cwd, ".agents", "extensions"); + mkdirSync(projectExtDir, { recursive: true }); + writeFileSync(join(projectExtDir, "probe.ts"), makeToolExtension(probe)); + + host = await loadHarnessExtensions({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + noExtensions: false, + agentDir: tempAgentDir(), + }); + + expect(host).toBeDefined(); + expect(fx.harness.getTools().map((t) => t.name)).toContain(probe); + }); +}); diff --git a/packages/cli/test/extension-reload.test.ts b/packages/cli/test/extension-reload.test.ts new file mode 100644 index 00000000..abf6f799 --- /dev/null +++ b/packages/cli/test/extension-reload.test.ts @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + loadHarnessExtensions, + type HarnessExtensions, +} from "../src/extensions/setup"; +import { buildTestHarness, type TestHarnessFixture } from "./fixtures/harness"; + +let fx: TestHarnessFixture | undefined; +let host: HarnessExtensions | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + fx = undefined; +}); + +/** A minimal, import-free extension that registers a single named tool. */ +function makeToolExtension(toolName: string): string { + return [ + "export default function (pi) {", + " pi.registerTool({", + ` name: ${JSON.stringify(toolName)},`, + ` label: ${JSON.stringify(toolName)},`, + ` description: ${JSON.stringify(toolName)},`, + ' parameters: { type: "object", properties: {}, additionalProperties: false },', + ' async execute() { return { content: [{ type: "text", text: "ok" }], details: {} }; },', + " });", + "}", + "", + ].join("\n"); +} + +describe("/reload path via the real host", () => { + it("hot-swaps an edited extension and reports a clean reload", async () => { + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + const extFile = join(extDir, "learned.ts"); + writeFileSync(extFile, makeToolExtension("alpha_tool")); + + host = await loadHarnessExtensions({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + noExtensions: false, + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + configuredPaths: [extDir], + }); + expect(host).toBeDefined(); + expect(fx.harness.getTools().map((t) => t.name)).toContain("alpha_tool"); + + writeFileSync(extFile, makeToolExtension("beta_tool")); + await host!.reload(); + + const toolNames = fx.harness.getTools().map((t) => t.name); + expect(toolNames).toContain("beta_tool"); + expect(toolNames).not.toContain("alpha_tool"); + expect(host!.loadErrors).toHaveLength(0); + }); + + it("surfaces loadErrors when an edited extension is broken", async () => { + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + const extFile = join(extDir, "learned.ts"); + writeFileSync(extFile, makeToolExtension("alpha_tool")); + + host = await loadHarnessExtensions({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + noExtensions: false, + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + configuredPaths: [extDir], + }); + expect(host).toBeDefined(); + + writeFileSync(extFile, "export default function ( { // syntactically broken\n"); + await host!.reload(); + + expect(host!.loadErrors.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/cli/test/extensions.test.ts b/packages/cli/test/extensions.test.ts new file mode 100644 index 00000000..d838745f --- /dev/null +++ b/packages/cli/test/extensions.test.ts @@ -0,0 +1,486 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { AgentHarness } from "@onkernel/cua-agent"; +import { cpSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { HarnessExtensionHost } from "../src/extensions/compat/host"; +import { HarnessToolRegistry } from "../src/extensions/compat/tool-registry"; +import { buildTestHarness, type TestHarnessFixture } from "./fixtures/harness"; + +const here = dirname(fileURLToPath(import.meta.url)); +const EXAMPLE_EXTENSION = join(here, "fixtures", "extensions", "click-visual.ts"); + +let fx: TestHarnessFixture | undefined; +let host: HarnessExtensionHost | undefined; + +afterEach(async () => { + await host?.dispose(); + host = undefined; + fx = undefined; +}); + +/** Copy the committed example extension into an isolated discovery directory. */ +function makeExtensionDir(): string { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + cpSync(EXAMPLE_EXTENSION, join(extDir, "click-visual.ts")); + return extDir; +} + +async function loadHost(): Promise { + fx = await buildTestHarness({ + turns: [ + { steps: [{ type: "tool_call", toolName: "click_visual", args: { description: "the button" } }] }, + { steps: [{ type: "text", text: "done" }] }, + ], + }); + const created = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [makeExtensionDir()], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + }); + await created.load(); + host = created; + return created; +} + +describe("HarnessExtensionHost", () => { + it("registers an extension tool on the harness", async () => { + await loadHost(); + const toolNames = fx!.harness.getTools().map((tool) => tool.name); + expect(toolNames).toContain("click_visual"); + }); + + it("bridges harness events into extension handlers", async () => { + await loadHost(); + + let sawAgentStart = false; + let toolResultText = ""; + fx!.harness.subscribe((event) => { + if (event.type === "agent_start") sawAgentStart = true; + if (event.type === "tool_execution_end" && event.toolName === "click_visual") { + const content = (event.result as { content: Array<{ type: string; text?: string }> }).content; + toolResultText = content.map((part) => part.text ?? "").join(""); + } + }); + + await fx!.harness.prompt("hi"); + + // agent_start reached the harness subscriber (loop-event forwarding path)… + expect(sawAgentStart).toBe(true); + // …and the extension's own agent_start handler ran: the tool, executed via + // the runner's wrapper, reports the count the extension incremented. + expect(toolResultText).toContain("would click: the button"); + expect(toolResultText).toContain("runs=1"); + }); + + it("keeps the extension tool after a model switch", async () => { + await loadHost(); + expect(fx!.harness.getTools().map((tool) => tool.name)).toContain("click_visual"); + + await fx!.harness.setModel("anthropic:claude-opus-4-7"); + + expect(fx!.harness.getTools().map((tool) => tool.name)).toContain("click_visual"); + }); + + it("keeps the extension tool active across a model switch", async () => { + await loadHost(); + expect(fx!.harness.getActiveTools().map((tool) => tool.name)).toContain("click_visual"); + + // setModel rebuilds the harness tool list from construction-time tools and + // resets active state; the host must re-activate extension tools, not just + // re-register them. + await fx!.harness.setModel("anthropic:claude-opus-4-7"); + + expect(fx!.harness.getActiveTools().map((tool) => tool.name)).toContain("click_visual"); + }); + + it("keeps the extension tool active across a mode switch", async () => { + await loadHost(); + expect(fx!.harness.getActiveTools().map((tool) => tool.name)).toContain("click_visual"); + + await fx!.harness.setMode("browser"); + + expect(fx!.harness.getTools().map((tool) => tool.name)).toContain("click_visual"); + expect(fx!.harness.getActiveTools().map((tool) => tool.name)).toContain("click_visual"); + }); + + it("re-registers extension tools after reload", async () => { + const created = await loadHost(); + expect(fx!.harness.getTools().map((tool) => tool.name)).toContain("click_visual"); + + await created.reload(); + + expect(fx!.harness.getTools().map((tool) => tool.name)).toContain("click_visual"); + }); + + it("reactivates rediscovered extension tools after reload", async () => { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync(join(extDir, "deactivate.ts"), DEACTIVATE_SELF_EXTENSION); + fx = await buildTestHarness({ + turns: [ + { steps: [{ type: "tool_call", toolName: "deactivate_self", args: {} }] }, + { steps: [{ type: "text", text: "done" }] }, + ], + }); + const created = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [extDir], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + }); + host = created; + await created.load(); + + await fx.harness.prompt("deactivate it"); + expect(fx.harness.getActiveTools().map((tool) => tool.name)).not.toContain("deactivate_self"); + + await created.reload(); + expect(fx.harness.getActiveTools().map((tool) => tool.name)).toContain("deactivate_self"); + }); + + it("drops a renamed extension's old tool on reload", async () => { + // Model a meta-agent revising a learned tool: the extension file is rewritten + // to register a differently-named tool, and reload must reflect the on-disk + // state — the previous tool should not linger on the harness. + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + const extFile = join(extDir, "learned.ts"); + writeFileSync(extFile, makeToolExtension("alpha_tool")); + + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + const created = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [extDir], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + }); + host = created; + await created.load(); + expect(fx.harness.getTools().map((tool) => tool.name)).toContain("alpha_tool"); + + writeFileSync(extFile, makeToolExtension("beta_tool")); + await created.reload(); + + const toolNames = fx.harness.getTools().map((tool) => tool.name); + expect(toolNames).toContain("beta_tool"); + expect(toolNames).not.toContain("alpha_tool"); + }); + + it("throws when load() is called after dispose", async () => { + const created = await loadHost(); + await created.dispose(); + await expect(created.load()).rejects.toThrow(/disposed/); + }); + + it("ignores a second load() rather than stacking a duplicate registration", async () => { + const created = await loadHost(); + const count = () => + fx!.harness.getTools().map((tool) => tool.name).filter((name) => name === "click_visual").length; + expect(count()).toBe(1); + await created.load(); + expect(count()).toBe(1); + }); + + it("removes its tools from the harness on dispose", async () => { + const created = await loadHost(); + expect(fx!.harness.getTools().map((tool) => tool.name)).toContain("click_visual"); + // dispose (e.g. via an extension's ctx.shutdown) must not leave the tool + // registered+active once its runner binding is gone. + await created.dispose(); + const names = fx!.harness.getTools().map((tool) => tool.name); + expect(names).not.toContain("click_visual"); + }); + + it("coalesces a reload requested while another is in flight", async () => { + const created = await loadHost(); + // A second request must report that the first reload still owns the operation. + const first = created.reload(); + const second = await created.reload(); + expect(second).toBe("coalesced"); + expect(await first).toBe("reloaded"); + }); + + it("invalidates extension APIs after reload and disposal", async () => { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync( + join(extDir, "capture.ts"), + "export default function (pi) { (globalThis.__capturedExtensionApis ??= []).push(pi); }", + ); + const captured = [] as Array<{ getActiveTools(): string[] }>; + (globalThis as typeof globalThis & { __capturedExtensionApis?: typeof captured }).__capturedExtensionApis = captured; + fx = await buildTestHarness({ + turns: [{ steps: [{ type: "text", text: "ok" }] }], + }); + const created = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [extDir], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + }); + host = created; + await created.load(); + await created.reload(); + expect(() => captured[0].getActiveTools()).toThrow(/stale/); + await created.dispose(); + expect(() => captured[1].getActiveTools()).toThrow(/stale/); + delete (globalThis as typeof globalThis & { __capturedExtensionApis?: typeof captured }).__capturedExtensionApis; + }); + + it("finishes an in-flight reload before disposal", async () => { + const created = await loadHost(); + const originalWaitForIdle = fx!.harness.waitForIdle.bind(fx!.harness); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + fx!.harness.waitForIdle = async () => { + await gate; + await originalWaitForIdle(); + }; + const reload = created.reload(); + const disposal = created.dispose(); + release(); + expect(await reload).toBe("disposed"); + await disposal; + expect(created.isDisposed()).toBe(true); + expect(fx!.harness.getTools().map((tool) => tool.name)).not.toContain( + "click_visual", + ); + }); + + it("publishes a dynamically registered extension tool before the next turn", async () => { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync(join(extDir, "dynamic.ts"), DYNAMIC_TOOL_EXTENSION); + fx = await buildTestHarness({ + turns: [ + { steps: [{ type: "tool_call", toolName: "register_later", args: {} }] }, + { steps: [{ type: "tool_call", toolName: "registered_later", args: {} }] }, + { steps: [{ type: "text", text: "done" }] }, + ], + }); + const created = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [extDir], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + }); + host = created; + await created.load(); + await fx.harness.prompt("register and call it"); + const secondContext = fx.provider.contexts()[1]; + expect(secondContext.tools?.map((tool) => tool.name)).toContain( + "registered_later", + ); + const marker = secondContext.messages.find( + (message) => + message.role === "toolResult" && + message.toolName === "register_later", + ); + expect(marker?.addedToolNames).toEqual(["registered_later"]); + }); + + it("does not let a startup extension message consume the first-turn screenshot", async () => { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync(join(extDir, "startup-msg.ts"), SEND_ON_STARTUP_EXTENSION); + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + let screenshotCalls = 0; + const created = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [extDir], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + initialScreenshot: async () => { + screenshotCalls += 1; + return [{ type: "image", data: "x", mimeType: "image/png" }]; + }, + }); + host = created; + await created.load(); + // Let the fire-and-forget startup prompt settle so an unguarded capture would + // have happened by now. + await new Promise((resolve) => setTimeout(resolve, 20)); + // The extension's startup sendUserMessage must not have captured the + // first-turn screenshot — that belongs to the user's real first prompt. + expect(screenshotCalls).toBe(0); + }); + + it("skips display capture for extension prompts in browser mode", async () => { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync(join(extDir, "browser-msg.ts"), SEND_AFTER_STARTUP_EXTENSION); + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + await fx.harness.setMode("browser"); + let screenshotCalls = 0; + const created = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [extDir], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + initialScreenshot: async () => { + screenshotCalls += 1; + return [{ type: "image", data: "x", mimeType: "image/png" }]; + }, + }); + host = created; + await created.load(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(screenshotCalls).toBe(0); + }); + + it("captures a failing extension sendUserMessage instead of an unhandled rejection", async () => { + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync(join(extDir, "startup-msg.ts"), SEND_ON_STARTUP_EXTENSION); + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + // Force the extension-initiated prompt to reject (models a busy/concurrent + // harness); without a catch this would surface as an unhandled rejection. + const realPrompt = fx.harness.prompt.bind(fx.harness); + fx.harness.prompt = ((text: string, options?: unknown) => + typeof text === "string" && text.includes("ext-startup-msg") + ? Promise.reject(new Error("harness busy")) + : realPrompt(text, options as never)) as typeof fx.harness.prompt; + const created = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [extDir], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + }); + host = created; + await created.load(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(created.loadErrors.some((e) => e.path === "")).toBe(true); + }); + + it("drops an extension tool that shadows a built-in and keeps the built-in", async () => { + fx = await buildTestHarness({ turns: [{ steps: [{ type: "text", text: "ok" }] }] }); + const builtin = fx.harness.getTools()[0].name; + const extDir = mkdtempSync(join(tmpdir(), "cua-ext-")); + writeFileSync(join(extDir, "shadow.ts"), makeToolExtension(builtin)); + const created = new HarnessExtensionHost({ + harness: fx.harness, + session: fx.session, + cwd: fx.cwd, + configuredPaths: [extDir], + agentDir: mkdtempSync(join(tmpdir(), "cua-agentdir-")), + }); + host = created; + // An extension may not shadow a built-in: the shadow would vanish the + // built-in when the extension is later removed on reload. + await created.load(); + const names = fx.harness.getTools().map((tool) => tool.name); + expect(names.filter((name) => name === builtin)).toHaveLength(1); + expect(created.loadErrors.some((e) => e.path === builtin && /built-in/.test(e.error))).toBe(true); + }); +}); + +describe("HarnessToolRegistry", () => { + it("serializes extension active-tool changes with other tool mutations", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const setActiveTools = vi.fn(async () => {}); + const harness = { + setActiveTools, + mutateTools: async (mutation: () => Promise) => { + await gate; + return mutation(); + }, + } as unknown as AgentHarness; + const registry = new HarnessToolRegistry(harness, () => {}); + + const update = registry.applyActiveTools(["click_visual"]); + await Promise.resolve(); + expect(setActiveTools).not.toHaveBeenCalled(); + + release(); + await update; + expect(setActiveTools).toHaveBeenCalledWith(["click_visual"]); + }); +}); + +const DYNAMIC_TOOL_EXTENSION = ` + export default function (pi) { + pi.registerTool({ + name: "register_later", + label: "register later", + description: "register another tool", + parameters: { type: "object", properties: {} }, + async execute() { + pi.registerTool({ + name: "registered_later", + label: "registered later", + description: "registered at runtime", + parameters: { type: "object", properties: {} }, + async execute() { return { content: [{ type: "text", text: "ok" }], details: {} }; }, + }); + return { content: [{ type: "text", text: "registered" }], details: {} }; + }, + }); + } +`; + +/** An extension that sends a user message during the startup session_start. */ +const SEND_ON_STARTUP_EXTENSION = [ + "export default function (pi) {", + ' pi.on("session_start", (event) => {', + ' if (event.reason === "startup") pi.sendUserMessage("ext-startup-msg");', + " });", + " pi.registerTool({", + ' name: "startup_probe",', + ' label: "startup probe",', + ' description: "noop",', + ' parameters: { type: "object", properties: {}, additionalProperties: false },', + ' async execute() { return { content: [{ type: "text", text: "ok" }], details: {} }; },', + " });", + "}", + "", +].join("\n"); + +const DEACTIVATE_SELF_EXTENSION = [ + "export default function (pi) {", + " pi.registerTool({", + ' name: "deactivate_self",', + ' label: "deactivate self",', + ' description: "deactivate this tool",', + ' parameters: { type: "object", properties: {}, additionalProperties: false },', + " async execute() {", + ' pi.setActiveTools(pi.getActiveTools().filter((name) => name !== "deactivate_self"));', + ' return { content: [{ type: "text", text: "deactivated" }], details: {} };', + " },", + " });", + "}", + "", +].join("\n"); + +const SEND_AFTER_STARTUP_EXTENSION = [ + "export default function (pi) {", + ' pi.on("session_start", (event) => {', + ' if (event.reason === "startup") setTimeout(() => pi.sendUserMessage("browser-msg"), 0);', + " });", + "}", + "", +].join("\n"); + +/** A minimal, import-free extension that registers a single named tool. */ +function makeToolExtension(toolName: string): string { + return [ + "export default function (pi) {", + " pi.registerTool({", + ` name: ${JSON.stringify(toolName)},`, + ` label: ${JSON.stringify(toolName)},`, + ` description: ${JSON.stringify(toolName)},`, + ' parameters: { type: "object", properties: {}, additionalProperties: false },', + ' async execute() { return { content: [{ type: "text", text: "ok" }], details: {} }; },', + " });", + "}", + "", + ].join("\n"); +} diff --git a/packages/cli/test/fixtures/extensions/click-visual.ts b/packages/cli/test/fixtures/extensions/click-visual.ts new file mode 100644 index 00000000..e32061a8 --- /dev/null +++ b/packages/cli/test/fixtures/extensions/click-visual.ts @@ -0,0 +1,47 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +/** + * Example pi extension exercising both Tier-A primitives the host proves: + * event subscription (`pi.on`) and tool registration (`pi.registerTool`). + * + * Parameters are declared as a plain JSON Schema object so the extension has no + * runtime imports: it is loaded from an arbitrary directory by the jiti loader, + * which resolves imports relative to that directory, not the cua workspace. + * + * Safe headless: the `agent_end` notification is guarded by `ctx.hasUI`, which + * is false under the host's no-op UI context, so it never runs in print mode. + * The `click_visual` tool reports the bridged `agent_start` count so a test can + * observe that harness events reach extension handlers. + */ +export default function clickVisual(pi: ExtensionAPI): void { + let agentRuns = 0; + + pi.on("agent_start", () => { + agentRuns += 1; + }); + + pi.on("agent_end", (_event, ctx) => { + if (ctx.hasUI) ctx.ui.notify(`agent runs: ${agentRuns}`, "info"); + }); + + pi.registerTool({ + name: "click_visual", + label: "Click (visual)", + description: "Stub: click an on-screen element by visual description.", + parameters: { + type: "object", + properties: { + description: { type: "string", description: "What to click" }, + }, + required: ["description"], + additionalProperties: false, + }, + async execute(_toolCallId, params) { + const description = (params as { description: string }).description; + return { + content: [{ type: "text", text: `would click: ${description} (runs=${agentRuns})` }], + details: { agentRuns }, + }; + }, + }); +} diff --git a/packages/cli/test/fixtures/scripted-provider.ts b/packages/cli/test/fixtures/scripted-provider.ts index 601b2d71..1e33a4cb 100644 --- a/packages/cli/test/fixtures/scripted-provider.ts +++ b/packages/cli/test/fixtures/scripted-provider.ts @@ -34,6 +34,8 @@ export interface ScriptedProviderHandle { callCount(): number; /** Latest context the provider was called with (assistant-side mock). */ lastContext(): Context | undefined; + /** Every provider context in dispatch order. */ + contexts(): readonly Context[]; /** Latest stream options the provider was called with. */ lastStreamOptions(): CuaSimpleStreamOptions | undefined; } @@ -48,10 +50,12 @@ export function createScriptedCuaModels(providerId: string, turns: ScriptedTurn[ const state = { index: 0, lastContext: undefined as Context | undefined, + contexts: [] as Context[], lastStreamOptions: undefined as CuaSimpleStreamOptions | undefined, }; const dispatch = (model: Model, context: Context, options?: CuaSimpleStreamOptions) => { state.lastContext = context; + state.contexts.push(context); state.lastStreamOptions = options; const turn = turns[state.index]; state.index += 1; @@ -71,6 +75,7 @@ export function createScriptedCuaModels(providerId: string, turns: ScriptedTurn[ reset(): void { state.index = 0; state.lastContext = undefined; + state.contexts = []; state.lastStreamOptions = undefined; }, callCount(): number { @@ -79,6 +84,9 @@ export function createScriptedCuaModels(providerId: string, turns: ScriptedTurn[ lastContext(): Context | undefined { return state.lastContext; }, + contexts(): readonly Context[] { + return state.contexts; + }, lastStreamOptions(): CuaSimpleStreamOptions | undefined { return state.lastStreamOptions; }, diff --git a/packages/cli/test/reload-command.test.ts b/packages/cli/test/reload-command.test.ts new file mode 100644 index 00000000..2f79b2d4 --- /dev/null +++ b/packages/cli/test/reload-command.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { applyReloadCommand, type InteractiveOptions } from "../src/tui/main"; +import { MessageList } from "../src/tui/message-list"; +import type { HarnessExtensions } from "../src/extensions/setup"; + +// `applyReloadCommand` is the glue the `/reload` dispatch runs (tui/main.ts): +// it bridges the parsed command to `host.reload()` and reports the outcome. +// MessageList colorizes via pi's theme singleton, so init it once. +initTheme(); + +/** Build the `applyReloadCommand` arg pair with a spyable host and message log. */ +function setup(host: HarnessExtensions | undefined): { + opts: InteractiveOptions; + messages: MessageList; + notices: string[]; + errors: string[]; +} { + const messages = new MessageList(); + const notices: string[] = []; + const errors: string[] = []; + vi.spyOn(messages, "addNotice").mockImplementation((text) => void notices.push(text)); + vi.spyOn(messages, "addError").mockImplementation((text) => void errors.push(text)); + return { opts: { host } as InteractiveOptions, messages, notices, errors }; +} + +describe("applyReloadCommand (/reload glue)", () => { + it("invokes host.reload() and reports a clean reload", async () => { + const reload = vi.fn(async () => {}); + const host = { reload, loadErrors: [], isDisposed: () => false } as unknown as HarnessExtensions; + const { opts, messages, notices, errors } = setup(host); + + await applyReloadCommand(opts, messages); + + expect(reload).toHaveBeenCalledTimes(1); + expect(notices).toContain("extensions reloaded"); + expect(errors).toHaveLength(0); + }); + + it("surfaces loadErrors after reload", async () => { + const reload = vi.fn(async () => {}); + const host = { + reload, + loadErrors: [{ path: "/ext/broken.ts", error: "boom" }], + isDisposed: () => false, + } as unknown as HarnessExtensions; + const { opts, messages, errors, notices } = setup(host); + + await applyReloadCommand(opts, messages); + + expect(reload).toHaveBeenCalledTimes(1); + expect(errors).toContain("/ext/broken.ts: boom"); + expect(notices).not.toContain("extensions reloaded"); + }); + + it("reports an in-progress reload instead of claiming success", async () => { + // A second manual reload coalesces with the operation already in flight; + // the command must not print "extensions reloaded" for work it + // didn't actually perform. + const reload = vi.fn(async () => "coalesced" as const); + const host = { reload, loadErrors: [], isDisposed: () => false } as unknown as HarnessExtensions; + const { opts, messages, notices } = setup(host); + + await applyReloadCommand(opts, messages); + + expect(notices.some((n) => n.includes("already in progress"))).toBe(true); + expect(notices).not.toContain("extensions reloaded"); + }); + + it("does not report success when the host disposed during reload", async () => { + const reload = vi.fn(async () => {}); + const host = { reload, loadErrors: [], isDisposed: () => true } as unknown as HarnessExtensions; + const { opts, messages, notices } = setup(host); + + await applyReloadCommand(opts, messages); + + expect(notices).not.toContain("extensions reloaded"); + expect(notices.some((n) => n.includes("shutting down"))).toBe(true); + }); + + it("no-ops with a notice when no host is loaded", async () => { + const { opts, messages, notices } = setup(undefined); + + await applyReloadCommand(opts, messages); + + expect(notices).toContain("extensions are disabled"); + }); +}); diff --git a/packages/cli/test/slash-commands.test.ts b/packages/cli/test/slash-commands.test.ts index 84caa1ad..5d3152db 100644 --- a/packages/cli/test/slash-commands.test.ts +++ b/packages/cli/test/slash-commands.test.ts @@ -26,6 +26,13 @@ describe("parseSlashCommand", () => { expect(parseSlashCommand("/compact")).toEqual({ command: "compact", argument: "" }); }); + it("parses /reload", () => { + expect(parseSlashCommand("/reload")).toEqual({ command: "reload", argument: "" }); + // Matches the existing builtin family: the bare command consumes any + // trailing text into `argument` (which /reload ignores), same as /compact. + expect(parseSlashCommand("/reload now")).toEqual({ command: "reload", argument: "now" }); + }); + it("parses /skill: with optional remainder", () => { expect(parseSlashCommand("/skill:hello")).toEqual({ command: "skill", diff --git a/vendor/pi/README.md b/vendor/pi/README.md new file mode 100644 index 00000000..0cad6a64 --- /dev/null +++ b/vendor/pi/README.md @@ -0,0 +1,20 @@ +# Pi message-anchored tool packages + +These four tarballs are built from upstream pi-mono commit +`3d8f74357c169d24f996a1611ecc4be72b7744bd`, the first commit containing +message-anchored dynamic tool loading. npm had not published a release containing +that commit when this change was implemented. + +All package versions and internal `@earendil-works/*` dependencies were rewritten +to the shared prerelease version `0.80.7-anchor.3d8f743` before running the normal +upstream build and `npm pack`. Replace these anchored dependencies with the first +normal npm release containing the commit before merging when one is available. +The package manifests use immutable raw GitHub URLs pinned to the commit that +first checked in these tarballs so packed CUA packages remain installable. + +SHA-256: + +- `earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz`: `04a7e2f330b56b6352d3a72b1b8785f8549dd68a985ca84c145bd4b1a8456f38` +- `earendil-works-pi-agent-core-0.80.7-anchor.3d8f743.tgz`: `d1ba7fce6f448ab4bd16baddfb4ae3d3d7f07c77b2f014ab63588aa4b9c09c1d` +- `earendil-works-pi-coding-agent-0.80.7-anchor.3d8f743.tgz`: `9af582b60b08fdab90d9b2c9bf8040ae410131e6785c77d844f9b87aeb714600` +- `earendil-works-pi-tui-0.80.7-anchor.3d8f743.tgz`: `a8ffd52318c8af5674c70f176489fe44117e2ea267f7b0712bb538a1f35450a9` diff --git a/vendor/pi/earendil-works-pi-agent-core-0.80.7-anchor.3d8f743.tgz b/vendor/pi/earendil-works-pi-agent-core-0.80.7-anchor.3d8f743.tgz new file mode 100644 index 00000000..1c306d87 Binary files /dev/null and b/vendor/pi/earendil-works-pi-agent-core-0.80.7-anchor.3d8f743.tgz differ diff --git a/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz b/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz new file mode 100644 index 00000000..4fd056dc Binary files /dev/null and b/vendor/pi/earendil-works-pi-ai-0.80.7-anchor.3d8f743.tgz differ diff --git a/vendor/pi/earendil-works-pi-coding-agent-0.80.7-anchor.3d8f743.tgz b/vendor/pi/earendil-works-pi-coding-agent-0.80.7-anchor.3d8f743.tgz new file mode 100644 index 00000000..0c63dd03 Binary files /dev/null and b/vendor/pi/earendil-works-pi-coding-agent-0.80.7-anchor.3d8f743.tgz differ diff --git a/vendor/pi/earendil-works-pi-tui-0.80.7-anchor.3d8f743.tgz b/vendor/pi/earendil-works-pi-tui-0.80.7-anchor.3d8f743.tgz new file mode 100644 index 00000000..ae80106b Binary files /dev/null and b/vendor/pi/earendil-works-pi-tui-0.80.7-anchor.3d8f743.tgz differ