From 3ff06beee7619d6f6d5479aa5507f7fd2fe410c8 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:23:56 +0000 Subject: [PATCH 1/7] ci: write a release's notes since the release before it, not its last pre-release (#387) release-please takes the last release to be the one its manifest names, so 0.1.0's notes said only what changed since 0.1.0-beta.2. release.yml now opens the release pull request through release-please's API, held to a lockfile, telling it the line's last release, or for a `.0` none, so a release's notes cover all its pre-releases did. Co-authored-by: Claude Opus 5.5 (cherry picked from commit d35b69a878505c6ba9bab76ffed1ce38d7cd107d) --- .github/dependabot.yml | 14 + .github/release-please/package-lock.json | 1814 ++++++++++++++++++++++ .github/release-please/package.json | 7 + .github/release-please/release-pr.js | 20 + .github/workflows/release.yml | 13 +- docs/contributing/releasing.md | 4 +- 6 files changed, 1868 insertions(+), 4 deletions(-) create mode 100644 .github/release-please/package-lock.json create mode 100644 .github/release-please/package.json create mode 100644 .github/release-please/release-pr.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e92f2657b..a398a0eef 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -90,3 +90,17 @@ updates: patterns: ["*"] commit-message: prefix: docs + + # The release-please release.yml opens release pull requests with. + - package-ecosystem: npm + directory: /.github/release-please + labels: [] + schedule: + interval: monthly + cooldown: + default-days: 7 + groups: + release-please: + patterns: ["*"] + commit-message: + prefix: ci diff --git a/.github/release-please/package-lock.json b/.github/release-please/package-lock.json new file mode 100644 index 000000000..10fa76a1f --- /dev/null +++ b/.github/release-please/package-lock.json @@ -0,0 +1,1814 @@ +{ + "name": "release-please", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "release-please": "17.11.2" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@conventional-commits/parser": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@conventional-commits/parser/-/parser-0.4.1.tgz", + "integrity": "sha512-H2ZmUVt6q+KBccXfMBhbBF14NlANeqHTXL4qCL6QGbMzrc4HDXyzWuxPxPNbz71f/5UkR5DrycP5VO9u7crahg==", + "license": "ISC", + "dependencies": { + "unist-util-visit": "^2.0.3", + "unist-util-visit-parents": "^3.1.1" + } + }, + "node_modules/@google-automations/git-file-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@google-automations/git-file-utils/-/git-file-utils-3.2.1.tgz", + "integrity": "sha512-LYOjAnGu/xtEkYYh6W3/1RmeK9HB/fV+xMe7nHc+WiDYrnxAJWBfcyUucoJtYS8XAhVUzNWrQvYDPRbxe67DNQ==", + "license": "Apache-2.0", + "dependencies": { + "@octokit/rest": "^20.1.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": ">= 22" + } + }, + "node_modules/@iarna/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==", + "license": "ISC" + }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.4.0.tgz", + "integrity": "sha512-nw2Px23Eirx0PFTVoPrb3FEq9Wkmg6vxcDtt38MtBCf1G0tjLCB9cK+3seOXyiw+6+aalgYhvJabQbWwMVKlEQ==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.1.0.tgz", + "integrity": "sha512-2Vkbda+XRjK6VTI6Lrh0sNBuMT8mZ51EhqMvO+V2rSdmqjs7NAGssxPZfkL23EFzYfSrvxyd1ZjOdqPQMM50og==", + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@octokit/auth-token": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-4.0.0.tgz", + "integrity": "sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/core": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", + "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^4.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.4.1", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.0.0", + "before-after-hook": "^2.2.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/endpoint": { + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", + "integrity": "sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "11.4.4-cjs.2", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-11.4.4-cjs.2.tgz", + "integrity": "sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.7.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-4.0.1.tgz", + "integrity": "sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==", + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "5" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "13.3.2-cjs.1", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-13.3.2-cjs.1.tgz", + "integrity": "sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.8.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@octokit/core": "^5" + } + }, + "node_modules/@octokit/request": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-8.4.1.tgz", + "integrity": "sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^9.0.6", + "@octokit/request-error": "^5.1.1", + "@octokit/types": "^13.1.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/request-error": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-5.1.1.tgz", + "integrity": "sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^13.1.0", + "deprecation": "^2.0.0", + "once": "^1.4.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/rest": { + "version": "20.1.2", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-20.1.2.tgz", + "integrity": "sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^5.0.2", + "@octokit/plugin-paginate-rest": "11.4.4-cjs.2", + "@octokit/plugin-request-log": "^4.0.0", + "@octokit/plugin-rest-endpoint-methods": "13.3.2-cjs.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@octokit/types": { + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-13.10.0.tgz", + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^24.2.0" + } + }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "license": "MIT" + }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "license": "MIT" + }, + "node_modules/@types/npm-package-arg": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/npm-package-arg/-/npm-package-arg-6.1.4.tgz", + "integrity": "sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q==", + "license": "MIT" + }, + "node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-ify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", + "integrity": "sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/before-after-hook": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", + "integrity": "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==", + "license": "Apache-2.0" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-keys": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz", + "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==", + "license": "MIT", + "dependencies": { + "camelcase": "^5.3.1", + "map-obj": "^4.0.0", + "quick-lru": "^4.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/compare-func": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", + "integrity": "sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==", + "license": "MIT", + "dependencies": { + "array-ify": "^1.0.0", + "dot-prop": "^5.1.0" + } + }, + "node_modules/conventional-changelog-conventionalcommits": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-6.1.0.tgz", + "integrity": "sha512-3cS3GEtR78zTfMzk0AizXKKIdN4OvSh7ibNz6/DPbhWWQu7LqE/8+/GqSodV+sywUR2gpJAdP/1JFf4XtN7Zpw==", + "license": "ISC", + "dependencies": { + "compare-func": "^2.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/conventional-changelog-writer": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-writer/-/conventional-changelog-writer-6.0.1.tgz", + "integrity": "sha512-359t9aHorPw+U+nHzUXHS5ZnPBOizRxfQsWT5ZDHBfvfxQOAik+yfuhKXG66CN5LEWPpMNnIMHUTCKeYNprvHQ==", + "license": "MIT", + "dependencies": { + "conventional-commits-filter": "^3.0.0", + "dateformat": "^3.0.3", + "handlebars": "^4.7.7", + "json-stringify-safe": "^5.0.1", + "meow": "^8.1.2", + "semver": "^7.0.0", + "split": "^1.0.1" + }, + "bin": { + "conventional-changelog-writer": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/conventional-commits-filter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/conventional-commits-filter/-/conventional-commits-filter-3.0.0.tgz", + "integrity": "sha512-1ymej8b5LouPx9Ox0Dw/qAO2dVdfpRFq28e5Y0jJEU8ZrLdy0vOSkkIInwmxErFGhg6SALro60ZrwYFVTUDo4Q==", + "license": "MIT", + "dependencies": { + "lodash.ismatch": "^4.4.0", + "modify-values": "^1.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/dateformat": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", + "integrity": "sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "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/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "license": "MIT", + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deprecation": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", + "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==", + "license": "ISC" + }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/handlebars": { + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.8", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsep": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.6.0.tgz", + "integrity": "sha512-Y8Tr54CX7B5YGAwo7/n8eumidEX3Opx98RyyQVnlHqUaZcPA8uUzL2v1rshr9ypW9Q6hNjfJtg3sL+8kaNE7TA==", + "license": "MIT", + "engines": { + "node": ">= 20.16.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.ismatch": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz", + "integrity": "sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", + "integrity": "sha512-r85E3NdZ+mpYk1C6RjPFEMSE+s1iZMuHtsHAqY0DT3jZczl0diWUZ8g6oU7h0M9cD2EL+PzaYghhCLzR0ZNn5Q==", + "license": "MIT", + "dependencies": { + "@types/minimist": "^1.2.0", + "camelcase-keys": "^6.2.2", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.0", + "read-pkg-up": "^7.0.1", + "redent": "^3.0.0", + "trim-newlines": "^3.0.0", + "type-fest": "^0.18.0", + "yargs-parser": "^20.2.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz", + "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "license": "MIT", + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/modify-values": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", + "integrity": "sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-github-repo-url": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/parse-github-repo-url/-/parse-github-repo-url-1.4.1.tgz", + "integrity": "sha512-bSWyzBKqcSL4RrncTpGsEKoJ7H8a4L3++ifTAbTFeMHyq2wRV+42DGmQcHIrJIvdcacjIOxEuKH/w4tthF17gg==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/quick-lru": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz", + "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "license": "MIT", + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "license": "MIT", + "dependencies": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/read-pkg/node_modules/hosted-git-info": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", + "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", + "license": "ISC" + }, + "node_modules/read-pkg/node_modules/normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "node_modules/read-pkg/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/release-please": { + "version": "17.11.2", + "resolved": "https://registry.npmjs.org/release-please/-/release-please-17.11.2.tgz", + "integrity": "sha512-we0+NgyHjvczxrilD6aujnRNuASd65Jem0dY5evevxSZvz8BnKnB/QRN3UzGA229fcV09lKCYbqeE9HgkXzGmA==", + "license": "Apache-2.0", + "dependencies": { + "@conventional-commits/parser": "^0.4.1", + "@google-automations/git-file-utils": "^3.2.0", + "@iarna/toml": "^3.0.0", + "@octokit/graphql": "^7.1.0", + "@octokit/request": "^8.3.1", + "@octokit/request-error": "^5.1.0", + "@octokit/rest": "^20.1.1", + "@types/npm-package-arg": "^6.1.0", + "@xmldom/xmldom": "^0.8.4", + "async-retry": "^1.3.3", + "chalk": "^4.0.0", + "conventional-changelog-conventionalcommits": "^6.0.0", + "conventional-changelog-writer": "^6.0.0", + "conventional-commits-filter": "^3.0.0", + "detect-indent": "^6.1.0", + "diff": "^8.0.3", + "figures": "^3.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.3.1", + "jsonpath-plus": "^10.0.0", + "node-html-parser": "^6.0.0", + "parse-github-repo-url": "^1.4.1", + "semver": "^7.5.3", + "type-fest": "^3.0.0", + "typescript": "^4.6.4", + "unist-util-visit": "^2.0.3", + "unist-util-visit-parents": "^3.1.1", + "xpath": "^0.0.34", + "yaml": "^2.2.2", + "yargs": "^17.0.0" + }, + "bin": { + "release-please": "build/src/bin/release-please.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "license": "CC0-1.0" + }, + "node_modules/split": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split/-/split-1.0.1.tgz", + "integrity": "sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==", + "license": "MIT", + "dependencies": { + "through": "2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "license": "MIT" + }, + "node_modules/trim-newlines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz", + "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universal-user-agent": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-6.0.1.tgz", + "integrity": "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==", + "license": "ISC" + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xpath": { + "version": "0.0.34", + "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", + "integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==", + "license": "MIT", + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/.github/release-please/package.json b/.github/release-please/package.json new file mode 100644 index 000000000..505e7a7a4 --- /dev/null +++ b/.github/release-please/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "description": "release-please for release.yml, held to its lockfile", + "dependencies": { + "release-please": "17.11.2" + } +} diff --git a/.github/release-please/release-pr.js b/.github/release-please/release-pr.js new file mode 100644 index 000000000..ece5ccd59 --- /dev/null +++ b/.github/release-please/release-pr.js @@ -0,0 +1,20 @@ +// Opens or updates the release pull request for $VERSION against $BRANCH, as +// `release-please release-pr --release-as` does, but with the last release $PREVIOUS where +// it is set. release-please takes the last release to be the one its manifest names, a +// pre-release included, and its command line cannot say otherwise: this is how a release's +// notes cover all that its pre-releases did. release.yml runs it. + +const { GitHub, Manifest } = require("release-please"); +const { Version } = require("release-please/build/src/version"); + +(async () => { + const { GITHUB_REPOSITORY, GH_TOKEN, BRANCH, VERSION, PREVIOUS } = process.env; + const [owner, repo] = GITHUB_REPOSITORY.split("/"); + const github = await GitHub.create({ owner, repo, token: GH_TOKEN }); + const manifest = await Manifest.fromManifest(github, BRANCH, undefined, undefined, {}, undefined, VERSION); + if (PREVIOUS) manifest.releasedVersions["."] = Version.parse(PREVIOUS); + console.log(await manifest.createPullRequests()); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1908c339..9ac2ae310 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,8 +88,13 @@ jobs: sys.exit(f"::error::{branch} would start a line before {'.'.join(map(str, max(lines)))}") if earlier := [other for other in branches if other < line]: cut = git("merge-base", "origin/main", f"origin/{branches[max(earlier)]}")[0] + # A release's notes start at the release before it, never at one of its pre-releases: + # at its line's newest, or, for a `.0`, where the line was cut (0.0.0, none at all). + previous = "" + if not m[4]: + previous = max((t for t in ours if "-" not in t), key=pep440, default="0.0.0") with open(os.environ["GITHUB_OUTPUT"], "a") as out: - print(f"branch={branch}", f"cut={line not in branches and 'true' or 'false'}", f"since={cut}", sep="\n", file=out) + print(f"branch={branch}", f"cut={line not in branches and 'true' or 'false'}", f"since={cut}", f"previous={previous}", sep="\n", file=out) PY # A new line's changes are those since the line before it was cut from `main`, which is @@ -113,13 +118,15 @@ jobs: git push "https://x-access-token:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.git" "HEAD:refs/heads/$BRANCH" - name: Open the release pull request + working-directory: .github/release-please env: GH_TOKEN: ${{ github.token }} BRANCH: ${{ steps.plan.outputs.branch }} VERSION: ${{ inputs.version }} + PREVIOUS: ${{ steps.plan.outputs.previous }} run: | - npx --yes release-please@17.11.2 release-pr --token "$GH_TOKEN" \ - --repo-url "$GITHUB_REPOSITORY" --target-branch "$BRANCH" --release-as "$VERSION" + npm ci --no-audit --no-fund + node release-pr.js # A pull request this workflow's token opens or updates starts no `ci-ok` of its own, and # the branch rule needs one. A dispatch is the one event that token may start. diff --git a/docs/contributing/releasing.md b/docs/contributing/releasing.md index af1823aa9..7811d62c7 100644 --- a/docs/contributing/releasing.md +++ b/docs/contributing/releasing.md @@ -31,7 +31,9 @@ rules as `main`. Three workflows do it: request bumps `version` in `pyproject.toml`, `uv.lock` and the macOS app's `tauri.conf.json`, and adds the release's section to `CHANGELOG.md`, written from the pull request titles since the last release of that line, - or, for a new line, since the line before it was cut. + or, for a new line, since the line before it was cut. A pre-release counts as a last release + only to the pre-releases after it: `0.2.0` says all that changed since `0.1.x`, not just + since `0.2.0-rc.1`. 2. **`ci.yml`** runs on the release pull request, started by `release.yml`: a pull request a workflow opened starts no checks of its own, and `ci-ok` is needed to merge it. It runs again on every push to a `release/*` branch, the merge included, on what the push changed. From e133228b133e2a7016c12753651b991860e6ecb2 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:16:05 +0000 Subject: [PATCH 2/7] ci: find the release pull request by its branch to run CI on, never on main (#398) release.yml looked it up by its `autorelease: pending` label, which release-please had not yet added for 0.2.0-beta.2: the lookup came back empty and CI was dispatched on main, leaving the release pull request without `ci-ok`. Co-authored-by: Claude Opus 5.5 (cherry picked from commit ce90040cc0906dfb28ac9c62236a16bdf685fea8) --- .github/workflows/release.yml | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ac2ae310..eb218a017 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,12 +129,18 @@ jobs: node release-pr.js # A pull request this workflow's token opens or updates starts no `ci-ok` of its own, and - # the branch rule needs one. A dispatch is the one event that token may start. + # the branch rule needs one. A dispatch is the one event that token may start. The pull + # request is found by release-please's branch, not its `autorelease: pending` label, which + # it may not have yet; and never dispatched without one, which would run CI on `main`. - name: Run CI on the release pull request env: GH_TOKEN: ${{ github.token }} BRANCH: ${{ steps.plan.outputs.branch }} run: | - head="$(gh pr list --repo "$GITHUB_REPOSITORY" --base "$BRANCH" --label 'autorelease: pending' \ - --json headRefName --jq '.[0].headRefName')" + head="$(gh pr list --repo "$GITHUB_REPOSITORY" --base "$BRANCH" --state open \ + --json headRefName --jq '[.[].headRefName | select(startswith("release-please--"))][0] // ""')" + if [[ -z "$head" ]]; then + echo "::error::No release pull request is open against $BRANCH." + exit 1 + fi gh workflow run ci.yml --ref "$head" --repo "$GITHUB_REPOSITORY" From 39e01ecb3708b2d238e891aad247cef50e53b6b5 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:25:44 +0000 Subject: [PATCH 3/7] fix: line up the sidebar's top row with the traffic lights, and every row on one column (#399) * fix(web): hang every sidebar row on one column The sidebar's brand, its three links, the workspace folders and the epics each started at an x of their own: icons at 16, 17, 19 and 29px, words at 36, 39, 53 and 60px. As ChatGPT and Claude lay theirs out, every row's icon now stands centred in one slot (--side-in, --side-slot) and every row's words start at --side-text just past it; an epic opened out runs its line under the slot's middle and its agents' icons where the epic's words start. A workspace's folder and an epic's mark stand in that slot themselves, giving way to the chevron that folds them where the row is pointed at or the chevron has the focus, rather than the chevron taking a column of its own. Co-Authored-By: Claude Opus 5.5 * fix(desktop): centre the traffic lights on the sidebar's top row Tauri's `y` is where the title bar is made to end, and AppKit keeps its buttons 9 points above that, so `y: 21` stood the lights' centre 19 down, 8 above the logo, the bell and the fold at 27 and the bar's 28. They now stand at `y: 30`, centred 28 down, and at `x: 19`, the close button centred on the sidebar's column of icons. The sidebar's top row is as tall as the bar over the view and centred on the same line, in the app and the browser. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (cherry picked from commit fc0588f6c9c31724d2293a6c8f3f6e0408b24132) --- desktop/src-tauri/src/chrome.rs | 7 ++- src/hmz/web/static/dom.js | 2 +- src/hmz/web/static/sidebar.js | 4 +- src/hmz/web/static/style.css | 99 ++++++++++++++++++++++++--------- 4 files changed, 79 insertions(+), 33 deletions(-) diff --git a/desktop/src-tauri/src/chrome.rs b/desktop/src-tauri/src/chrome.rs index 9882914c8..0bde9d702 100644 --- a/desktop/src-tauri/src/chrome.rs +++ b/desktop/src-tauri/src/chrome.rs @@ -8,9 +8,10 @@ use tauri::{ LogicalPosition, Manager, Runtime, TitleBarStyle, WebviewWindow, WebviewWindowBuilder, Window, }; -/// Where the traffic lights stand: centred on the page's top row, 57 pixels tall, and inset from -/// its left edge as far as from the top. -const LIGHTS: LogicalPosition = LogicalPosition { x: 20.0, y: 21.0 }; +/// Where the traffic lights stand: centred on the page's top row, 57 pixels tall with the rule +/// under it, so 28 down; the close button centred on the sidebar's column of icons, 26 in. `y` is +/// where the title bar is made to end, and AppKit keeps its 14-point buttons 9 points above that. +const LIGHTS: LogicalPosition = LogicalPosition { x: 19.0, y: 30.0 }; /// What WKWebView says it is where it is not told otherwise. const WEBKIT: &str = diff --git a/src/hmz/web/static/dom.js b/src/hmz/web/static/dom.js index e3cf55f50..cd1cf93b0 100644 --- a/src/hmz/web/static/dom.js +++ b/src/hmz/web/static/dom.js @@ -257,7 +257,7 @@ const ICONS = { export function icon(name) { return svg( 'svg', - { class: 'icon', viewBox: '0 0 24 24', width: '18', height: '18', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'square', 'stroke-linejoin': 'miter', 'aria-hidden': 'true' }, + { class: `icon icon-${name}`, viewBox: '0 0 24 24', width: '18', height: '18', fill: 'none', stroke: 'currentColor', 'stroke-width': '1.8', 'stroke-linecap': 'square', 'stroke-linejoin': 'miter', 'aria-hidden': 'true' }, (ICONS[name] || []).map((d) => svg('path', { d })), ) } diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js index 6c5b70d65..117e5dca6 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -122,7 +122,7 @@ export function mountSidebar(root, context, where) { list, order.map((group) => { const open = !shut.has(group.path) - const toggle = h('button', { class: 'side-toggle', type: 'button', 'aria-expanded': String(open), 'aria-label': open ? 'Collapse' : 'Expand' }, icon('chevron')) + const toggle = h('button', { class: 'side-toggle', type: 'button', 'aria-expanded': String(open), 'aria-label': open ? 'Collapse' : 'Expand' }, icon('folder'), icon('chevron')) toggle.addEventListener('click', () => { if (open) shut.add(group.path) else shut.delete(group.path) @@ -133,7 +133,7 @@ export function mountSidebar(root, context, where) { 'div', { class: ['side-folder', chosen === group.path && 'current'] }, toggle, - h('span', { class: 'side-folder-name', title: group.path }, icon('folder'), h('span', { class: 'side-label' }, nameOf(group.path))), + h('span', { class: 'side-folder-name', title: group.path }, h('span', { class: 'side-label' }, nameOf(group.path))), iconButton('plus', `New epic in ${nameOf(group.path)}`, { class: 'side-add', onclick: () => (location.hash = hash(`/new?workspace=${encodeURIComponent(group.path)}`)) }), ) const where = encodeURIComponent(group.path) diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index cf15000c7..e110390df 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -62,6 +62,11 @@ select, a button and an icon button, and the small ones on a composer's bar. */ --control-h: 32px; --control-h-sm: 26px; + /* The sidebar's one column, as ChatGPT and Claude keep theirs: every row's icon stands in a slot + this far in from the sidebar's edge, and every row's words start just past it. */ + --side-in: 16px; + --side-slot: 20px; + --side-text: calc(var(--side-in) + var(--side-slot) + 8px); } [data-theme='dark'] { @@ -260,17 +265,19 @@ code { background: linear-gradient(90deg, var(--green) 0 50%, var(--cyan) 50%); } +/* As tall as the bar over the view, its rule included, and centred on the same line. */ .side-top { display: flex; align-items: center; justify-content: space-between; - padding: 12px 10px 8px 14px; + padding: 8px 8px 13px calc(var(--side-in) - 2px); } .brand { display: flex; align-items: center; - gap: 9px; + /* The mark, 2px wider than the slot each side, centred on it: the name starts where words do. */ + gap: 6px; color: var(--ink); text-decoration: none; } @@ -289,15 +296,16 @@ code { .side-nav { display: grid; gap: 1px; - padding: 4px 8px 8px; + padding: 2px 8px 8px; border-bottom: 1px solid var(--line); } .side-nav a { display: flex; align-items: baseline; - gap: 10px; - padding: 7px 10px; + gap: 8px; + min-height: var(--control-h); + padding: 6px calc(var(--side-in) - 8px); color: var(--ink); text-decoration: none; font-weight: 600; @@ -313,6 +321,10 @@ code { box-shadow: inset 4px 0 0 var(--cyan); } +.side-nav a > .icon { + margin: 0 calc((var(--side-slot) - 18px) / 2); +} + .side-badge { margin-left: auto; min-width: 18px; @@ -363,10 +375,12 @@ code { } .side-quiet.nested { - padding-left: 34px; + padding-left: calc(var(--side-text) - 8px); } +/* An epic: its mark in the slot, and over it, where the row is pointed at, what opens it out. */ .side-run { + position: relative; display: flex; align-items: center; } @@ -379,7 +393,7 @@ code { display: flex; align-items: baseline; gap: 8px; - padding: 6px 8px; + padding: 6px calc(var(--side-in) - 8px); color: var(--ink); text-decoration: none; } @@ -406,8 +420,8 @@ code { .side-toggle { flex: none; - width: 22px; - height: 30px; + width: var(--side-slot); + height: 100%; display: grid; place-items: center; padding: 0; @@ -418,12 +432,13 @@ code { } .side-toggle .icon { + grid-area: 1 / 1; width: 14px; height: 14px; transition: transform 0.15s; } -.side-toggle[aria-expanded='true'] .icon { +.side-toggle[aria-expanded='true'] .icon-chevron { transform: rotate(90deg); } @@ -431,9 +446,41 @@ code { color: var(--ink); } +.side-run > .side-toggle { + position: absolute; + inset: 0 auto 0 calc(var(--side-in) - 8px); + opacity: 0; +} + +.side-run.current > .side-toggle { + color: var(--on-ink); +} + +.side-run:is(:hover, :has(.side-toggle:focus-visible)) > .side-toggle { + opacity: 1; +} + +.side-run:is(:hover, :has(.side-toggle:focus-visible)) .side-mark { + visibility: hidden; +} + +/* A workspace's folder, as an epic's mark, gives way to what folds it where it is pointed at. */ +.side-folder .side-toggle .icon-folder { + color: var(--cyan-ink); +} + +.side-folder .side-toggle .icon-chevron, +.side-folder:is(:hover, :has(.side-toggle:focus-visible)) .side-toggle .icon-folder { + visibility: hidden; +} + +.side-folder:is(:hover, :has(.side-toggle:focus-visible)) .side-toggle .icon-chevron { + visibility: visible; +} + .side-mark { flex: none; - width: 14px; + width: var(--side-slot); text-align: center; font-size: 11px; color: var(--tone, var(--ink-3)); @@ -457,14 +504,16 @@ code { color: var(--ink-3); } +/* The line down an epic opened out runs under the slot's middle, and its agents' icons stand + where the epic's words start. */ .side-tree { - margin: 0 0 6px 21px !important; - padding: 2px 0 2px 6px !important; + margin: 0 0 6px calc(var(--side-in) + var(--side-slot) / 2 - 9px) !important; + padding: 2px 0 2px calc(var(--side-slot) / 2 - 1px) !important; border-left: 2px solid var(--line-strong); } .side-tree ul { - padding-left: 18px !important; + padding-left: 22px !important; } .side-agent { @@ -590,7 +639,15 @@ code { .side-folder { display: flex; align-items: center; + gap: 8px; + min-height: var(--control-h-sm); margin-top: 6px; + padding-left: calc(var(--side-in) - 8px); +} + +.side-folder > .side-toggle { + align-self: stretch; + height: auto; } .side-folder-name { @@ -598,7 +655,6 @@ code { min-width: 0; display: flex; align-items: center; - gap: 7px; padding: 4px 0; font: 600 11px var(--mono); text-transform: uppercase; @@ -606,12 +662,6 @@ code { color: var(--ink-2); } -.side-folder-name .icon { - width: 14px; - height: 14px; - color: var(--cyan-ink); -} - .side-folder.current .side-folder-name { color: var(--ink); } @@ -627,11 +677,6 @@ code { opacity: 1; } -.side-group > ul, -.side-group > .side-quiet { - margin-left: 8px !important; -} - .pickers { display: flex; align-items: center; @@ -3711,7 +3756,7 @@ pre.exo-args { } :root[data-shell='macos']:not([data-fullscreen]) .side-top { - padding: 7px 10px 13px calc(var(--lights) + 6px); + padding-left: calc(var(--lights) + 8px); } :root[data-shell='macos']:not([data-fullscreen]) .side-folded .bar { From 42c5b121624911858d36af9afa82476eea8b09d9 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:28:50 +0000 Subject: [PATCH 4/7] ci: approve the release pull request's own CI run instead of dispatching one (#400) A dispatched run's ci-ok does not satisfy the branch rule on 0.2.0-beta.2's pull request; the pull_request run, held for approval as it is on a pull request a workflow opened, does. Co-authored-by: Claude Opus 5.5 (cherry picked from commit a4b64a35ce7eb72cce44bd780d69065f8679ea53) --- .github/workflows/release.yml | 21 +++++++++++++++------ docs/contributing/releasing.md | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eb218a017..34ea2f524 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: contents: write # the release branch pull-requests: write # the release pull request issues: write # the `autorelease:` labels release-please finds its pull requests by - actions: write # to run ci.yml on the release pull request + actions: write # to approve ci.yml's run on the release pull request steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -128,10 +128,10 @@ jobs: npm ci --no-audit --no-fund node release-pr.js - # A pull request this workflow's token opens or updates starts no `ci-ok` of its own, and - # the branch rule needs one. A dispatch is the one event that token may start. The pull - # request is found by release-please's branch, not its `autorelease: pending` label, which - # it may not have yet; and never dispatched without one, which would run CI on `main`. + # A pull request this workflow's token opens or updates starts ci.yml held for approval, + # and only that run's `ci-ok` lets it merge: a dispatched run's does not count. So it is + # approved here, found by release-please's branch rather than its `autorelease: pending` + # label, which the pull request may not have yet. - name: Run CI on the release pull request env: GH_TOKEN: ${{ github.token }} @@ -143,4 +143,13 @@ jobs: echo "::error::No release pull request is open against $BRANCH." exit 1 fi - gh workflow run ci.yml --ref "$head" --repo "$GITHUB_REPOSITORY" + for _ in $(seq 30); do + run="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --branch "$head" \ + --event pull_request --status action_required --json databaseId --jq '.[0].databaseId // ""')" + if [[ -n "$run" ]]; then + gh api -X POST "repos/$GITHUB_REPOSITORY/actions/runs/$run/approve" + exit 0 + fi + sleep 10 + done + echo "::warning::No CI run on $head waits for approval: the pull request did not change." diff --git a/docs/contributing/releasing.md b/docs/contributing/releasing.md index 7811d62c7..8f7d23ad9 100644 --- a/docs/contributing/releasing.md +++ b/docs/contributing/releasing.md @@ -34,8 +34,8 @@ rules as `main`. Three workflows do it: or, for a new line, since the line before it was cut. A pre-release counts as a last release only to the pre-releases after it: `0.2.0` says all that changed since `0.1.x`, not just since `0.2.0-rc.1`. -2. **`ci.yml`** runs on the release pull request, started by `release.yml`: a pull request a - workflow opened starts no checks of its own, and `ci-ok` is needed to merge it. It runs +2. **`ci.yml`** runs on the release pull request, approved by `release.yml`: on a pull request + a workflow opened, it waits for approval, and its `ci-ok` is needed to merge it. It runs again on every push to a `release/*` branch, the merge included, on what the push changed. 3. **`publish.yml`**, on a push to a `release/*` branch that merged its release pull request, tags the merge commit `vX.Y.Z`, builds a wheel and an sdist, runs `twine check --strict` From 24a1cef47564c4b6eec699d85625e70f2cd3b91e Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 03:48:03 +0000 Subject: [PATCH 5/7] feat: call every agent by its own name, never by the role it fills (#401) A role is the parameter a flow takes an agent as, not the agent. An agent driver now draws a codename once and every session it opens is that agent, so a conversation is keyed `/` and the same agent handed on to a called flow keeps its name. The run's start and its epic say which agent fills each role; the web page and the terminal interface name agents first and show the role after, while setup and outworlders stay by role. Co-authored-by: Claude Opus 5.5 (cherry picked from commit 8460e07d9cc9c16294d3fd302abbea19fd3fd4ff) --- docs/reference/agents.md | 6 +- docs/reference/cli.md | 4 +- docs/reference/daemon.md | 12 +- docs/reference/sdk.md | 4 +- docs/reference/tracing.md | 8 +- docs/reference/tui.md | 10 +- docs/reference/web.md | 6 +- docs/user/btw.md | 4 +- docs/user/export.md | 1 + docs/user/monitor.md | 2 +- docs/user/tracing.md | 2 +- specs/coganchor/agents.md | 5 +- specs/runtime/SPEC.md | 9 +- specs/runtime/doing.md | 11 +- specs/runtime/flowing.md | 5 +- specs/runtime/tracing.md | 12 +- specs/runtime/watching.md | 18 +- specs/tui.md | 5 +- specs/web.md | 3 + src/hmz/coganchor/agents/base.py | 18 -- src/hmz/runtime/doing/hosting.py | 16 +- src/hmz/runtime/doing/running.py | 10 +- src/hmz/runtime/epic.py | 8 +- src/hmz/runtime/exporting.py | 3 + src/hmz/runtime/flowing/fakes.py | 5 + src/hmz/runtime/flowing/harnesses.py | 13 +- src/hmz/runtime/flowing/spi.py | 9 + src/hmz/runtime/runner.py | 31 ++- src/hmz/runtime/tracing/timeline.py | 30 ++- src/hmz/runtime/watching/btw.py | 25 +-- src/hmz/runtime/watching/following.py | 15 +- src/hmz/runtime/watching/monitor.py | 6 +- src/hmz/runtime/watching/tally.py | 7 +- src/hmz/tui/app.py | 177 +++++++++++------- src/hmz/tui/monitoring.py | 5 +- src/hmz/tui/pick.py | 3 +- src/hmz/web/held.py | 35 ++-- src/hmz/web/runs.py | 8 +- src/hmz/web/static/acting.js | 8 +- src/hmz/web/static/epic.js | 74 ++++---- src/hmz/web/static/graph.js | 40 ++-- src/hmz/web/static/sidebar.js | 14 +- src/hmz/web/static/transcript.js | 16 +- src/hmz/web/static/views/epic.js | 42 +++-- src/hmz/web/static/views/exomyth/flame.js | 16 +- src/hmz/web/static/views/search.js | 2 +- tests/integration/test_core_exec.py | 15 +- tests/integration/test_tui_exomyth.py | 2 +- tests/integration/test_tui_monitor.py | 6 +- tests/integration/test_web_runs.py | 37 ++-- tests/system/test_tui_ralph_loop.py | 8 +- tests/system/web/test_web_page.py | 28 +-- tests/system/web/test_web_scoping.py | 2 +- tests/unit/coganchor/agents/test_base.py | 10 +- tests/unit/runtime/doing/test_hosting.py | 63 ++++--- tests/unit/runtime/doing/test_running.py | 14 ++ tests/unit/runtime/flowing/test_fakes.py | 5 + tests/unit/runtime/flowing/test_harnesses.py | 17 +- tests/unit/runtime/test_epic.py | 19 +- tests/unit/runtime/test_exporting.py | 27 ++- tests/unit/runtime/test_runner.py | 26 +-- tests/unit/runtime/tracing/test_timeline.py | 34 +++- tests/unit/runtime/watching/test_btw.py | 27 +-- tests/unit/runtime/watching/test_following.py | 46 ++++- tests/unit/runtime/watching/test_monitor.py | 20 +- tests/unit/runtime/watching/test_tally.py | 6 +- tests/unit/tui/test_monitoring.py | 45 +++-- tests/unit/web/test_runs.py | 21 +++ 68 files changed, 786 insertions(+), 455 deletions(-) diff --git a/docs/reference/agents.md b/docs/reference/agents.md index 56a323ede..ab8a19c12 100644 --- a/docs/reference/agents.md +++ b/docs/reference/agents.md @@ -1797,7 +1797,7 @@ returns the copy's path, or `None`. | Attribute | Meaning | | --- | --- | -| `agent.id` | the given `name=`, the flow's role name, or a codename | +| `agent.id` | the given `name=`, or a codename; it never changes, and a flow's role is not it | | `agent.backend` | the backend's name, or an ACP CLI's | | `agent.opened` | every session id it opened, oldest first | | `agent.sessions` | the sessions still held | @@ -1812,8 +1812,8 @@ No codename repeats in one process. `agent.clone(*, config=None, name=None, skills=None)` makes a fresh agent: same settings unless given, no conversations, spending, watchers or hooks, a new codename unless named; it spends the -same allowance. `reconfigure(config)`, `runs_on(machine)`, `loads(skills)`, `rename(name)` and -`disable_goals()` change an agent in place; flows have none of them, only `derive`. +same allowance. `reconfigure(config)`, `runs_on(machine)`, `loads(skills)` and `disable_goals()` +change an agent in place; flows have none of them, only `derive`. ### The person as an agent {#the-person-as-an-agent} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f2f5aafbf..72ccaa4cc 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -469,7 +469,7 @@ key: | Key | Type | Value | | --- | --- | --- | | `at` | number | Unix time the record was made. | -| `agent` | string | The agent's id: the role it fills. | +| `agent` | string | The agent's own name, such as `NeiKos496`: never the role it fills. | | `cli` | string | The backend (`claude`, `codex`, …, or an ACP CLI's name). | | `model` | string | The model it was configured with. | | `session` | string | The backend's own id for the conversation; `""` before it has named one, and for events not about one conversation. | @@ -496,7 +496,7 @@ Lines that are not events (`hmz exec: …` notes, the flow's own prints) go to s ```console $ hmz exec -f chat -a assistant=claude/claude-opus-5:high --json "say hello" | jq -c 'select(.kind == "result")' -{"at":1789026740.6,"agent":"assistant","cli":"claude","model":"claude-opus-5","session":"1d1ff959","kind":"result","text":"Hello.","whose":"","tokens":{"claude-opus-5":2080},"spent":{"input":2000,"output":80}} +{"at":1789026740.6,"agent":"NeiKos496","cli":"claude","model":"claude-opus-5","session":"1d1ff959","kind":"result","text":"Hello.","whose":"","tokens":{"claude-opus-5":2080},"spent":{"input":2000,"output":80}} ``` #### Colour {#colour} diff --git a/docs/reference/daemon.md b/docs/reference/daemon.md index 643c8c838..899855c15 100644 --- a/docs/reference/daemon.md +++ b/docs/reference/daemon.md @@ -290,7 +290,7 @@ An exception inside a request is refused with `str(exception)` or its type name. | `claim` | `role` (str, required), `take` (bool, `false`) | — | `a role to claim is named`, ` is 's` (without `take`). Needs no run; role not checked against the flow. | | `release` | `role` | — | ` is not yours to release` | | `board` | `key`, `value` (`""` removes the line); acts on the last run's board as `user` | — | `this run has no board`, ` is not on the board`, ` is flow's to change, not user's` (and other ` is 's to change, not 's` refusals) | -| `aside` (open) | `key` (`/` of the last run) with `fork` (bool), **or** `runs` (an `-a` spec after `=`) -- with `recall` (what its CLI called a session an earlier run kept), `kept` (where that run kept it) and `cwd`, and `fork`, for a side conversation about that session | `side` (`s`), `forked` (bool) | ` has no conversation to ask`, ` is not an agent that can be made here`, `an aside is about a conversation, or asks an agent`, `this frontend has gone` | +| `aside` (open) | `key` (`/` of the last run) with `fork` (bool), **or** `runs` (an `-a` spec after `=`) -- with `recall` (what its CLI called a session an earlier run kept), `kept` (where that run kept it) and `cwd`, and `fork`, for a side conversation about that session | `side` (`s`), `forked` (bool) | ` has no conversation to ask`, ` is not an agent that can be made here`, `an aside is about a conversation, or asks an agent`, `this frontend has gone` | | `aside` (ask) | `side`, `prompt` | `answer` (str, stripped) | `no aside is open`, `the aside is still answering the last question` | | `unaside` | `side` | — | `no aside is open` | | `status` | — | [status keys](#status) without the † host keys | — | @@ -302,8 +302,8 @@ An exception inside a request is refused with `str(exception)` or its type name. | `to` | The line | | --- | --- | | `""` | Answers a pending *listen* question the sender may answer, else goes to the first conversation with a turn open (in opening order). | -| `` | That role's newest conversation with a turn open; with none open, in `chat` only, answers a pending listen question the sender may answer. | -| `/` | That conversation; with no turn open in it, in `chat` only, answers a pending listen question the sender may answer. | +| `` | That agent's newest conversation with a turn open; with none open, in `chat` only, answers a pending listen question the sender may answer. | +| `/` | That conversation; with no turn open in it, in `chat` only, answers a pending listen question the sender may answer. | | `outworlder:` | Answers that role's pending listen question only; never put into a turn. | A line enters a conversation only while a turn is open, one line per agent at a time; it @@ -342,10 +342,10 @@ first (counted in `elided`), the newest always kept. Every record but `printed` | `type` | Fields | | --- | --- | -| `started` | `flow`, `ref`, `task`, `by` (frontend name), `client`, `roles` (declared agent roles the line fills), `outworlders` (runtime-filled roles), `agents` (`{role: spec}`), `envs` (`{role: spec}`), `params` (object), `budget` (Budget JSON), `profile` (bool), `resume` (`""`, or `str()` of what was given: `"True"`, an epic path), `began` (host monotonic), `at` (Unix time) | -| `opened` | `role`, `key` (`/`), `agent` (agent id), `cli`, `model`, `counts` (sorted token kinds the backend reports), `forks` (bool), `person` (bool), `kept` (directory of the session's logs; `""` for a person or the CLI's own home), `env`, `harness`, `mono`, `wall` (Unix time) | +| `started` | `flow`, `ref`, `task`, `by` (frontend name), `client`, `roles` (declared agent roles the line fills), `names` (`{role: agent name}`: what the agent filling each is called, which its conversations are keyed by), `outworlders` (runtime-filled roles), `agents` (`{role: spec}`), `envs` (`{role: spec}`), `params` (object), `budget` (Budget JSON), `profile` (bool), `resume` (`""`, or `str()` of what was given: `"True"`, an epic path), `began` (host monotonic), `at` (Unix time) | +| `opened` | `role` (the role its agent fills), `key` (`/`), `agent` (the agent's own name, such as `NeiKos496`), `cli`, `model`, `counts` (sorted token kinds the backend reports), `forks` (bool), `person` (bool), `kept` (directory of the session's logs; `""` for a person or the CLI's own home), `env`, `harness`, `mono`, `wall` (Unix time) | | `event` | `key`, `session` (`key` where the event names a conversation, else `""`), `agent`, `cli`, `model`, `ident` (backend's conversation id, `""` until named), `kind`, `text`, `whose`, `tokens`, `spent`, `at`, `mono`. Same fields as [`hmz exec --json`](/reference/cli#ndjson). | -| `asked` | `question` (`q`, host-wide), `role` (default `outworlder`), `text`, `options` (list), `mode` (`ask` where there are options or a turn is open, else `listen`), `key` (`/` of the one conversation with a turn open, else `""`) | +| `asked` | `question` (`q`, host-wide), `role` (default `outworlder`), `text`, `options` (list), `mode` (`ask` where there are options or a turn is open, else `listen`), `key` (`/` of the one conversation with a turn open, else `""`) | | `answered` | `question`, `role`, `by`, `client`, `text` (the option chosen, or the text) | | `withdrawn` | `question`, `why`: `away` (the role went afk) or `over` (the run ended, stopped, was forced or the host closed) | | `said` | `text`, `key`, `by`, `client`: a line a conversation took | diff --git a/docs/reference/sdk.md b/docs/reference/sdk.md index 166e36a21..94266d787 100644 --- a/docs/reference/sdk.md +++ b/docs/reference/sdk.md @@ -384,7 +384,7 @@ places tried after it, in order. | `all()` | `list[Path]` | Every epic, oldest first. | | `read(epic: Path)` | `Ran \| None` | `None` for a directory holding no run. | | `sessions(epic)` | `list[Session]` | Every session, across every record of the epic. | -| `opened(epic)` | `dict[str, list[str]]` | Session ids per agent role. | +| `opened(epic)` | `dict[str, list[str]]` | Session ids per agent, by its name. | | `resumed(flow: str)` | `Path \| None` | The epic `--resume` would pick up. `flow`: canonical ref or name as run. | | `picks_up(epic)` | `bool` | Whether its journal has an entry, it is not going, and it is not `foreign`. | | `foreign(epic)` | `str` | Why an imported run cannot be picked up here (its workspace is not at that path, or lacks the commit its bundle recorded), or `""`. | @@ -615,6 +615,6 @@ them. | Type | Fields | | --- | --- | | `Ran` | `at: Path`, `flow`, `task`, `workspace`, `began`, `ended` (`""` while running or abandoned), `how` (`done`, `failed`, `stopped`, or `""`), `agents: tuple[Drove, ...]`, `sessions: tuple[Session, ...]`, `called: tuple[Called, ...]`, `resumable: bool`, `ref`, `envs: tuple[str, ...]` (as `-e` spells them, a run's kept in an older spelling included), `params: dict`, `budget: dict \| None`, `picked_up: str` (epic name or `""`), `profile: bool` (whether it was [profiled](/reference/tracing#profiling-a-run)); property `name` | -| `Drove` | `agent`, `backend`, `model`, `effort` (`""` = auto), `provider` (`""` = as local); property `spec` (`-a` spelling after `=`) | +| `Drove` | `agent` (the role), `backend`, `model`, `effort` (`""` = auto), `provider` (`""` = as local), `name` (what the agent filling it is called; `""` in an epic written before agents had names); property `spec` (`-a` spelling after `=`) | | `Called` | `flow`, `task`, `record` (file in the epic), `began`, `ended`, `how`, `calls: tuple[Called, ...]` | | `Session` | `agent`, `backend`, `provider` (`local` = as local), `ident` (backend's id), `name`, `at`, `flow`, `parent` (forked-from id or `""`), `record`, `where` (kept-session path), `harness` (`local`, `self`, the `:` of a runtime an affinity sent it to, or `""` for work on this machine) | diff --git a/docs/reference/tracing.md b/docs/reference/tracing.md index 03edea88e..34a41df73 100644 --- a/docs/reference/tracing.md +++ b/docs/reference/tracing.md @@ -322,8 +322,8 @@ JSON Lines, one line per event, appended and flushed as it happens. Every line h | `event` | When | Fields | | --- | --- | --- | -| `began` | the epic opens | `flow` (as named), `ref` (canonical, when known), `task`, `workspace`, `resumable`, `picked_up` (epic name, when resumed), `agents` (list of `{agent, backend, model, effort, provider}`, one per role), `envs` (list of `role=<-e spec>`, as given; in a record with no `spelling`, one written in a spelling `-e` now refuses, `local@/x`, `docker@local/x`, `swarm@local/x`, `apple-container@local/x`, `slurm@local/x`, an unsaved ssh host out of brackets, is read back as `-e` spells it), `used` (the same, where an environment was put on a runtime its `-e` [fell back to](/reference/machines#falling-back); omitted where it equals `envs`), `params`, `budget` (`{duration, cost, output_tokens, graceful}` as JSON; `cost: "Infinity"` for unlimited), `profile` (`true`, only for a [profiled](#profiling-a-run) run), `spelling` (`2`: `envs` and `used` are spelled as `-e` writes `@` only before a provider) | -| `opened` | a session's CLI has given it an id (when it opens, or during its first turn); a session whose CLI never started is not written | `agent` (role), `backend`, `provider` (`local` for the machine's own sign-in), `session` (the CLI's id), `name` (`-@-`, characters outside `[A-Za-z0-9._@-]` → `-`), `where` (`sessions/` relative to the epic, or an absolute path for a session kept elsewhere), `parent` (the id it was forked from, when forked), `harness` (`local`, `self`, or the `:` of the runtime an affinity sent it to; only when its work was on another machine) | +| `began` | the epic opens | `flow` (as named), `ref` (canonical, when known), `task`, `workspace`, `resumable`, `picked_up` (epic name, when resumed), `agents` (list of `{agent, backend, model, effort, provider, name}`, one per role: `agent` is the role, `name` what the agent filling it is called; `name` is absent from an epic written before agents had names of their own, whose sessions were written under the role), `envs` (list of `role=<-e spec>`, as given; in a record with no `spelling`, one written in a spelling `-e` now refuses, `local@/x`, `docker@local/x`, `swarm@local/x`, `apple-container@local/x`, `slurm@local/x`, an unsaved ssh host out of brackets, is read back as `-e` spells it), `used` (the same, where an environment was put on a runtime its `-e` [fell back to](/reference/machines#falling-back); omitted where it equals `envs`), `params`, `budget` (`{duration, cost, output_tokens, graceful}` as JSON; `cost: "Infinity"` for unlimited), `profile` (`true`, only for a [profiled](#profiling-a-run) run), `spelling` (`2`: `envs` and `used` are spelled as `-e` writes `@` only before a provider) | +| `opened` | a session's CLI has given it an id (when it opens, or during its first turn); a session whose CLI never started is not written | `agent` (the agent's own name, such as `NeiKos496`, never the role it fills), `backend`, `provider` (`local` for the machine's own sign-in), `session` (the CLI's id), `name` (`-@-`, characters outside `[A-Za-z0-9._@-]` → `-`), `where` (`sessions/` relative to the epic, or an absolute path for a session kept elsewhere), `parent` (the id it was forked from, when forked), `harness` (`local`, `self`, or the `:` of the runtime an affinity sent it to; only when its work was on another machine) | | `called` | the flow calls a flow | `flow` (callee's canonical ref), `task`, `epic` (the callee's record file name) | | `returned` | that call ends, however | `flow`, `epic` | | `usage` | the run ends | `cost` (USD), `output_tokens`, `seconds`: the run's total as its budget counted it | @@ -334,8 +334,8 @@ budget ran out, or that raised `FlowCancelled`; `failed` for any other exception otherwise. A missing `ended` means the process was killed (or the run is still going). ```json -{"event": "began", "at": "2026-09-30T05:33:55.691Z", "flow": "chat", "task": "hi", "workspace": "/tmp/ws", "resumable": false, "ref": "chat:chat", "agents": [{"agent": "assistant", "backend": "claude", "model": "haiku", "effort": "low", "provider": ""}], "envs": [], "params": {}, "budget": {"duration": null, "cost": "Infinity", "output_tokens": null, "graceful": true}} -{"event": "opened", "at": "2026-09-30T05:33:57.978Z", "agent": "assistant", "backend": "claude", "provider": "local", "session": "56107ec6-dfa6-4484-97dc-ae0f49981bed", "name": "assistant-claude@local-56107ec6-dfa6-4484-97dc-ae0f49981bed", "where": "sessions/claude"} +{"event": "began", "at": "2026-09-30T05:33:55.691Z", "flow": "chat", "task": "hi", "workspace": "/tmp/ws", "resumable": false, "ref": "chat:chat", "agents": [{"agent": "assistant", "backend": "claude", "model": "haiku", "effort": "low", "provider": "", "name": "NeiKos496"}], "envs": [], "params": {}, "budget": {"duration": null, "cost": "Infinity", "output_tokens": null, "graceful": true}} +{"event": "opened", "at": "2026-09-30T05:33:57.978Z", "agent": "NeiKos496", "backend": "claude", "provider": "local", "session": "56107ec6-dfa6-4484-97dc-ae0f49981bed", "name": "NeiKos496-claude@local-56107ec6-dfa6-4484-97dc-ae0f49981bed", "where": "sessions/claude"} {"event": "usage", "at": "2026-09-30T05:33:58.796Z", "cost": 0.0, "output_tokens": 46, "seconds": 2.252695} {"event": "ended", "at": "2026-09-30T05:33:58.796Z", "how": "done"} ``` diff --git a/docs/reference/tui.md b/docs/reference/tui.md index 4f87dd395..5451ad38f 100644 --- a/docs/reference/tui.md +++ b/docs/reference/tui.md @@ -86,7 +86,7 @@ At mount, in the background, the interface: | --- | --- | --- | | all agents (aggregate) | `""` | Every agent's lines and every outworlder's questions and answers. Default view; selected again when a run starts. | | one agent | `` | Every conversation of that role. | -| one conversation | `/` | One session. `` counts from 1 per role per run, in opening order. | +| one conversation | `/` | One session, keyed by its agent's own name (such as `NeiKos496`), not the role it fills. `` counts from 1 per agent per run, in opening order. | | one outworlder | `outworlder:` | What the flow asks through that `Outworlder` role, and the answers. | | monitor | — | A screen of its own: [Monitor](#watching-the-run). | @@ -118,7 +118,7 @@ conversation), `outworlder` and `monitor`. | `hmz: nobody lists a price for [, …], so cost= cannot stop what it spends` | yellow | always, before the first turn of a run whose `cost` limit no agent's price can enforce | | `✻ Worked for s · ` | dim | always | | `❯ · by ` | | a run another frontend started | -| `'s harness runs here` / `'s harness runs on its environment's machine` / `'s harness runs on the runtime :` | dim | once per role per run, where the session reports a harness placement (`opened.harness` not empty) | +| `'s harness runs here` / `'s harness runs on its environment's machine` / `'s harness runs on the runtime :` | dim | once per agent per run, where the session reports a harness placement (`opened.harness` not empty) | | `— stopping the flow —` / `— is stopping the flow —` | | this / another frontend stopped the run | | `hmz: ` / `hmz: stopped -- ` / `— the flow is done —` | red / yellow / — | run ended refused-failed-crashed / by its budget / normally | @@ -276,7 +276,7 @@ Every message is red and prefixed `hmz: `. | `/settings ` | unknown page | `/settings has no page '': choose general, accounts, fallback, runtimes, backup or workspace` | | `/settings flowverses` | a run going | `the flowverses are on /flow now, which offers them while no flow runs` | | `/btw` | no agent to ask | `/btw requires a coding agent` | -| `/btw` | the session is gone | `/btw: no conversation found for /` | +| `/btw` | the session is gone | `/btw: no conversation found for /` | | `/btw ` | busy | `btw is still answering the last question` | ### `/flow [flow]` {#flow-command} @@ -469,7 +469,7 @@ also in the conversation it is part of, where it is one: | Leave | `/btw` alone or `esc`: `btw: exited`; a run starting: `btw: exited -- a new flow started`. Side sessions are closed (`unaside`). | | Target | A `session` view: that conversation (for a role view, its newest). Any other view: the **btw agent** — the one set on [General](#general-page), else the flow's first declared role with a session, else its configured agent. | | Session target | First question opens `aside(key, fork=True)`; where the CLI forks, the fork answers. Otherwise, or if the fork answers nothing, a fresh session of the same agent seeded with a snapshot of the run filtered to that role. Ended sessions included. | -| btw agent | Seeded with a snapshot (the last 32 of up to 80 observations, 600 characters each) and the list of sessions. It may reply with lines `@ask /: ` (regex `^\s*@ask\s+(\S+?)\s*:\s*(\S.*)$`); each is put to that session's side copy (`btw · asking : `), and answers return as `…`. At most 4 asks per question; failures return as `(session not found)`, `(could not ask: )`, `(no answer)`. | +| btw agent | Seeded with a snapshot (the last 32 of up to 80 observations, 600 characters each) and the list of sessions. It may reply with lines `@ask /: ` (regex `^\s*@ask\s+(\S+?)\s*:\s*(\S.*)$`); each is put to that session's side copy (`btw · asking : `), and answers return as `…`. At most 4 asks per question; failures return as `(session not found)`, `(could not ask: )`, `(no answer)`. | | Side sessions | Read-only permission, no goals, no skills, no allowed tools, MCP approval off, none of the flow's hooks. | | Answer | `●` (cyan) `btw · ` (dim), then the answer in cyan, continuation lines indented 2, on the current view. | | Failures | `hmz: btw is still answering the last question`, `hmz: /btw could not read flow progress: `, `hmz: /btw could not start: `, `hmz: /btw: ` (e.g. `the agent returned no answer`). | @@ -1771,7 +1771,7 @@ In order: the all-agents node, outworlder nodes, the graph or list, the board. | all agents | `▣ all agents · of working · turn(s) · [ · reading]`; `` agents drawn, `` all their turns, `` the run's age | reads the aggregate | | outworlder | `◉ · outworlder: messages from the flow to you[ · yours\|'s][ · reading]`; declared order, then order first asked | reads its view | | agent box | see [Graph](#monitor-graph) | reads the role's view | -| session | see Graph | reads `/` | +| session | see Graph | reads `/` | | environment | `▤ · · · ` | opens its [page](#an-environment-s-page) | | board line | `◈ [ · flow's\|user's]` | edits it; refused for a `flow` line: ` can only be changed by the flow` | | `+ add entry` | | adds a board line | diff --git a/docs/reference/web.md b/docs/reference/web.md index a9ddf1f15..fb698a9f1 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -136,7 +136,7 @@ Each view is an address after `#`, so it can be kept and opened again: | `#/epics/import?path=` | Imports the `.hmzepic` at that whole path on this machine, as [`POST /api/runs/import`](#api-import) does -- in the desktop app as it is opened; in a browser, which any page elsewhere could have sent here, once **Import** under the path is pressed -- then stands in for itself with `#/epics//exomyth`, its [Exomyth](/user/web#exomyth) -- going back does not import it again; a refusal is said in its place, with the epic's own address where it is here already. What the desktop app is to open a `.hmzepic` with. | | `#/epics/` | One epic, by its directory's name, drawn as a graph of its agents; live where it is the epic going. | | `#/epics//agents/` | The same graph, with that agent picked out. | -| `#/epics//sessions//` | The thread of the `n`-th session that role opened, as the epic going names it (`/` written `%2F`). | +| `#/epics//sessions//` | The thread of the `n`-th session that agent opened, as the epic going names it (`/` written `%2F`). | | `#/epics//logs[/]` | The log every agent and person appears on; or one agent's, across its sessions; or one person's, what they are asked and answer. | | `#/epics//exomyth[?tab=flame]` | The epic's [Exomyth](/user/web#exomyth): its timeline, or its flame graph. | | `#/flows[?workspace=&flow=]` | The flows on offer in a workspace, one opened. | @@ -171,7 +171,7 @@ JSON in and out. A `POST` takes a JSON object, `{}` where nothing is sent. | `GET /api/held` | | `machine` (this machine's name), `here` (where `hmz web` started), `version`, `newer` (a newer release of `hmz` PyPI lists, asked as `hmz web` starts and at most once a day, or `""`), `running` (`{workspace: run}`: the [`run` snapshot](/reference/daemon#snapshots) of each workspace whose epic is going, or what its host's `status` says of it) | | `GET /api/held/stream` | `?workspace=`; `Last-Event-ID` header, or `?last=` | [The stream](#stream) of that workspace's runs, attaching to them | | `POST /api/held/start` | `workspace`; `flow`, `task` (both required); `agents`, `envs` (`{role: spec}`); `params`, `budget` (object or `null`); `profile` (bool); `resume` (bool, or an epic); `backup` (target names, `[]` for none; `null`, the default, for the defaults) | The host's answer: `run` | -| `POST /api/held/say` | `text`; `to` (`""`, a role, `/`, or `outworlder:`, routed as [`say` routes it](/reference/daemon#say-routing)) | The host's answer | +| `POST /api/held/say` | `text`; `to` (`""`, an agent, `/`, or `outworlder:`, routed as [`say` routes it](/reference/daemon#say-routing)) | The host's answer | | `POST /api/held/answer` | `question`, `text` | The host's answer | | `POST /api/held/stop`, `/force` | | The host's answer | | `POST /api/held/afk` | `on` (bool); `role` | The host's answer | @@ -179,7 +179,7 @@ JSON in and out. A `POST` takes a JSON object, `{}` where nothing is sent. | `POST /api/held/release` | `role` | The host's answer | | `POST /api/held/board` | `key`; `value` (`""` removes the line) | The host's answer | | `GET /api/btw` | `?workspace=` | `open` (the conversations a side one is open on, `""` the btw agent's) and `said` (each question answered since, oldest first, as `POST` answers it; the last **200**) | -| `POST /api/btw` | `workspace`; `question`; `to` (`""` the btw agent, or `/`); `epic` (the epic `to` is of: a session of one the runs hold no more is carried on from where that epic kept it, its conversation answered as `//`) | `to`, `question`, `answer`, and `asked`: `[{to, question}]`, each session the btw agent asked on the way | +| `POST /api/btw` | `workspace`; `question`; `to` (`""` the btw agent, or `/`); `epic` (the epic `to` is of: a session of one the runs hold no more is carried on from where that epic kept it, its conversation answered as `//`) | `to`, `question`, `answer`, and `asked`: `[{to, question}]`, each session the btw agent asked on the way | | `POST /api/btw/leave` | `workspace`; `to`, or nothing for every side conversation | `{"ok": true}` | Every request about one workspace's runs names it as `workspace` -- in the query of a `GET`, in diff --git a/docs/user/btw.md b/docs/user/btw.md index e524f523a..34fcd8bfb 100644 --- a/docs/user/btw.md +++ b/docs/user/btw.md @@ -106,7 +106,7 @@ same side conversation, so a follow-up can say "and then?". The status line star Who answers depends on what is on the screen when you type `/btw`: -- **One conversation's transcript** (`btw · builder/2`): a side copy of that conversation. +- **One conversation's transcript** (`btw · NeiKos496/2`): a side copy of that conversation. Where its CLI can fork a conversation (Claude Code, Codex, Grok Build, Kimi Code, MiMo Code, Oh My Pi, opencode, pi, Qwen Code, and a CLI you added that speaks ACP), the copy is a fork and knows everything the conversation knew. Otherwise, or if the fork fails, it is a fresh @@ -136,7 +136,7 @@ btw agent answers. Step through it: What to look at, by number: 1. **`btw · btw agent`.** Who is answering. On one conversation's transcript this names that - conversation instead, such as `btw · reviewer/1`. + conversation instead, such as `btw · PoleMos600/1`. 2. **`each line is a question; /btw or esc to exit`.** You are in btw mode now. A plain line typed from here is a question, not a steer. 3. **The status line** starts with `btw · btw agent` until you leave, so you cannot forget you diff --git a/docs/user/export.md b/docs/user/export.md index f7a3046d8..8a7bd6254 100644 --- a/docs/user/export.md +++ b/docs/user/export.md @@ -184,6 +184,7 @@ This one is a resumed run of `stateful_ralph` that was profiled, so it has both "agents": [ { "agent": "agent", + "name": "NeiKos496", "backend": "claude", "model": "claude-haiku-4-5-20251001", "effort": "low", diff --git a/docs/user/monitor.md b/docs/user/monitor.md index 4afc3ce18..056a3ec0d 100644 --- a/docs/user/monitor.md +++ b/docs/user/monitor.md @@ -372,7 +372,7 @@ out: | `image` | What a container for it is started from, where the flow names one. | | `grants` | The capabilities the flow declared for the role: `Shell`, `Files`, `GitWorktree`, … or `nothing beyond running in it`. | | `needs` | What the flow asks of the machine: CPUs, memory, GPUs. | -| `harness` | Where the run put the harnesses of the agents working in it, such as `self: on this environment's machine, with the CLI installed there` or `local: on this machine; what it runs lands here`. Where sessions went different ways, each is named: `self for builder/1 · local for reviewer/1`. See [Remote execution](/user/remote-execution). | +| `harness` | Where the run put the harnesses of the agents working in it, such as `self: on this environment's machine, with the CLI installed there` or `local: on this machine; what it runs lands here`. Where sessions went different ways, each is named: `self for NeiKos496/1 · local for PoleMos600/1`. See [Remote execution](/user/remote-execution). | | `status` | How many of its sessions are working now, such as `1 of 3 sessions working`. | | `Sessions` | Each session working in it. enter or a click on one reads it. | diff --git a/docs/user/tracing.md b/docs/user/tracing.md index df7388fae..70c5b4b6b 100644 --- a/docs/user/tracing.md +++ b/docs/user/tracing.md @@ -112,7 +112,7 @@ much as the CLI logged. - **The summary line has sessions in it.** `0 sessions, 0 slices` means the trace is empty; see [If it goes wrong](#if-it-goes-wrong). -- **Perfetto shows one process per agent**, each named after its role and model, with as +- **Perfetto shows one process per agent**, each named after its agent and model, with as many sessions as `/epics` counted. - **The slices read like the transcript.** The first tool calls of the first session are the first things the agent did in the run. diff --git a/specs/coganchor/agents.md b/specs/coganchor/agents.md index 680b8ac1f..161c1a900 100644 --- a/specs/coganchor/agents.md +++ b/specs/coganchor/agents.md @@ -216,7 +216,6 @@ class AgentBase(ABC): skills: Iterable[Loaded] | None = None, ) -> Self: ... def reconfigure(self, config: AgentConfig) -> None: ... - def rename(self, name: str) -> None: ... def runs_on(self, machine: MachineConfig | None) -> None: ... def loads(self, skills: Iterable[Loaded]) -> None: ... def disable_goals(self) -> None: ... @@ -720,8 +719,8 @@ class BackendAgent(AgentBase): (`Unserved`) before its first turn rather than part way through one. - What is true of a CLI rather than of driving it -- forking, resuming, web search, hook tables, preloads, bundle fingerprints -- MUST be read off `hmz.coganchor.backends`, not declared here. -- `AgentBase.id` MUST be the name given, else a codename nothing else in the process answers to; - `rename` MUST take a name only for an agent that was not named where it was made. +- `AgentBase.id` MUST be the name given, else a codename nothing else in the process answers to, + and MUST NOT change after: a flow's role is the parameter an agent fills, not its name. - `new` MUST open a session rooted at the directory given, and every turn of it MUST run there; a directory that is not there, or one outside an anchored agent's workspace, MUST be refused before the turn. diff --git a/specs/runtime/SPEC.md b/specs/runtime/SPEC.md index 1d8d8c538..ba3b3e698 100644 --- a/specs/runtime/SPEC.md +++ b/specs/runtime/SPEC.md @@ -105,11 +105,12 @@ class Session(NamedTuple): where: str = "" # where it is kept: from the epic, or whole where it stayed at home harness: str = "" # where its harness ran, for work elsewhere: local, self, : class Drove(NamedTuple): # one agent role, and what it was given - agent: str + agent: str # the role backend: str model: str effort: str provider: str = "" + name: str = "" # what the agent filling it is called, its sessions written down under it @property def spec(self) -> str: ... class Called(NamedTuple): @@ -428,9 +429,9 @@ class Keeping: # one epic's policies, as it runs flow over the drivers with the workspace as every `LocalEnv` role and whoever is outside the run as every `Outworlder` role -- nobody, away, where none was given -- MUST write the run down as it goes: each flow call a record under the one that made it, saying the task it - was called with, each session in the record of the call that opened it and named for its - role, and what the run spent -- holding no session past its close to count it; and - MUST close every driver it was given however the run ends. A run stopped from outside, or + was called with, each session in the record of the call that opened it and under its + agent's own name, and what the run spent -- holding no session past its close to count it; + and MUST close every driver it was given however the run ends. A run stopped from outside, or by its budget, MUST be written down as stopped rather than failed. - A run MUST read back what its budget counted as it ended -- what it cost, the output tokens and the seconds -- as `Ran.spent`, and nothing for a run still going or one killed before it diff --git a/specs/runtime/doing.md b/specs/runtime/doing.md index 0a6c63472..275309ebb 100644 --- a/specs/runtime/doing.md +++ b/specs/runtime/doing.md @@ -94,7 +94,9 @@ class Run: usage: Usage; epic: Path | None; running: bool; raised: BaseException | None result: Any # properties @property - def agents(self) -> tuple[AgentBase, ...]: ... # behind each session still open, by role + def agents(self) -> tuple[AgentBase, ...]: ... # behind each session still open, by name + @property + def names(self) -> dict[str, str]: ... # what the agent filling each role is called def unreadable(self) -> str: ... def watch(self, listener: Listener) -> None: ... def opened(self, callback: Callable[[str, AgentBase, SessionBase], None]) -> None: ... @@ -351,8 +353,9 @@ class Fallbacks: the order it happened and held to a ceiling in bytes, the oldest whole records going first and counted. A text too long to carry MUST be cut, and what was cut counted. - `record` MUST be the one shape an agent's event is written in, for every frontend and for - `hmz exec --json` alike, and a conversation MUST be keyed `/` once, as it opens, so - that every frontend names it alike. + `hmz exec --json` alike, and a conversation MUST be keyed `/` once, as it opens -- + by the agent's own name, never the role it fills -- so that every frontend names it alike. + A run started MUST say what the agent filling each of its agent roles is called. - An `Outworlder` role MUST be claimed by one frontend at a time -- refused to another unless it takes it over -- and given back when that frontend goes. Only its claimant MUST answer for a claimed role; a role nobody claims MUST be answered by whoever answers first, and a later @@ -365,7 +368,7 @@ class Fallbacks: is away for a role somebody else holds, and saying it is away for everything MUST leave those roles as they were. - A line said to a run MUST go into the turn open on the view it was said on -- a conversation, - a role's newest, or the first working -- one at a time per agent until the agent says it has + an agent's newest, or the first working -- one at a time per agent until the agent says it has it; said while no turn is open it MUST go into whichever turn starts next for it -- unless the run is `chat`'s and waiting to be told what to say next, which a line said to its agent or conversation with no turn open MUST answer, going into no turn a second time; every other diff --git a/specs/runtime/flowing.md b/specs/runtime/flowing.md index 779f3296a..bf5e616c3 100644 --- a/specs/runtime/flowing.md +++ b/specs/runtime/flowing.md @@ -44,7 +44,7 @@ class HookBridge: # here() / call(make, *, default) / abandon() / close() @dataclass(frozen=True, slots=True) class Kept: ... # harness, id, at: a conversation a session kept, copied class SessionHandle(Protocol): ... # id, usage, keep, turn, move, steer, interrupt, close -class AgentDriver(Protocol): ... # harness, model, effort, provider, capabilities, open, close +class AgentDriver(Protocol): ... # name, harness, model, effort, provider, capabilities, open, close class EnvDriver(Protocol): ... # backend, provider, workdir, capabilities, resources, exec, # read, write, derive_*, destroy_*, snapshot, rewind, # snapshots, placement, close @@ -418,6 +418,9 @@ def under() -> Path: ... # machine()/skills nothing by itself. Only a flow marked with `full_view` MUST be granted its harness's all. - `derive` MUST only narrow: a permission the grant covers, skills the grant names. An agent derived from another MUST share its hooks and its sessions. +- An agent MUST be called by its driver's `name` -- a codename nothing else in the process + answers to, for a driver made of a spec -- and every session of it MUST be the agent of that + name, whichever role it fills: a role is the parameter a flow takes it as, not its name. - A role's `_tools` MUST be offered by every session of its agent, and a call of one MUST run its function on the run's loop as the flow the agent belongs to; what it raises MUST be answered to the agent as the tool failing, and MUST NOT fail the turn. A role's `_clis` MUST diff --git a/specs/runtime/tracing.md b/specs/runtime/tracing.md index 5078ff470..957915e91 100644 --- a/specs/runtime/tracing.md +++ b/specs/runtime/tracing.md @@ -37,10 +37,12 @@ def stacked(spans: Iterable[tuple[float, float]]) -> list[tuple[int, int]]: ... # tracing/timeline.py -- Exomyth VERSION: int class Opened(Protocol): ident: str; agent: str; record: str # epic.Session is one +class Drove(Protocol): agent: str; name: str # epic.Drove is one class Call(Protocol): # epic.Called is one flow: str; task: str; record: str; began: str; ended: str; how: str; calls: Sequence[Call] class Epic(Protocol): # epic.Ran is one name: str; flow: str; task: str; began: str; ended: str; how: str; sessions: Sequence[Opened] + agents: Sequence[Drove] @dataclass(frozen=True, slots=True) class Timeline: document: dict[str, Any] @@ -112,10 +114,12 @@ def read(at: str | os.PathLike[str]) -> list[Process]: ... ordered by start, programs by start then pid. Each MUST carry `id` (its index), `kind`, `name`, `key`, `pid` (0 but for a program), `start`, `end`, `parent` (a process id, or -1), `tracks` (track ids, in lane order), and `agent`, `role`, `backend`, `model`, `label` and - `record`. A sub-agent's parent MUST be the session that started it, a session the epic - opened -- and a sub-agent whose starter did nothing -- MUST hang from the `flow` process, - and a program's MUST be the program that held its parent's pid when it started, where that - was profiled. + `record`. A session's `role` MUST be the one its agent filled, read off the epic's agents by + the agent's `name` -- or by the role, for an agent written down with none -- and "" for one + no agent of the epic's names. A sub-agent's parent MUST be the session that started it, a + session the epic opened -- and a sub-agent whose starter did nothing -- MUST hang from the + `flow` process, and a program's MUST be the program that held its parent's pid when it + started, where that was profiled. - The `flow` process MUST hold a span for the epic and one for every flow it called, each inside its caller; a call that has not returned, and an epic that has not ended, MUST end at the last moment anything in the document did. A session's MUST hold a `session` span over diff --git a/specs/runtime/watching.md b/specs/runtime/watching.md index 310b42f96..375ed58d6 100644 --- a/specs/runtime/watching.md +++ b/specs/runtime/watching.md @@ -38,7 +38,8 @@ def thousands(count: float) -> str: ... def short(agent: str) -> str: ... def tallied(monitor: Monitor) -> tuple[str, str] | None: ... # the kinds line, the money line # tally.py -- what a run has spent, read off the logs its agents keep -class Seen: ... # one session: id, backend, model, counts, idents, kept, since +class Seen: ... # one session: id (its agent's name), backend, model, counts, idents, kept, + # since, role class Tally: def __init__(self, sessions: Sequence[Seen], monitor: Monitor) -> None: ... def add(self, session: Seen) -> None: ... @@ -51,7 +52,7 @@ class Following: run: int monitor: Monitor tally: Tally - seen: dict[str, Seen] # by `/` + seen: dict[str, Seen] # by `/` def started(self, record: Mapping[str, Any]) -> None: ... def opened(self, record: Mapping[str, Any]) -> Seen | None: ... def heard(self, record: Mapping[str, Any]) -> str | None: ... @@ -69,7 +70,7 @@ def format_turn(question: str) -> str: ... def format_answers(answers: list[tuple[str, str]], *, more: bool) -> str: ... def asked(answer: str) -> tuple[list[tuple[str, str]], str]: ... def compact(text: str, limit: int = ...) -> str: ... -def opened_as(chosen: str, roles: Mapping[str, str], +def opened_as(chosen: str, agents: Mapping[str, str], sessions: Sequence[str]) -> dict[str, str] | None: ... class Left(Exception): ... class Btw: @@ -105,15 +106,16 @@ class Btw: - `lasting` MUST say a clock as every frontend says it: seconds under a minute, minutes and seconds under an hour, hours and minutes above. - What a side question is told MUST be bounded, MUST be the run in front alone -- what its - agents did and said, and what its flow printed -- MUST leave out an agent's thinking, and - MUST start empty with each run. + agents did and said, and what its flow printed -- MUST leave out an agent's thinking, MUST + say each agent by its own name with the role it fills after it, and MUST start empty with + each run. - A side question MUST go to a side conversation and never to the run: in a conversation's name, to a read-only fork of it where its CLI forks one, and where the fork will not open or will not answer, to a copy of its agent told the snapshot; otherwise to the btw agent, told the snapshot and the conversations it may ask by `@ask : `, at most `HOPS` of them on one question, each answered back to it. -- The btw agent MUST be the agent `/settings` names; else the flow's first role, as its first - conversation where it has one or as the agent it runs as; else the run's first conversation; - and none where there is none of these. +- The btw agent MUST be the agent `/settings` names; else the agent of the flow's first role, as + its first conversation where it has one or as what it runs as; else the run's first + conversation; and none where there is none of these. - A mode left MUST close every side conversation it opened, and one that opens after it was left MUST be closed at once, the question that opened it raising `Left`. diff --git a/specs/tui.md b/specs/tui.md index b7aaaa58b..c0d143ece 100644 --- a/specs/tui.md +++ b/specs/tui.md @@ -56,6 +56,9 @@ Textual's `run()` opens it; `action_quit` stops what is running and leaves. - MUST round the shared transcript, the conversations with a turn open and the outworlders of a running flow, forward on `shift+tab` and back on `tab`, a conversation that has ended leaving the round while staying readable from the monitor. +- MUST call every agent of a run by its own name -- its transcript, each of its conversations + as `/`, its node on the monitor, the line saying it is working -- from the moment the + run says it, before it has opened any, the role it fills only ever after that name. - MUST show which agent is read, how many conversations it holds, whether it is working and whether it has something unread — nothing unread while the shared transcript is read — and each outworlder of a running flow, whether it is asking, away, read or unread. @@ -104,7 +107,7 @@ Textual's `run()` opens it; `action_quit` stops what is running and leaves. finish -- be set and read on the page its roles are on, and whether a run is profiled as well as traced be switched and read beside it, off until it is switched on, and remembered with the budget per flow; a run started MUST be profiled exactly where that says so. -- MUST say where a role's harness went as its first session on another machine opens: here, +- MUST say where an agent's harness went as its first session on another machine opens: here, on its environment's machine, or on the runtime its affinity sent it to. - MUST make an agent a CLI -- or litellm, a model called directly -- an account, a model and an effort and nothing else, offering only CLIs installed here whose harness is the one the role names and serves what the role asks, diff --git a/specs/web.md b/specs/web.md index 2fab54d94..31fbab83a 100644 --- a/specs/web.md +++ b/specs/web.md @@ -159,6 +159,9 @@ until `Site.shutdown`. `serve` is both, until it is interrupted. across its sessions, one per person of what they are asked and answer, and one they all appear on -- a line said in one going to whose it is, and answering instead what that session's open turn asks. +- MUST name every agent of a run, going or written down, by its own name -- its sessions + `/`, its node, its transcript, its address and what is said to it -- the role it + fills said only after the name, as the parameter it is; and a person by the role they answer. - MUST open a chat started from it on its conversation, read as a chat app reads one: the task and every answer as the person's lines among the agent's, a line typed under it the next turn, what to say next never drawn as a question, and what the agent asks mid-turn asked at diff --git a/src/hmz/coganchor/agents/base.py b/src/hmz/coganchor/agents/base.py index 4e8eedaf9..22304a375 100644 --- a/src/hmz/coganchor/agents/base.py +++ b/src/hmz/coganchor/agents/base.py @@ -3899,9 +3899,6 @@ def __init__(self, config: AgentConfig, *, name: str | None = None) -> None: #: must outlive the conversations it spent it in. self._meter = Meter() self._id = name or codename() - #: Whether that name is the agent's own, rather than one to be told by whatever ends - #: up driving it: a flow that names the agents it takes names the ones that are not. - self._named = name is not None #: What this agent has opened, is watched by, and still holds. Every one of them is #: written from whichever thread a turn is running on and read from whichever thread #: is asking, and a flow may have ten thousand of both, so each is touched under this @@ -4599,21 +4596,6 @@ def _also_allowed(self, made: AgentBase) -> None: made.allowance = held held.enrol(made) - def rename(self, name: str) -> None: - """Calls this agent what the flow driving it calls it, if it has no name of its own. - - A flow that declares its agents as a named tuple has said what each of them is for -- - builder, reviewer -- and that is a better name than a codename out of somebody else's - story, which is what an agent nobody named has. One handed an agent that was named - where it was made says nothing: the name it was given is the name. - - Args: - name: What the flow calls this one. - """ - if not self._named: - self._id = name - self._hooks.agent = name - def loads(self, skills: Iterable[Loaded]) -> None: """Says which skills every session this agent opens is to be given. diff --git a/src/hmz/runtime/doing/hosting.py b/src/hmz/runtime/doing/hosting.py index a8fdda1e3..5b091c4b8 100644 --- a/src/hmz/runtime/doing/hosting.py +++ b/src/hmz/runtime/doing/hosting.py @@ -2,7 +2,7 @@ A run used to have one person outside it: whoever was at the one interface that started it. The queue of lines typed at it, the questions it stopped on, who was away, which conversation -was `builder/2` -- all of it lived in that interface, because that interface was the only one. +was `NeiKos496/2` -- all of it lived in that interface, because that interface was the only one. A day's work watched by two people, or by a person and a bot, is two frontends on one run, and each of those things has to be answered once rather than once per frontend or the two disagree about what the run is doing. @@ -21,7 +21,7 @@ - *away*: per role and not per frontend, since it is a fact about the run -- whether a question of that role is waited on -- and it outlives the frontend that said it. - *lines*: what is said to the run's agents, queued per target and handed over one at a time. -- *keys*: which conversation is `/`, numbered once so every frontend names it alike. +- *keys*: which conversation is `/`, numbered once so every frontend names it alike. - *asides*: the side conversations `/btw` opens, read-only and invisible to the run. - *history*: every record of the run, in order, so a frontend arriving late reads it all. @@ -108,7 +108,7 @@ def record( session: The conversation it was said in, or None for something the agent said for all of them. event: What was said. - key: The transcript it goes on: the conversation's `/`, or where the agent's + key: The transcript it goes on: the conversation's `/`, or where the agent's words go when no one conversation said them. run: Which run of the host it belongs to, or 0 for none. @@ -950,6 +950,9 @@ def _start(self, frontend: _Frontend, said: dict[str, Any]) -> dict[str, Any]: "by": frontend.name, "client": frontend.client, "roles": [one.name for one in declared.agents if not one.auto], + # What the agent filling each is called, which its sessions are keyed by: + # the role is only the parameter it is taken as. + "names": run.names, "outworlders": [one.name for one in declared.agents if one.auto], "agents": _spoken(agents) if isinstance(agents, dict) else {}, "envs": _spoken(envs) if isinstance(envs, dict) else {}, @@ -1172,7 +1175,8 @@ def _opened( board: Board | None = None with self._lock: # Numbered now, in the order the run opens them, rather than as each first - # speaks: `builder/2` is the second conversation a builder opened. + # speaks: `NeiKos496/2` is the second conversation NeiKos496 opened, whichever + # role it fills. key = self._key_of(current, agent, session) current.agents.append(agent) if person: @@ -1223,7 +1227,7 @@ def _key_of( ) -> str: """The transcript a conversation's lines go on, numbering it the first time. - What no one conversation said goes on the one working, or the newest, and on the role + What no one conversation said goes on the one working, or the newest, and on the agent alone where it has none. Under the lock. """ if session is None: @@ -1386,7 +1390,7 @@ def _picks( def _target(current: _Run, to: str) -> tuple[AgentBase, SessionBase] | None: """The conversation a line said on one view goes into, where one is working. - A conversation's own view is that conversation; a role's is its newest working one; + A conversation's own view is that conversation; an agent's is its newest working one; and every agent's is the first working one in the order the flow opened them. """ from hmz.coganchor.agents import HumanAgent diff --git a/src/hmz/runtime/doing/running.py b/src/hmz/runtime/doing/running.py index 7e68d5163..a57204ede 100644 --- a/src/hmz/runtime/doing/running.py +++ b/src/hmz/runtime/doing/running.py @@ -115,7 +115,7 @@ def usage(self) -> Usage: def agents(self) -> tuple[AgentBase, ...]: """The coganchor agent behind each session of the run still open, oldest first. - Each is named for the role it was opened for, which is what its events say. Only the + Each is called by its own name, which is what its events say. Only the open ones: a run that opens a session a round for a week holds no more than one that opened one, and a session that closed has no agent left to reach. """ @@ -125,6 +125,14 @@ def agents(self) -> tuple[AgentBase, ...]: held = (getattr(one, "agent", None) for one in recorder.sessions) return tuple(one for one in held if one is not None) + @property + def names(self) -> dict[str, str]: + """What the agent filling each agent role is called, by role. + + Which is what its sessions are keyed by: the role is only the parameter it is taken as. + """ + return {role: one.name for role, one in self._runner.agents.items()} + @property def epic(self) -> Path | None: """The epic the run is written into, once it has started.""" diff --git a/src/hmz/runtime/epic.py b/src/hmz/runtime/epic.py index b5311c5ec..3e2faa6b7 100644 --- a/src/hmz/runtime/epic.py +++ b/src/hmz/runtime/epic.py @@ -238,6 +238,8 @@ class Drove(NamedTuple): model: What that CLI was asked to run. effort: How hard it was asked to think, "" for the CLI's own default. provider: The account it was configured to run as, or "" for this machine's own. + name: What the agent is called, which its sessions are written down under; "" for + one written before an agent had a name of its own. """ agent: str @@ -245,6 +247,7 @@ class Drove(NamedTuple): model: str effort: str provider: str = "" + name: str = "" @property def spec(self) -> str: @@ -360,7 +363,7 @@ def called(agent: str, backend: str, provider: str, ident: str) -> str: alone is what a trace is gathered by, and a name without it would name two. Args: - agent: Whose session it is, by the name the flow calls that agent. + agent: Whose session it is, by the agent's own name. backend: The CLI that took its turns. provider: The account they ran as, or "" for this machine's own. ident: The backend's own id for it. @@ -962,7 +965,7 @@ def session( """Writes down a session, as :meth:`opened` does, for one no coganchor agent opened. Args: - agent: Whose it is, by the role the flow calls that agent. + agent: Whose it is, by the agent's own name -- not the role it fills. backend: What took its turns. provider: The account they ran as, or "" for this machine's own. ident: Its id. @@ -1262,6 +1265,7 @@ def read(epic: Path) -> Ran | None: model=str(said.get("model") or ""), effort=str(said.get("effort") or ""), provider=str(said.get("provider") or ""), + name=str(said.get("name") or ""), ) ) envs = began.get("envs") diff --git a/src/hmz/runtime/exporting.py b/src/hmz/runtime/exporting.py index 3a17ee43f..9de91c1d9 100644 --- a/src/hmz/runtime/exporting.py +++ b/src/hmz/runtime/exporting.py @@ -1046,6 +1046,9 @@ def _manifest( "agents": [ { "agent": one.agent, + # What the agent filling that role was called, which its sessions are under: + # the role itself, for a run written before an agent had a name of its own. + "name": one.name or one.agent, "backend": one.backend, "model": one.model, "effort": one.effort, diff --git a/src/hmz/runtime/flowing/fakes.py b/src/hmz/runtime/flowing/fakes.py index 13da3983d..1ea36733a 100644 --- a/src/hmz/runtime/flowing/fakes.py +++ b/src/hmz/runtime/flowing/fakes.py @@ -726,6 +726,7 @@ class FakeAgentDriver: :class:`~hmz.flows.SessionError`. names_late: Whether a session says its id only once its first turn has started, as a real CLI's does, rather than as it opens. + name: What it is called, or None for a codename, as a real driver's is. Attributes: sessions: Every session it opened, in order. @@ -748,7 +749,11 @@ def __init__( seconds: float = 0.0, forks: bool | None = None, names_late: bool = False, + name: str | None = None, ) -> None: + from hmz.coganchor.agents.codenames import codename + + self.name = name or codename() self.harness = HarnessKind(harness) self.capabilities = ( HARNESS_CAPABILITIES[self.harness] diff --git a/src/hmz/runtime/flowing/harnesses.py b/src/hmz/runtime/flowing/harnesses.py index b44558856..56867fd32 100644 --- a/src/hmz/runtime/flowing/harnesses.py +++ b/src/hmz/runtime/flowing/harnesses.py @@ -264,6 +264,7 @@ class HarnessDriver: "_installed", "_kind", "_listeners", + "_name", "_profile", "_sessions", "_spec", @@ -287,9 +288,14 @@ def __init__( profile: What coganchor knows of the CLI, or None for one it knows nothing of. harbors: The run's harness runtimes, or None for no affinity anywhere. """ + from hmz.coganchor.agents.codenames import codename + self._spec = spec self._kind = kind self._config = config + #: Every session's agent is built under it: a session's own agent is this one + #: agent's conversation, and two of them are not two agents. + self._name = codename() self._profile = profile self._harbors = harbors self._capabilities = HARNESS_CAPABILITIES[spec.harness] @@ -305,6 +311,11 @@ def __init__( self._fences: dict[tuple[str, bool], tuple[type[HarnessError], str] | None] = {} self._asking = asyncio.Lock() + @property + def name(self) -> str: + """What the agent is called: a codename nothing else in the process answers to.""" + return self._name + @property def harness(self) -> HarnessKind: """Which CLI it is.""" @@ -539,7 +550,7 @@ def _made_as(self, config: AgentConfig) -> AgentBase: from hmz.coganchor.agents import Unfenced try: - return self._kind(config) + return self._kind(config, name=self._name) except Unfenced as refused: raise HarnessSandboxed(f"{self._spec}: {refused}") from refused except ValueError as refused: diff --git a/src/hmz/runtime/flowing/spi.py b/src/hmz/runtime/flowing/spi.py index 6623fe478..f29f2f176 100644 --- a/src/hmz/runtime/flowing/spi.py +++ b/src/hmz/runtime/flowing/spi.py @@ -643,6 +643,15 @@ class AgentDriver(Protocol): :data:`HARNESS_CAPABILITIES` for its harness. """ + @property + def name(self) -> str: + """What the agent is called, which every session of it is keyed by. + + Its own, and not the role it fills: a role is only the parameter a flow takes it as, + and the same agent handed on to a flow it calls fills another under the same name. + """ + ... + @property def harness(self) -> HarnessKind: ... diff --git a/src/hmz/runtime/runner.py b/src/hmz/runtime/runner.py index 456839586..3c4d8f28b 100644 --- a/src/hmz/runtime/runner.py +++ b/src/hmz/runtime/runner.py @@ -722,7 +722,10 @@ async def arun( task, self._workspace, ref=impl.ref, - agents=[_drove(role, spec) for role, spec in self._specs.items()], + agents=[ + _drove(role, spec, self._agents[role].name) + for role, spec in self._specs.items() + ], envs=[f"{role}={spec}" for role, spec in self._places.items()], used=[f"{role}={spec}" for role, spec in self._used.items()], # Through JSON text rather than `mode="json"`, which leaves an infinite cost a @@ -951,7 +954,7 @@ def left(self, call: LiveCall, error: BaseException | None) -> None: def spawned( self, call: LiveCall, role: str, session: SessionHandle, driver: AgentDriver ) -> None: - """A flow call opened a session: named for its role, and written into its record.""" + """A flow call opened a session: written into its record, under its agent's own name.""" with self._lock: self._live[id(session)] = session record = self._records.get(id(call), self._epic) @@ -960,18 +963,24 @@ def spawned( # A driver with no coganchor agent behind it -- a fake -- is written down here, # once it has said what it calls the session: now, or when it is `named`. if session.id is not None: - record.session(role, str(driver.harness), driver.provider, session.id) + record.session( + driver.name, str(driver.harness), driver.provider, session.id + ) return - # Named for its role, and written down in the record of the call that opened it - # once its CLI has said what it calls the conversation, which is its first turn. - agent.rename(role) + # Written down in the record of the call that opened it once its CLI has said what + # it calls the conversation, which is its first turn. Under the agent's own name: the + # role is only the parameter it fills. agent.epic = record conversation: SessionBase | None = getattr(session, "coganchor", None) if self._opened is not None and conversation is not None: self._opened(role, agent, conversation, getattr(session, "placement", None)) def named( - self, call: LiveCall, role: str, session: SessionHandle, driver: AgentDriver + self, + call: LiveCall, + role: str, # noqa: ARG002 -- written under the agent's name, which the role is not + session: SessionHandle, + driver: AgentDriver, ) -> None: """A session was named as a turn of it went: written into its record, if it was not. @@ -981,7 +990,7 @@ def named( if getattr(session, "agent", None) is not None or session.id is None: return record = self._records.get(id(call), self._epic) - record.session(role, str(driver.harness), driver.provider, session.id) + record.session(driver.name, str(driver.harness), driver.provider, session.id) def closed(self, session: SessionHandle) -> None: """A session closed, and is let go of: what it spent is already the run's.""" @@ -1152,7 +1161,7 @@ def _by_role(given: object) -> list[tuple[str, object]]: ] -def _drove(role: str, spec: str) -> Drove: +def _drove(role: str, spec: str, name: str) -> Drove: """One agent role as the epic writes it down, off the spec it was given as.""" from hmz.coganchor.spelling import parted @@ -1162,7 +1171,9 @@ def _drove(role: str, spec: str) -> Drove: runs = read_back(spec) cli, _, rest = (runs.spec if runs is not None else spec).partition("/") model, effort = parted(rest) - return Drove(role, cli, model, effort, runs.provider if runs is not None else "") + return Drove( + role, cli, model, effort, runs.provider if runs is not None else "", name + ) def _roles(names: Iterable[str]) -> str: diff --git a/src/hmz/runtime/tracing/timeline.py b/src/hmz/runtime/tracing/timeline.py index 80515ec1c..6e595e3df 100644 --- a/src/hmz/runtime/tracing/timeline.py +++ b/src/hmz/runtime/tracing/timeline.py @@ -73,6 +73,15 @@ def agent(self) -> str: ... def record(self) -> str: ... +class Drove(Protocol): + """One agent role of an epic, and the agent that filled it: `hmz.runtime.epic.Drove` is one.""" + + @property + def agent(self) -> str: ... + @property + def name(self) -> str: ... + + class Call(Protocol): """One flow an epic called, and what it called in turn: `hmz.runtime.epic.Called` is one.""" @@ -109,6 +118,8 @@ def ended(self) -> str: ... def how(self) -> str: ... @property def sessions(self) -> Sequence[Opened]: ... + @property + def agents(self) -> Sequence[Drove]: ... @dataclasses.dataclass(frozen=True, slots=True) @@ -224,9 +235,16 @@ def build( Returns: The document and what each of its slices carried. """ - roles = {one.ident: one for one in (ran.sessions if ran is not None else ())} + opened = {one.ident: one for one in (ran.sessions if ran is not None else ())} + # What role each agent filled, by its name: a session is written down under its agent's + # name, the role being only the parameter it was taken as -- and an epic written before + # an agent had a name of its own wrote its role down as its name. + roles = { + one.name or one.agent: one.agent + for one in (ran.agents if ran is not None else ()) + } held = [ - _session(one, roles) + _session(one, opened, roles) for one in sorted( (one for one in sessions if one.actions), key=lambda one: (min(action.start for action in one.actions), one.key), @@ -466,7 +484,9 @@ def _flow(ran: Epic, calls: Sequence[Call]) -> _Process: ) -def _session(one: Session, roles: dict[str, Any]) -> _Process: +def _session( + one: Session, sessions: dict[str, Opened], roles: dict[str, str] +) -> _Process: """One session as a process: a banner over everything it did, and each thing inside it.""" start = min(action.start for action in one.actions) end = max(action.end for action in one.actions) @@ -490,7 +510,7 @@ def _session(one: Session, roles: dict[str, Any]) -> _Process: ) for action in one.actions ] - opened = roles.get(one.ident) if one.parent is None else None + opened = sessions.get(one.ident) if one.parent is None else None model = one.args.get("model") return _Process( "session", @@ -499,7 +519,7 @@ def _session(one: Session, roles: dict[str, Any]) -> _Process: one.label or "main", _fields( agent=one.agent, - role=opened.agent if opened is not None else "", + role=roles.get(opened.agent, "") if opened is not None else "", backend=one.backend, model=model if isinstance(model, str) else "", label=one.label, diff --git a/src/hmz/runtime/watching/btw.py b/src/hmz/runtime/watching/btw.py index 1d08375c3..48198f181 100644 --- a/src/hmz/runtime/watching/btw.py +++ b/src/hmz/runtime/watching/btw.py @@ -191,7 +191,7 @@ def format_snapshot(snapshot: FlowSnapshot, question: str, *, about: str = "") - """ lines = [ f"You are answering a side question about the session `{compact(about, 120)}` of a " - "coding flow; the observations below are the ones from its role." + "coding flow; the observations below are the ones from its agent." if about else "You are the btw agent, answering a side question about a coding flow.", "The primary flow is running independently. " + _UNTOUCHED, @@ -210,7 +210,7 @@ def format_snapshot(snapshot: FlowSnapshot, question: str, *, about: str = "") - agents = snapshot.agents[:_MAX_AGENTS] for agent in agents: state = "working" if agent.working else "idle" - named = f"{agent.role} ({agent.agent})" if agent.role else agent.agent + named = f"{agent.agent} ({agent.role})" if agent.role else agent.agent lines.append( f"- {compact(named, 240) or '(unnamed)'}: {state}, " f"{max(agent.turns, 0)} turn(s), " @@ -312,15 +312,16 @@ def format_snapshot(snapshot: FlowSnapshot, question: str, *, about: str = "") - def opened_as( - chosen: str, roles: Mapping[str, str], sessions: Sequence[str] + chosen: str, agents: Mapping[str, str], sessions: Sequence[str] ) -> dict[str, str] | None: """What the btw agent is opened as: the one `/settings` names, or the flow's first. Args: chosen: The agent `/settings` names, as `-a` spells one, or "" for none. - roles: The flow's agent roles in the order it declares them, each with the agent it - runs as, as `-a` spells one -- or "" for a role nothing is set up for. - sessions: The run's conversations, `/` apiece, oldest first. + agents: The flow's agents in the order it declares their roles, each by its name, or + by its role where it has none yet, with what it runs as, as `-a` spells one -- or "" + for a role nothing is set up for. + sessions: The run's conversations, `/` apiece, oldest first. Returns: What to open a side conversation with -- `runs`, an agent as `-a` spells one, or @@ -329,8 +330,8 @@ def opened_as( """ if chosen: return {"runs": chosen} - for role, runs in roles.items(): - if held := [key for key in sessions if key.startswith(f"{role}/")]: + for name, runs in agents.items(): + if held := [key for key in sessions if key.startswith(f"{name}/")]: return {"key": held[0]} if runs: return {"runs": runs} @@ -348,11 +349,11 @@ class Btw: conversation opened after it was closed is closed again at once. Args: - target: The session asked, as `/`, or "" for the btw agent. + target: The session asked, as `/`, or "" for the btw agent. aside: Asks the runs about a side conversation -- opens one, or takes one turn of one -- as a link's `aside` does, answering what the runs answered. unaside: Closes one side conversation, by the number the runs gave it. - sessions: The run's conversations a side one may be opened on, `/` apiece. + sessions: The run's conversations a side one may be opened on, `/` apiece. source: What the btw agent is opened as (:func:`opened_as`), or None for nothing. asking: Told each session the btw agent asks, and what, as it asks it. opening: What opens the target's side conversation, as a link's `aside` takes it, for @@ -480,11 +481,11 @@ def _turn(self, key: str, question: str, snapshot: FlowSnapshot) -> str: # A copy of its agent, opened afresh: for an earlier run's, without its history. fresh = {"runs": opening["runs"]} if opening else {"key": key} side = self._kept(key, str(self._aside(**fresh)["side"])) - role = key.rpartition("/")[0] + agent = key.rpartition("/")[0] about = replace( snapshot, observations=tuple( - one for one in snapshot.observations if one.agent in (role, "") + one for one in snapshot.observations if one.agent in (agent, "") ), sessions=(), ) diff --git a/src/hmz/runtime/watching/following.py b/src/hmz/runtime/watching/following.py index d1dc00a8c..32bc47f33 100644 --- a/src/hmz/runtime/watching/following.py +++ b/src/hmz/runtime/watching/following.py @@ -44,7 +44,7 @@ class Following: run: The run in front of the frontend, as its host numbers runs, or 0 before any. monitor: That run's graph, and its bill per model. tally: What that run has cost, read from the logs its agents keep as they go. - seen: That run's sessions, by the key a frontend names one by, `/`. + seen: That run's sessions, by the key a frontend names one by, `/`. """ def __init__(self) -> None: @@ -69,7 +69,7 @@ def started(self, record: Mapping[str, Any]) -> None: """ self.run = int(record["run"]) # The conversations of the run before this one went with it: this run's first - # conversation is its role's first again. + # conversation is its agent's first again. self.seen = {} self.monitor = Monitor(began=record["began"]) # What the run costs is read from the logs the agents keep, which they write as they @@ -101,6 +101,7 @@ def opened(self, record: Mapping[str, Any]) -> Seen | None: frozenset(record["counts"]), kept=record.get("kept", ""), since=float(record.get("wall") or 0.0), + role=record.get("role", ""), ) if record["run"] == self.run: self.seen[record["key"]] = seen @@ -126,7 +127,7 @@ def heard(self, record: Mapping[str, Any]) -> str | None: record: What was said, by whose turn and in which of its conversations. Returns: - The conversation it was said in, `/`, where the run it is of is the one in + The conversation it was said in, `/`, where the run it is of is the one in front of the frontend -- or None, for something said outside any conversation or by a run unwinding behind the next, which numbers its conversations from one as that one does and so is the agent's alone. @@ -235,7 +236,7 @@ def snapshot( task: What the run was started on. workspace: Where it runs. going: Whether it is going still. - working: The conversations with a turn open, `/` apiece. + working: The conversations with a turn open, `/` apiece. waiting: How many messages are waiting for a turn to take them. waiting_for_input: Whether the run is waiting on a person. @@ -245,8 +246,8 @@ def snapshot( shape = self.monitor.shape() moment = time.monotonic() ended = self.monitor.until - # One per role, however many sessions it opened: a role is what is watched, and each - # of its sessions is an agent of its own named for it. + # One per agent, however many sessions it opened: an agent is what is watched, each of + # its sessions only a conversation of it, and the role it fills is said beside it. driven = {seen.id: seen for seen in list(self.seen.values()) if seen.id} with self._observing: observations = tuple(self._observed) @@ -262,7 +263,7 @@ def snapshot( model=seen.model, turns=shape.turns.get(who, 0), working=who in shape.working, - role=who, + role=seen.role, ) for who, seen in driven.items() ), diff --git a/src/hmz/runtime/watching/monitor.py b/src/hmz/runtime/watching/monitor.py index 85ab620f5..c45c3ffd7 100644 --- a/src/hmz/runtime/watching/monitor.py +++ b/src/hmz/runtime/watching/monitor.py @@ -322,7 +322,7 @@ class Monitor: _last: str | None = None #: The same graph kept a session at a time rather than an agent at a time, which is what #: the monitor draws an agent opened out to its sessions from: who worked, who handed to - #: whom, how long each has been at it and what each has spent, with `/` where the + #: whom, how long each has been at it and what each has spent, with `/` where the #: agent was. Made when the first turn that names its session starts, and read for nothing #: but that -- the bill is the run's, and is read off this one, above. _sessions: Monitor | None = None @@ -341,7 +341,7 @@ def begins( agent: Whose turn it is. model: The model that agent runs at. now: When, defaulting to this moment. Given only so a test can say. - session: Which of its sessions the turn is in, as `/`, or None where + session: Which of its sessions the turn is in, as `/`, or None where nobody said -- which is a turn counted against the agent alone. """ if session is not None: @@ -785,7 +785,7 @@ def shape(self, *, sessions: bool = False) -> Shape: moment of the run rather than three moments of three counters. Args: - sessions: Whether a node is a session, keyed `/`, rather than an agent. + sessions: Whether a node is a session, keyed `/`, rather than an agent. Returns: The graph, which is what the monitor is drawn from. diff --git a/src/hmz/runtime/watching/tally.py b/src/hmz/runtime/watching/tally.py index f29e991d0..46a63b415 100644 --- a/src/hmz/runtime/watching/tally.py +++ b/src/hmz/runtime/watching/tally.py @@ -299,7 +299,7 @@ class Seen: the names are gathered as the records bring them. Attributes: - id: The role it was opened for, which is what its spending is reported under. + id: The agent's own name, which is what its spending is reported under. backend: The CLI that runs it, which is whose logs are read and how. model: What it runs at, for a row of a log that does not say for itself. counts: The kinds of token its backend reports, whether or not it has spent any. @@ -310,6 +310,8 @@ class Seen: since: When the run opened it, in seconds since the epoch. A row its log wrote down before then is not this run's spending: a conversation carried on from an earlier run, or cut from another one, comes with that one's rows. + role: The role of the flow its agent fills, or "" where nobody said: only the + parameter the agent is taken as, never what it is called. """ id: str @@ -319,6 +321,7 @@ class Seen: idents: frozenset[str] = frozenset() kept: str = "" since: float = 0.0 + role: str = "" @dataclass @@ -354,7 +357,7 @@ def __init__(self, sessions: Sequence[Seen], monitor: Monitor) -> None: #: named it, for a backend whose names hold across every log: a request said again #: adds only what it says beyond what was already counted of it. self._requests: dict[tuple[str, str], Counter[str]] = {} - #: Which roles this has actually opened a log of, so that what the monitor is told a + #: Which agents this has actually opened a log of, so that what the monitor is told a #: backend reports is what the interface can in fact see. A rollout written on another #: machine, or in a container, is one nothing here reads -- and a kind claimed off a #: log nobody read would be a nought drawn as a fact. diff --git a/src/hmz/tui/app.py b/src/hmz/tui/app.py index f381f03ee..37a0903c6 100644 --- a/src/hmz/tui/app.py +++ b/src/hmz/tui/app.py @@ -137,6 +137,8 @@ class _RunSeen: task: What it was started on. by: Who started it, as that frontend is named. roles: Its agent roles, in the order the flow declares them. + names: What the agent filling each agent role is called, by role: what its sessions + are keyed by and what it is read as, the role being only the parameter it fills. outworlders: Its `Outworlder` roles, likewise. agents: What each agent role runs, as `-a` spells it after `=`. envs: What each environment role was given, as `-e` spells it after `=`. @@ -148,6 +150,7 @@ class _RunSeen: task: str = "" by: str = "" roles: tuple[str, ...] = () + names: Mapping[str, str] = field(default_factory=dict[str, str]) outworlders: tuple[str, ...] = () agents: Mapping[str, str] = field(default_factory=dict[str, str]) envs: Mapping[str, str] = field(default_factory=dict[str, str]) @@ -162,6 +165,7 @@ def of(cls, said: Mapping[str, Any]) -> _RunSeen: str(said.get("task") or ""), str(said.get("by") or ""), tuple(said.get("roles") or ()), + dict(said.get("names") or {}), tuple(said.get("outworlders") or ()), dict(said.get("agents") or {}), dict(said.get("envs") or {}), @@ -1061,7 +1065,7 @@ def __init__( #: The backup targets an epic of it here goes to, by name, or None for the defaults #: as it starts: chosen on `/flow`, and remembered with the budget per flow. self._backup = self.settings.backup(self._flow_named) - #: Where each role's harness went in the run going, by role: what the runtime it works + #: Where each agent's harness went in the run going, by its name: what the runtime it works #: on came to, which only the machine could say. self._harnessed: dict[str, str] = {} #: What has been typed here before, which the arrows walk. Read now rather than each @@ -1070,10 +1074,10 @@ def __init__( self.history = History() #: When each agent's turn started, for the line that closes it. self._began: dict[str, float] = {} - #: What each transcript has to show: one per role, under the role, and one under + #: What each transcript has to show: one per agent, under its name, and one under #: `_EVERY` where all of them appear together. Kept by name rather than by the - #: object, since a role's sessions come and go under it -- a Ralph loop opens one a - #: turn -- and what is read is the role rather than whichever of them is open. + #: object, since an agent's sessions come and go under it -- a Ralph loop opens one a + #: turn -- and what is read is the agent rather than whichever of them is open. self._kept: dict[str, _Kept] = {} #: Which transcript is being read: what the screen shows, and which agent a typed #: line is said to. The one they all appear on until somebody steps off it, that @@ -1145,6 +1149,43 @@ def _named_by(self) -> tuple[str, ...]: return self._declared.roles return tuple(self._models) + def _name_of(self, role: str) -> str: + """What the agent filling one role is called, in the run going or the last one. + + Which is what its sessions are keyed by and what it is read as: the role is only the + parameter it fills. The role itself where no run has said -- one set up here and not + yet started, whose agent has no name until it is. + """ + ran = self._run or self._seen_last + return (ran.names.get(role) if ran is not None else None) or role + + def _role_named(self, agent: str) -> str: + """The agent role one agent of the run going or the last one fills, or "" for none.""" + ran = self._run or self._seen_last + names: Mapping[str, str] = ran.names if ran is not None else {} + return next((role for role, name in names.items() if name == agent), "") + + def _agents_named(self) -> tuple[str, ...]: + """Each agent role's agent as a line about it names it: by its name, then its role. + + By its name from the moment the run says it, which is before it has opened anything: + the role is the parameter it fills, and stays the name only for a flow set up here + and not yet started. + """ + return tuple( + role if (name := self._name_of(role)) == role else f"{name}{_DOT}{role}" + for role in self._named_by + ) + + def _runs_as(self, agent: str) -> str: + """What one agent runs, behind the role it fills: what its box says under its name.""" + role = self._role_named(agent) or agent + return _DOT.join( + part + for part in (role if role != agent else "", self._runs_of(role).spec) + if part + ) + def _runs_of(self, role: str) -> Runs: """What one agent role runs: as the run going was started with, or as set up here.""" if self._run is not None: @@ -1576,13 +1617,13 @@ def _into( if not whose: where = [self._attached] else: - role = self._role_of(whose) + agent = self._agent_of(whose) where = [ *([_EVERY] if shared else []), - *([role] if role and role != whose else []), + *([agent] if agent and agent != whose else []), whose, ] - speaker = self._role_of(whose or "") or whose + speaker = self._agent_of(whose or "") or whose for one in where: kept = self._keeping(one) if one == _EVERY and speaker and kept.spoke != speaker: @@ -1596,15 +1637,15 @@ def _into( self._writes(one, _Shown(content, shrink)) @staticmethod - def _role_of(key: str) -> str: - """The agent role a transcript is of: its own for a conversation's, "" for the rest. + def _agent_of(key: str) -> str: + """The agent a transcript is of: its own for a conversation's, "" for the rest. Args: key: The transcript, as `_now_reading` names them. Returns: - The role for an agent's or a conversation's, and "" for the one every agent is on - and for an outworlder's, neither of which is any agent's. + The agent's name for an agent's or a conversation's, and "" for the one every agent + is on and for an outworlder's, neither of which is any agent's. """ if key.startswith(_OUTWORLDER): return "" @@ -1624,8 +1665,8 @@ def _titled(key: str) -> str: return "all agents" if key.startswith(_OUTWORLDER): return f"outworlder {key.removeprefix(_OUTWORLDER)}" - role, _, count = key.partition("/") - return f"{short(role)}{_DOT}conversation {count}" if count else short(role) + agent, _, count = key.partition("/") + return f"{short(agent)}{_DOT}conversation {count}" if count else short(agent) def _view_kind(self) -> str: """Which kind of view is in front of the person, which is what a command works in. @@ -1715,12 +1756,12 @@ def _driven(self) -> list[str]: """ return list(self._seen) - def _of(self, role: str) -> list[str]: - """Every session one role has opened, oldest first.""" - return [key for key, seen in list(self._seen.items()) if seen.id == role] + def _of(self, agent: str) -> list[str]: + """Every session one agent has opened, by its name, oldest first.""" + return [key for key, seen in list(self._seen.items()) if seen.id == agent] def _working_agents(self) -> list[str]: - """Which of the flow's roles have a turn open, in the order they opened sessions. + """Which of the flow's agents have a turn open, in the order they opened sessions. Returns: Their names. These are the ones tab steps between: with ten agents going, what @@ -1735,16 +1776,16 @@ def _working_agents(self) -> list[str]: ) def _reading(self) -> str | None: - """The conversation being read, where one role is rather than all of them. + """The conversation being read, where one agent is rather than all of them. Returns: - The conversation read, the newest of the role read, or None on the transcript they + The conversation read, the newest of the agent read, or None on the transcript they all appear on, on an outworlder's, and for one whose flow is over -- the transcript stays up either way, there being nothing to say to it. """ if "/" in self._attached: return self._attached if self._attached in self._seen else None - held = self._of(self._role_of(self._attached)) if self._attached else [] + held = self._of(self._agent_of(self._attached)) if self._attached else [] return held[-1] if held else None def _says_to(self) -> str | None: @@ -1779,9 +1820,10 @@ def _now_reading(self, whose: str, *, stepped: bool = True) -> None: screen rather than replacing it. Args: - whose: Which transcript: `""` for the one every agent is on, `` for one agent - and all of its conversations, `/` for the n-th conversation that role - opened, counting from one, and `outworlder:` for what one outworlder asks. + whose: Which transcript: `""` for the one every agent is on, `` for one agent + and all of its conversations, by its name, `/` for the n-th conversation + that agent opened, counting from one, and `outworlder:` for what one + outworlder asks. stepped: Whether somebody asked for this, rather than what was being read having gone with the flow that held it. """ @@ -1797,7 +1839,7 @@ def _now_reading(self, whose: str, *, stepped: bool = True) -> None: one.unread = False shown = self.query_one("#transcript", Transcript) shown.clear() - held = len(self._of(whose)) if self._role_of(whose) == whose else 0 + held = len(self._of(whose)) if self._agent_of(whose) == whose else 0 many = f"{_DOT}{held} conversations" if held > 1 else "" named = f"{escape(self._titled(whose))}{many}" shown.write( @@ -1831,12 +1873,13 @@ def _held(self) -> list[Held]: return [] held: list[Held] = [] for role in self._named_by: - keys = self._of(role) + agent = self._name_of(role) + keys = self._of(agent) held.append( Held( many=len(keys), - reading=role == self._role_of(self._attached), - unread=self._unread(role), + reading=agent == self._agent_of(self._attached), + unread=self._unread(agent), working=any(key in self._working for key in keys), ) ) @@ -2096,7 +2139,7 @@ def _draw(self) -> None: # and they are read one at a time, against the name the flow calls each one by -- and # with the conversations each of them is holding, since one of those is what is being # read and what a typed line goes to. - lines = reads(self._named_by, self._in_order(), self._held()) or [ + lines = reads(self._agents_named(), self._in_order(), self._held()) or [ "no agent installed" if self._named_by else "no agent available" ] lines.extend(self._outworlder_lines()) @@ -2509,7 +2552,7 @@ def action_monitor(self) -> None: places=self._places, setup=lambda: ( self._run.flow if self._run is not None else self._flow_named, - self._named_by, + self._agents_named(), self._in_order(), self._params, ), @@ -2593,7 +2636,7 @@ def _boxes(self) -> list[Drawn]: """ shape = self._monitor.shape() drawn: list[Drawn] = [] - for who in self._roles_seen(): + for who in self._agents_seen(): working = any(key in self._working for key in self._of(who)) if not (working or shape.turns.get(who, 0)): continue @@ -2601,7 +2644,7 @@ def _boxes(self) -> list[Drawn]: Drawn( who=who, named=who, - runs=self._runs_of(who).spec, + runs=self._runs_as(who), working=working, reading=who == self._attached, unread=self._unread(who), @@ -2613,23 +2656,23 @@ def _branches(self) -> list[Drawn]: """The sessions that have worked, as the monitor hangs them under their agents. Returns: - One per session that has taken a turn, keyed `/`, its role's in the order - the flow declares its roles and each role's in the order they were opened -- each - saying which agent it is of and which environment it works in. + One per session that has taken a turn, keyed `/`, its agent's in the order + the flow declares the roles they fill and each agent's in the order they were + opened -- each saying which agent it is of and which environment it works in. """ shape = self._monitor.shape(sessions=True) - seen = self._roles_seen() + seen = self._agents_seen() return [ Drawn( who=key, - named=f"{role}{_DOT}session {at}", - runs=self._runs_of(role).spec, + named=f"{agent}{_DOT}session {at}", + runs=self._runs_as(agent), working=key in shape.working, - # A session read on its own where it has a log of its own, and on its role's + # A session read on its own where it has a log of its own, and on its agent's # where it does not: that is the log reading it opens. - reading=self._attached in (key, role), - unread=self._unread(key if key in self._kept else role), - of=role, + reading=self._attached in (key, agent), + unread=self._unread(key if key in self._kept else agent), + of=agent, env=place_key(placed) if (placed := self._placed.get(key)) else "", ) for key in sorted( @@ -2641,14 +2684,20 @@ def _branches(self) -> list[Drawn]: int(key.partition("/")[2] or 0), ), ) - for role, _, at in [key.partition("/")] + for agent, _, at in [key.partition("/")] ] - def _roles_seen(self) -> list[str]: - """The roles the run has opened sessions for, in the order the flow declares them.""" + def _agents_seen(self) -> list[str]: + """The agents the run has opened sessions for, by name, in the order of their roles.""" named = self._named_by seen = list(dict.fromkeys(one.id for one in list(self._seen.values()))) - seen.sort(key=lambda who: named.index(who) if who in named else len(named)) + seen.sort( + key=lambda who: ( + named.index(role) + if (role := self._role_named(who) or who) in named + else len(named) + ) + ) return seen def _places(self) -> list[Placed]: @@ -2863,8 +2912,8 @@ def _btw_target(self) -> str: """Who a side question here goes to: a session's view key, or "" for the btw agent. Returns: - `/` for a session, the newest of a role's where the view is the role's - own, and "" for the aggregate, the monitor and an outworlder's view. + `/` for a session, the newest of an agent's where the view is the + agent's own, and "" for the aggregate, the monitor and an outworlder's view. """ if self._view_kind() != "session": return "" @@ -2878,10 +2927,10 @@ def _btw_sessions(self) -> list[str]: """Every conversation of this run or the last, keyed as the views name them. The key its transcript and its node go by, so that a side question asked of - `builder/2` is asked of the conversation `builder/2` shows. + `NeiKos496/2` is asked of the conversation `NeiKos496/2` shows. Returns: - `/` apiece, `n` counting a role's from 1, and the person's left out -- + `/` apiece, `n` counting an agent's from 1, and the person's left out -- theirs is this prompt. """ return list(self._seen) @@ -3007,7 +3056,9 @@ def _btw_source(self) -> dict[str, str] | None: return opened_as( written(said) if said is not None else "", { - role: written(runs) if (runs := self._runs_of(role)).spec else "" + self._name_of(role): written(runs) + if (runs := self._runs_of(role)).spec + else "" for role in self._named_by }, self._btw_sessions(), @@ -4115,7 +4166,7 @@ def _started(self, record: dict[str, Any]) -> None: if self._attached != _EVERY: self._now_reading(_EVERY, stepped=False) # The conversations of the run before this one went with it, and so do their numbers - # and their transcripts: this run's first conversation is its role's first again. + # and their transcripts: this run's first conversation is its agent's first again. self._working, self._placed, self._asked_in = set(), {}, {} self._last_answer_in = "" # And where their harnesses went, which this run settles for itself. @@ -4182,27 +4233,27 @@ def _opened(self, record: dict[str, Any]) -> None: **placed, "harness": str(record.get("harness") or ""), } - self._harnessed_at(str(record.get("role") or ""), record.get("harness")) + self._harnessed_at(str(record.get("agent") or ""), record.get("harness")) - def _harnessed_at(self, role: str, where: object) -> None: - """Says where a role's harness went, the first time it goes somewhere this run. + def _harnessed_at(self, agent: str, where: object) -> None: + """Says where an agent's harness went, the first time it goes somewhere this run. Only for work on another machine, which is the only work whose harness had anywhere else to be: a session that works here says nothing. Args: - role: The role, as the flow calls it. + agent: The agent, by its name. where: Where its harness runs, as the run said it, or nothing for work here. """ - if not role or not isinstance(where, str) or not where: + if not agent or not isinstance(where, str) or not where: return - if self._harnessed.get(role) == where: + if self._harnessed.get(agent) == where: return - self._harnessed[role] = where + self._harnessed[agent] = where said = {"local": "here", "self": "on its environment's machine"}.get( where, f"on the runtime {where}" ) - self.show(f"[dim]{escape(role)}'s harness runs {escape(said)}[/dim]") + self.show(f"[dim]{escape(short(agent))}'s harness runs {escape(said)}[/dim]") def _heard(self, record: dict[str, Any]) -> None: """Shows what a turn said, on the transcript of the agent that said it. @@ -4348,19 +4399,19 @@ def _heard(self, record: dict[str, Any]) -> None: ) def _conversation(self, key: str) -> str: - """Which of a role's conversations a turn is being taken in, where it has several. + """Which of an agent's conversations a turn is being taken in, where it has several. Args: key: The conversation, or "" where the agent said it rather than one of them. Returns: - Which one, counting from one, and nothing at all for a role holding one -- there + Which one, counting from one, and nothing at all for an agent holding one -- there being nothing to tell it apart from. """ - role, _, at = key.partition("/") + agent, _, at = key.partition("/") if not at: return "" - opened = len(self._of(role)) + opened = len(self._of(agent)) if opened < 2: # noqa: PLR2004 -- one is none to tell apart return "" return f"{_DOT}conversation {at} of {opened}" diff --git a/src/hmz/tui/monitoring.py b/src/hmz/tui/monitoring.py index 79c7658ea..c891918d5 100644 --- a/src/hmz/tui/monitoring.py +++ b/src/hmz/tui/monitoring.py @@ -256,8 +256,9 @@ class Drawn(NamedTuple): """One node of a run -- an agent, or one session of one -- as the monitor draws it. Attributes: - who: The key the node is read under: the agent id for an agent, `/` for the - n-th session of that role. What reading it names, and what the graph counts it under. + who: The key the node is read under: the agent's name for an agent, `/` for + the n-th session of that agent. What reading it names, and what the graph counts it + under. named: What to call it on its box, or "" to call it by its id cut down. runs: What it runs, as the line that says what each agent is says it. working: Whether it has a turn open right now. diff --git a/src/hmz/tui/pick.py b/src/hmz/tui/pick.py index f5a001fde..18d61d7a7 100644 --- a/src/hmz/tui/pick.py +++ b/src/hmz/tui/pick.py @@ -284,7 +284,8 @@ def reads( line without it, and it says nothing there. Args: - named: The role each of them fills. + named: What each of them is called: its agent's name and the role it fills once a + run has said it, and the role alone before. runs: What each of them runs. holding: The conversations each of them has open, in the same order, or nothing at all for a flow that is not running -- which holds none. diff --git a/src/hmz/web/held.py b/src/hmz/web/held.py index fe7ed97f4..a951fd7bc 100644 --- a/src/hmz/web/held.py +++ b/src/hmz/web/held.py @@ -224,7 +224,7 @@ def btw( Args: question: What was asked. - target: The conversation asked, `/`, or "" for the btw agent. + target: The conversation asked, `/`, or "" for the btw agent. workspace: Where the runs are, as the question is told it. source: What the btw agent is opened as, given the run as the runs last said it and its conversations. @@ -293,7 +293,7 @@ def btw( return said def sessions(self) -> list[str]: - """The conversations of the run in front of this interface, `/` apiece.""" + """The conversations of the run in front of this interface, `/` apiece.""" with self._changed: return list(self._following.seen) @@ -718,7 +718,7 @@ def _recalled(ran: Any, key: str) -> tuple[dict[str, Any], FlowSnapshot]: Args: ran: The epic. - key: The session, `/`: the n-th its role opened. + key: The session, `/`: the n-th its agent opened. Returns: Its agent, conversation and where it was kept, as a link's `aside` takes them; and the @@ -730,14 +730,17 @@ def _recalled(ran: Any, key: str) -> tuple[dict[str, Any], FlowSnapshot]: from hmz.runtime.epic import where from hmz.runtime.watching.btw import AgentProgress, FlowSnapshot - role, _, number = key.rpartition("/") - mine = [one for one in ran.sessions if one.agent == role] + agent, _, number = key.rpartition("/") + mine = [one for one in ran.sessions if one.agent == agent] if not number.isdigit() or not 0 < int(number) <= len(mine): raise Refusal(f"{ran.name} opened no session {key}.", 404) session = mine[int(number) - 1] - runs = next((one.spec for one in ran.agents if one.agent == role), "") + # One written before an agent had a name of its own went by its role. + runs = next( + (one.spec for one in ran.agents if (one.name or one.agent) == agent), "" + ) if not runs: - raise Refusal(f"{ran.name} says nothing of what {role} ran as.", 404) + raise Refusal(f"{ran.name} says nothing of what {agent} ran as.", 404) began, ended = _moment(ran.began), _moment(ran.ended) about = FlowSnapshot( flow=ran.flow, @@ -747,7 +750,11 @@ def _recalled(ran: Any, key: str) -> tuple[dict[str, Any], FlowSnapshot]: finished=True, agents=tuple( AgentProgress( - agent=one.agent, model=one.model, turns=0, working=False, role=one.agent + agent=one.name or one.agent, + model=one.model, + turns=0, + working=False, + role=one.agent, ) for one in ran.agents ), @@ -776,9 +783,9 @@ def _source( ) -> dict[str, str] | None: """What the btw agent is opened as, as the terminal interface opens it. - The agent `/settings` names; or the run's first role, as its conversation or as the - agent it was started with; or, with no run to read, as this workspace's default flow was - last set up. + The agent `/settings` names; or the agent of the run's first role, as its conversation + or as what it was started with; or, with no run to read, as this workspace's default flow + was last set up. """ from hmz.runtime.kept import read_back, written from hmz.runtime.watching.btw import opened_as @@ -787,7 +794,11 @@ def _source( said = read_back(settings.btw) if settings.btw else None if run and run.get("roles"): agents: Mapping[str, Any] = run.get("agents") or {} - roles = {str(role): str(agents.get(role) or "") for role in run["roles"]} + names: Mapping[str, Any] = run.get("names") or {} + roles = { + str(names.get(role) or role): str(agents.get(role) or "") + for role in run["roles"] + } elif settings.flow: roles = { role: written(runs) for role, runs in settings.agents(settings.flow).items() diff --git a/src/hmz/web/runs.py b/src/hmz/web/runs.py index 80d95e37d..e3c3959e0 100644 --- a/src/hmz/web/runs.py +++ b/src/hmz/web/runs.py @@ -334,7 +334,10 @@ def row(ran: Ran, *, held: bool, going: bool = False) -> dict[str, Any]: "began": ran.began, "ended": ran.ended, "how": ran.how or (RUNNING if held or going else UNFINISHED), - "agents": [{"role": one.agent, "runs": one.spec} for one in ran.agents], + "agents": [ + {"name": one.name or one.agent, "role": one.agent, "runs": one.spec} + for one in ran.agents + ], "sessions": len(ran.sessions), "resumable": ran.resumable, "spent": labelled(ran.spent), @@ -379,6 +382,7 @@ def detail( "ref": ran.ref, "agents": [ { + "name": one.name or one.agent, "role": one.agent, "runs": one.spec, "backend": one.backend, @@ -390,7 +394,7 @@ def detail( ], "sessions": [ { - "role": one.agent, + "agent": one.agent, "backend": one.backend, "provider": one.provider, "ident": one.ident, diff --git a/src/hmz/web/static/acting.js b/src/hmz/web/static/acting.js index f77d5ec87..b9dbf4583 100644 --- a/src/hmz/web/static/acting.js +++ b/src/hmz/web/static/acting.js @@ -38,7 +38,7 @@ export function question(workspace, one, me, clients) { } /** - * Says a line to the run: into one session's turn, to a role, or to the next turn to start -- + * Says a line to the run: into one session's turn, to an agent, or to the next turn to start -- * or, given a session, into that session alone. Made once for a page, as `asides` is, and told * who there is to say it to as that changes, so that a line being typed outlives every drawing * of the page around it. Given what the session is asked, a line typed while it is asked @@ -59,13 +59,13 @@ export function saying(workspace, session = '', { asked = () => null, to: into = box.node.dataset.hmz = 'say' return { node: box.node, - /** Offers the roles, sessions and people there are to say a line to, keeping the one picked. */ - offers(roles, sessions, working, outworlders) { + /** Offers the agents, by name, sessions and people there are to say a line to, keeping the one picked. */ + offers(agents, sessions, working, outworlders) { const picked = to.value fill( to, h('option', { value: '' }, 'Next turn'), - roles.map((role) => h('option', { value: role }, role)), + agents.map((name) => h('option', { value: name }, name)), sessions.map((key) => h('option', { value: key }, `${key}${working.includes(key) ? ' (working)' : ''}`)), outworlders.map((role) => h('option', { value: `outworlder:${role}` }, `${role} (person)`)), ) diff --git a/src/hmz/web/static/epic.js b/src/hmz/web/static/epic.js index e6e653516..17f5fbcd4 100644 --- a/src/hmz/web/static/epic.js +++ b/src/hmz/web/static/epic.js @@ -3,9 +3,10 @@ // who handed to whom -- off the records of the run going, as the terminal interface's monitor // works them out, or off the epic written down and, once read, the logs it kept. // -// A session is named as the run going names it, `/`, the n-th its role opened; a run -// written down names its sessions the same way, in the order they were opened, so an address -// to a session stays one as its run ends. +// An agent goes by its own name, never by the role it fills: the role is only a parameter it +// was given, said beside the name. A session is named as the run going names it, +// `/`, the n-th its agent opened; a run written down names its sessions the same +// way, in the order they were opened, so an address to a session stays one as its run ends. import { get, poll } from './api.js' import { lane, lasting } from './dom.js' @@ -95,9 +96,9 @@ export function going(held) { export function turnsOf(records, offset) { const held = new Map() for (const record of records) { - if (record.type === 'opened' && !record.person) held.set(record.key, { key: record.key, role: record.role, cli: record.cli, model: record.model, turns: [] }) + if (record.type === 'opened' && !record.person) held.set(record.key, { key: record.key, agent: record.agent || record.key.split('/')[0], role: record.role, cli: record.cli, model: record.model, turns: [] }) if (record.type !== 'event' || !record.session) continue - if (!held.has(record.session)) held.set(record.session, { key: record.session, role: record.session.split('/')[0], cli: record.cli, model: record.model, turns: [] }) + if (!held.has(record.session)) held.set(record.session, { key: record.session, agent: record.session.split('/')[0], role: '', cli: record.cli, model: record.model, turns: [] }) const one = held.get(record.session) if (record.kind === 'begins') one.turns.push({ start: record.at, end: null }) else if (record.kind === 'ends') { @@ -133,8 +134,8 @@ function unread(epic, key, records) { export function sessionsOf(detail) { const counted = {} return detail.sessions.map((one) => { - counted[one.role] = (counted[one.role] || 0) + 1 - return { ...one, key: `${one.role}/${counted[one.role]}`, traced: `${one.backend}:${one.ident}` } + counted[one.agent] = (counted[one.agent] || 0) + 1 + return { ...one, key: `${one.agent}/${counted[one.agent]}`, traced: `${one.backend}:${one.ident}` } }) } @@ -145,38 +146,42 @@ export function live(held, run, detail) { const figures = held.figures const sessions = turnsOf(records, offset) const people = started.outworlders || [] - const roles = [...(started.roles || Object.keys(started.agents || {}))] - for (const one of sessions) if (one.role && !roles.includes(one.role)) roles.push(one.role) - for (const one of people) if (!roles.includes(one)) roles.push(one) + // Each agent by its name, with the role it fills: one a host named none for by that role, + // and a person by the role it answers. + const names = started.names || {} + const roleOf = new Map((started.roles || Object.keys(started.agents || {})).map((role) => [names[role] || role, role])) + for (const one of sessions) if (one.agent && !roleOf.has(one.agent)) roleOf.set(one.agent, one.role || '') + for (const one of people) if (!roleOf.has(one)) roleOf.set(one, one) const working = new Set(((figures && figures.sessions) || []).filter((one) => one.working).map((one) => one.name)) const now = Date.now() / 1000 const starts = sessions.flatMap((one) => one.turns.map((turn) => turn.start)) const from = started.at || Math.min(now, ...starts) const last = records.findLast((one) => one.at) const to = run.live ? now : Math.max(from + 1, last ? last.at : now, ...sessions.flatMap((one) => one.turns.map((turn) => turn.end || 0))) - const specs = Object.fromEntries(((detail && detail.agents) || []).map((one) => [one.role, one.runs])) - const agents = roles.map((role, index) => { - const figure = figures && figures.agents.find((one) => one.name === role) - const person = people.includes(role) + const specs = Object.fromEntries(((detail && detail.agents) || []).map((one) => [one.name, one.runs])) + const agents = [...roleOf].map(([name, role], index) => { + const figure = figures && figures.agents.find((one) => one.name === name) + const person = people.includes(name) const given = started.agents && typeof started.agents[role] === 'string' ? started.agents[role] : '' return { + name, role, person, color: lane(person ? -1 : index), - spec: person ? 'a person' : specs[role] || given, + spec: person ? 'a person' : specs[name] || given, working: Boolean(figure && figure.working), status: figure ? (figure.working ? `working ${figure.lasting}` : `idle ${figure.lasting}`) : person ? '' : 'not started', - turns: figure ? figure.turns : sessions.filter((one) => one.role === role).reduce((sum, one) => sum + one.turns.length, 0), + turns: figure ? figure.turns : sessions.filter((one) => one.agent === name).reduce((sum, one) => sum + one.turns.length, 0), tokens: figure ? figure.tokens : '', under: figure ? figure.under : [], sessions: sessions - .filter((one) => one.role === role) + .filter((one) => one.agent === name) .map((one) => ({ key: one.key, about: [one.cli, one.model].filter(Boolean).join(' · '), working: working.has(one.key) || one.turns.some((turn) => turn.end === null), turns: one.turns, unread: unread(epic, one.key, records) })), } }) return { flow: started.flow, - frames: [{ flow: started.flow, roles }], + frames: [{ flow: started.flow, names: [...roleOf.keys()] }], agents, handovers: ((figures && figures.handovers) || []).filter((one) => one.from !== one.to), latest: figures && figures.latest, @@ -189,27 +194,28 @@ export function live(held, run, detail) { /** What each agent of a run written down was, from its epic and -- once read -- its logs. */ export function written(detail, traced) { const sessions = sessionsOf(detail) - const roles = [...new Set([...detail.agents.map((one) => one.role), ...sessions.map((one) => one.role)])] + const names = [...new Set([...detail.agents.map((one) => one.name), ...sessions.map((one) => one.agent)])] const turns = new Map((traced || []).map((one) => [one.key, one.actions.filter((action) => action.category === 'turn').map((action) => ({ start: action.start, end: action.start + action.seconds }))])) - const specs = Object.fromEntries(detail.agents.map((one) => [one.role, one.runs])) + const given = Object.fromEntries(detail.agents.map((one) => [one.name, one])) // The flows it ran, the one it was started with first: an agent is drawn in the flow its // first session was opened in. - const frames = [{ flow: detail.flow, roles: [] }] - for (const role of roles) { - const first = sessions.find((one) => one.role === role) + const frames = [{ flow: detail.flow, names: [] }] + for (const name of names) { + const first = sessions.find((one) => one.agent === name) const flow = first && first.flow ? first.flow : detail.flow let frame = frames.find((one) => one.flow === flow) - if (!frame) frames.push((frame = { flow, roles: [] })) - frame.roles.push(role) + if (!frame) frames.push((frame = { flow, names: [] })) + frame.names.push(name) } - const agents = roles.map((role, index) => { - const mine = sessions.filter((one) => one.role === role) + const agents = names.map((name, index) => { + const mine = sessions.filter((one) => one.agent === name) const count = mine.reduce((sum, one) => sum + (turns.get(one.traced) || []).length, 0) return { - role, + name, + role: given[name] ? given[name].role : '', person: false, color: lane(index), - spec: specs[role] || '', + spec: given[name] ? given[name].runs : '', working: false, status: traced ? `${count} turn${count === 1 ? '' : 's'}` : `${mine.length} session${mine.length === 1 ? '' : 's'}`, // Its status says them already. @@ -223,18 +229,18 @@ export function written(detail, traced) { const began = Date.parse(detail.began) / 1000 const from = all.length ? Math.min(...all.map((turn) => turn.start)) : began const to = all.length ? Math.max(...all.map((turn) => turn.end)) : detail.ended ? Date.parse(detail.ended) / 1000 : began + 1 - return { flow: detail.flow, frames: frames.filter((one) => one.roles.length || one.flow === detail.flow), agents, handovers: traced ? handed(sessions, traced) : [], latest: null, from, to, live: false } + return { flow: detail.flow, frames: frames.filter((one) => one.names.length || one.flow === detail.flow), agents, handovers: traced ? handed(sessions, traced) : [], latest: null, from, to, live: false } } -/** Who handed to whom in a run written down: each turn, in the order taken, after another role's. */ +/** Who handed to whom in a run written down: each turn, in the order taken, after another agent's. */ function handed(sessions, traced) { - const roleOf = Object.fromEntries(sessions.map((one) => [one.traced, one.role])) + const agentOf = Object.fromEntries(sessions.map((one) => [one.traced, one.agent])) const taken = traced - .flatMap((one) => one.actions.filter((action) => action.category === 'turn').map((action) => ({ start: action.start, role: roleOf[one.key] || one.agent || one.key }))) + .flatMap((one) => one.actions.filter((action) => action.category === 'turn').map((action) => ({ start: action.start, agent: agentOf[one.key] || one.agent || one.key }))) .sort((a, b) => a.start - b.start) const counts = new Map() for (let index = 1; index < taken.length; index++) { - const [from, to] = [taken[index - 1].role, taken[index].role] + const [from, to] = [taken[index - 1].agent, taken[index].agent] if (from === to) continue counts.set(`${from}\n${to}`, (counts.get(`${from}\n${to}`) || 0) + 1) } diff --git a/src/hmz/web/static/graph.js b/src/hmz/web/static/graph.js index 03924578d..4696ccf3e 100644 --- a/src/hmz/web/static/graph.js +++ b/src/hmz/web/static/graph.js @@ -1,7 +1,7 @@ // An epic drawn as a graph: a frame for each flow it ran, a node for each agent -- and each // person -- in the flow it worked in, the sessions an agent opened listed on its node, each as // a lane of the turns it took over the run, and an arrow for each way the run handed from one -// role to another, as many times as it did. While the run goes, the node working is lit, the +// agent to another, as many times as it did. While the run goes, the node working is lit, the // last handover is the red one, and a line on every lane says where now is. import { hash } from './api.js' @@ -28,7 +28,7 @@ const SLOP = 4 const BOW = 42 /** - * @param {{open: (role: string) => void}} handlers What picking an agent does. + * @param {{open: (name: string) => void}} handlers What picking an agent does. * @returns {{node: HTMLElement, draw: (model: object, focus: string, epic: string) => void}} */ export function graph({ open }) { @@ -200,7 +200,7 @@ export function graph({ open }) { function draw(model, focus, epic) { last = [model, focus, epic] // Drawn again only where what it is made of changed; the clocks on it move on their own. - const made = JSON.stringify([model.frames, model.agents.map(({ role, working, status, spec, sessions, turns, tokens, under }) => [role, working, status, spec, turns, tokens, under, sessions.map((one) => [one.key, one.working, one.unread, one.turns.length, one.turns.map((turn) => turn.end === null)])]), model.handovers, model.latest, model.live, focus]) + const made = JSON.stringify([model.frames, model.agents.map(({ name, role, working, status, spec, sessions, turns, tokens, under }) => [name, role, working, status, spec, turns, tokens, under, sessions.map((one) => [one.key, one.working, one.unread, one.turns.length, one.turns.map((turn) => turn.end === null)])]), model.handovers, model.latest, model.live, focus]) if (made !== shape) { shape = made build(model, focus, epic) @@ -209,7 +209,7 @@ export function graph({ open }) { } function build(model, focus, epic) { - const byRole = Object.fromEntries(model.agents.map((one) => [one.role, one])) + const byName = Object.fromEntries(model.agents.map((one) => [one.name, one])) laidAcross = across() const boxes = {} const frames = [] @@ -217,21 +217,21 @@ export function graph({ open }) { let widest = 0 for (const frame of model.frames) { const rows = [] - for (let index = 0; index < frame.roles.length; index += laidAcross) rows.push(frame.roles.slice(index, index + laidAcross)) + for (let index = 0; index < frame.names.length; index += laidAcross) rows.push(frame.names.slice(index, index + laidAcross)) if (!rows.length) rows.push([]) let inner = 0 let height = LABEL for (const row of rows) { - const tall = Math.max(HEAD + FOOT, ...row.map((role) => heightOf(byRole[role]))) + const tall = Math.max(HEAD + FOOT, ...row.map((name) => heightOf(byName[name]))) inner = Math.max(inner, row.length * WIDE + Math.max(0, row.length - 1) * APART) - row.forEach((role, index) => { - boxes[role] = { x: INSET + index * (WIDE + APART), y: y + height, w: WIDE, h: heightOf(byRole[role]) } + row.forEach((name, index) => { + boxes[name] = { x: INSET + index * (WIDE + APART), y: y + height, w: WIDE, h: heightOf(byName[name]) } }) height += tall + DOWN } height = height - DOWN + INSET const width = Math.max(inner, WIDE) + INSET * 2 - frames.push({ flow: frame.flow, x: 0, y, w: width, h: height, roles: frame.roles }) + frames.push({ flow: frame.flow, x: 0, y, w: width, h: height, names: frame.names }) widest = Math.max(widest, width) y += height + DOWN * 0.6 } @@ -239,7 +239,7 @@ export function graph({ open }) { for (const frame of frames) { const shift = (widest - frame.w) / 2 frame.x = shift - for (const role of frame.roles) boxes[role].x += shift + for (const name of frame.names) boxes[name].x += shift } // Room above and below for the widest bows, which reach out as far as the frames are wide. // Stacked one over another, they bow out to the sides instead. @@ -268,7 +268,7 @@ export function graph({ open }) { h('div', { class: ['frame', index === 0 && 'root'], style: { left: `${frame.x}px`, top: `${frame.y}px`, width: `${frame.w}px`, height: `${frame.h}px` } }, h('span', { class: 'frame-label', title: frame.flow }, index === 0 ? 'flow' : 'called', h('b', {}, flowName(frame.flow)))), ), edges, - ...model.agents.filter((one) => boxes[one.role]).map((one) => agentNode(one, boxes[one.role], focus === one.role, epic, model.live)), + ...model.agents.filter((one) => boxes[one.name]).map((one) => agentNode(one, boxes[one.name], focus === one.name, epic, model.live)), ) stage.style.width = `${size.width}px` stage.style.height = `${size.height}px` @@ -287,14 +287,16 @@ export function graph({ open }) { function agentNode(agent, box, focused, epic, going) { const base = hash(`/epics/${encodeURIComponent(epic)}`) + // The role an agent fills is said after its name, beside what it runs as; a person's is its name. + const about = [agent.role !== agent.name && agent.role, agent.spec].filter(Boolean).join(' · ') const head = h( 'button', { class: 'node-head', type: 'button', 'aria-pressed': String(focused) }, - h('span', { class: 'node-name' }, h('i', { class: 'swatch', 'aria-hidden': 'true' }), agent.role, agent.person ? h('span', { class: 'node-tag' }, 'person') : null), - h('span', { class: 'node-spec mono' }, agent.spec || '—'), + h('span', { class: 'node-name' }, h('i', { class: 'swatch', 'aria-hidden': 'true' }), agent.name, agent.person ? h('span', { class: 'node-tag' }, 'person') : null), + h('span', { class: 'node-spec mono' }, about || '—'), h('span', { class: 'node-state' }, agent.working ? h('span', { class: 'dot live', 'aria-hidden': 'true' }) : null, [agent.status, agent.tokens ? `${agent.tokens} tokens` : ''].filter(Boolean).join(' · ')), ) - head.addEventListener('click', () => open(agent.role)) + head.addEventListener('click', () => open(agent.name)) const sessions = agent.sessions.map((one) => { const track = h('span', { class: 'track', 'aria-hidden': 'true' }) one.turns.forEach((turn, index) => { @@ -325,20 +327,20 @@ export function graph({ open }) { class: ['node', agent.working && 'working', agent.person && 'person', focused && 'focused', agent.sessions.some((one) => one.unread) && 'has-unread'], style: { left: `${box.x}px`, top: `${box.y}px`, width: `${box.w}px`, height: `${box.h}px`, '--lane': agent.color }, 'data-hmz': 'graph-agent', - 'data-role': agent.role, + 'data-agent': agent.name, }, head, h( 'div', { class: 'node-sessions' }, - h('a', { class: 'node-session node-log', href: `${base}/logs/${encodeURIComponent(agent.role)}`, 'data-hmz': 'graph-log' }, h('span', { class: 'session-key mono' }, '≡ log'), h('span', { class: 'dim' }, agent.person ? 'asked and answered' : 'every session')), + h('a', { class: 'node-session node-log', href: `${base}/logs/${encodeURIComponent(agent.name)}`, 'data-hmz': 'graph-log' }, h('span', { class: 'session-key mono' }, '≡ log'), h('span', { class: 'dim' }, agent.person ? 'asked and answered' : 'every session')), sessions, ), ) return menuOn(made, () => [ - { head: agent.spec ? `${agent.role} · ${agent.spec}` : agent.role }, - agent.person ? null : { label: focused ? 'Shown' : 'Pick out', glyph: 'agent', act: () => open(agent.role), disabled: focused }, - { label: 'Open log', glyph: 'thread', href: `${base}/logs/${encodeURIComponent(agent.role)}` }, + { head: [agent.name, about].filter(Boolean).join(' · ') }, + agent.person ? null : { label: focused ? 'Shown' : 'Pick out', glyph: 'agent', act: () => open(agent.name), disabled: focused }, + { label: 'Open log', glyph: 'thread', href: `${base}/logs/${encodeURIComponent(agent.name)}` }, ...agent.sessions.slice(0, 8).map((one) => ({ label: `Open ${one.key}`, glyph: 'open', href: `${base}/sessions/${encodeURIComponent(one.key)}` })), '-', agent.spec && !agent.person ? { label: 'Copy agent', glyph: 'copy', act: () => copy(agent.spec, 'Agent copied') } : null, diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js index 117e5dca6..7c001a561 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -221,7 +221,7 @@ export function mountSidebar(root, context, where) { } const detail = read && read.detail const sessions = detail ? sessionsOf(detail) : [] - const agents = run.agents.map((one) => ({ role: one.role, runs: one.runs, sessions: sessions.filter((session) => session.role === one.role) })) + const agents = run.agents.map((one) => ({ name: one.name, role: one.role, runs: one.runs, sessions: sessions.filter((session) => session.agent === one.name) })) if (!agents.length) return h('p', { class: 'side-quiet nested' }, 'No agents') const base = hash(`/epics/${encodeURIComponent(run.name)}`) const logged = run.name === here && parts[2] === 'logs' && !parts[3] @@ -246,22 +246,22 @@ export function mountSidebar(root, context, where) { ]), ), agents.map((agent) => { - const at = run.name === here && parts[2] === 'agents' && parts[3] === agent.role + const at = run.name === here && parts[2] === 'agents' && parts[3] === agent.name return h( 'li', {}, menuOn( h( 'a', - { class: ['side-agent', at && 'current'], href: `${base}/agents/${encodeURIComponent(agent.role)}`, 'aria-current': at ? 'page' : null, title: agent.runs, 'data-hmz': 'agent' }, + { class: ['side-agent', at && 'current'], href: `${base}/agents/${encodeURIComponent(agent.name)}`, 'aria-current': at ? 'page' : null, title: `${agent.role} · ${agent.runs}`, 'data-hmz': 'agent' }, icon('agent'), - h('span', { class: 'side-label' }, agent.role), + h('span', { class: 'side-label' }, agent.name), h('span', { class: 'side-meta mono' }, agent.runs.split('/')[0]), ), () => [ - { head: agent.runs }, - { label: 'Show on graph', glyph: 'flows', href: `${base}/agents/${encodeURIComponent(agent.role)}` }, - { label: 'Open log', glyph: 'thread', href: `${base}/logs/${encodeURIComponent(agent.role)}` }, + { head: `${agent.role} · ${agent.runs}` }, + { label: 'Show on graph', glyph: 'flows', href: `${base}/agents/${encodeURIComponent(agent.name)}` }, + { label: 'Open log', glyph: 'thread', href: `${base}/logs/${encodeURIComponent(agent.name)}` }, '-', { label: 'Copy agent', glyph: 'copy', act: () => copy(agent.runs, 'Agent copied') }, ], diff --git a/src/hmz/web/static/transcript.js b/src/hmz/web/static/transcript.js index c921a78f3..1a87959eb 100644 --- a/src/hmz/web/static/transcript.js +++ b/src/hmz/web/static/transcript.js @@ -56,9 +56,9 @@ function threadOf(one) { /** * What a run going says, a record at a time, as one of the terminal interface's transcripts - * says it: one session's, `/`; one agent's, every session of it, `agent:`; one - * person's, what is asked of them and what they answer, `person:`; or every one's, the - * shared one, where `key` is "". Kept between records, so that a transcript of thousands of + * says it: one session's, `/`; one agent's, every session of it, `agent:`; one + * person's, what is asked of them and what they answer, `person:`, by the role they + * answer; or every one's, the shared one, where `key` is "". Kept between records, so that a transcript of thousands of * lines is drawn once and then added to, rather than drawn again for every word. * * A chat's is read as a conversation: its session's thread opens on the task and goes on with @@ -69,15 +69,15 @@ function threadOf(one) { */ export function sayer(key, { chat = false } = {}) { const began = new Map() - const [kind, role] = key.startsWith('agent:') ? ['agent', key.slice(6)] : key.startsWith('person:') ? ['person', key.slice(7)] : ['session', ''] + const [kind, whom] = key.startsWith('agent:') ? ['agent', key.slice(6)] : key.startsWith('person:') ? ['person', key.slice(7)] : ['session', ''] const of = (record) => String(record.session || record.key || record.agent || '') - const whose = (record) => (kind === 'agent' ? of(record).split('/')[0] === role : kind === 'person' ? false : of(record) === key) + const whose = (record) => (kind === 'agent' ? of(record).split('/')[0] === whom : kind === 'person' ? false : of(record) === key) const label = !key || kind === 'agent' return (record) => { const mine = !key || whose(record) switch (record.type) { case 'said': { - const toPerson = kind === 'person' && (record.to === `outworlder:${role}` || record.role === role || record.by === role) + const toPerson = kind === 'person' && (record.to === `outworlder:${whom}` || record.role === whom || record.by === whom) return mine || toPerson ? [line('you', record.text, { who: record.by ? `${record.by} said` : '' })] : [] } case 'asked': @@ -85,12 +85,12 @@ export function sayer(key, { chat = false } = {}) { // A question asked while a turn is open is that session's, where it says whose it is. return !key || (kind === 'person' - ? record.role === role + ? record.role === whom : record.key ? whose(record) : chat ? kind === 'session' - : record.role && (kind === 'agent' ? record.role === role : key.startsWith(`${record.role}/`))) + : record.role && (kind === 'agent' ? record.role === whom : key.startsWith(`${record.role}/`))) ? [line('asks', record.text, { who: `${record.role || 'the flow'} asks` })] : [] case 'answered': diff --git a/src/hmz/web/static/views/epic.js b/src/hmz/web/static/views/epic.js index b544c6936..86c86c4b1 100644 --- a/src/hmz/web/static/views/epic.js +++ b/src/hmz/web/static/views/epic.js @@ -1,5 +1,5 @@ // One epic: the run going, as it happens, or one written down. `#/runs/` draws it as a -// graph of its agents and who handed to whom; `/agents/` is the graph with that agent +// graph of its agents and who handed to whom; `/agents/` is the graph with that agent // picked out; `/sessions/` is one session's thread, as a chat is read; `/exomyth` is its // timeline, drawn by views/exomyth. `#/live` is whichever run is going -- a chat's opened on its // conversation rather than its graph. Under the view, while the run goes, are the questions it @@ -37,7 +37,7 @@ export function mount(root, { parts, query }) { // A person is read by their log, as the terminal interface's monitor reads one; an agent is // picked out on the graph. - const drawn_ = graph({ open: (role) => go(`/epics/${encodeURIComponent(name)}/${persons.has(role) ? 'logs' : 'agents'}/${encodeURIComponent(role)}`) }) + const drawn_ = graph({ open: (who) => go(`/epics/${encodeURIComponent(name)}/${persons.has(who) ? 'logs' : 'agents'}/${encodeURIComponent(who)}`) }) // What is said to the epic, and asked beside it, once it is known which workspace it is of. let say = null let sayTo = null @@ -110,13 +110,13 @@ export function mount(root, { parts, query }) { mode = modeOf(['epics', name, 'sessions', opened.key]) } const model = run ? live(held, run, detail) : written(detail, traced) - const roleOfSession = mode.session ? mode.session.split('/')[0] : mode.log || '' - persons = new Set(model.agents.filter((one) => one.person).map((one) => one.role)) + const agentOfSession = mode.session ? mode.session.split('/')[0] : mode.log || '' + persons = new Set(model.agents.filter((one) => one.person).map((one) => one.name)) drawHead(detail, run) if (reading(mode)) drawThread(detail, run) else drawGraph(detail, run, model) drawDock(detail, run) - drawPanel(detail, run, model, mode.focus || roleOfSession) + drawPanel(detail, run, model, mode.focus || agentOfSession) } // ------------------------------------------------------------------ the bar @@ -171,7 +171,7 @@ export function mount(root, { parts, query }) { for (const one of ['thread', 'thread-head', 'thread-ask', 'agent-list', 'graph-note']) drawn[one] = undefined }) if (shape === 'list') { - region('agent-list', [model.agents.map(({ role, working, status, spec, sessions, tokens }) => [role, working, status, spec, tokens, sessions.map((one) => [one.key, one.working, one.turns.length])]), mode.focus], () => fill(listed, listOf(model, detail.name, mode.focus))) + region('agent-list', [model.agents.map(({ name, role, working, status, spec, sessions, tokens }) => [name, role, working, status, spec, tokens, sessions.map((one) => [one.key, one.working, one.turns.length])]), mode.focus], () => fill(listed, listOf(model, detail.name, mode.focus))) return } const asking = !run && !traced @@ -286,7 +286,7 @@ export function mount(root, { parts, query }) { region('thread', [key, Boolean(traced), tracing, String(traceError)], () => { saidFor = '' // The sessions the thread is of: one, every one of an agent's, or every one. - const sessions = sessionsOf(detail).filter((one) => (mode.session ? one.key === key : !mode.log || one.role === mode.log)) + const sessions = sessionsOf(detail).filter((one) => (mode.session ? one.key === key : !mode.log || one.agent === mode.log)) const read = traced ? sessions.map((one) => ({ ...(traced.find((each) => each.key === one.traced) || { actions: [] }), label: one.key })).filter((one) => one.actions.length) : [] fill( box, @@ -331,9 +331,10 @@ export function mount(root, { parts, query }) { if (going) { const sessions = (standing.sessions && standing.sessions.open) || [] const working = (standing.sessions && standing.sessions.working) || [] - const roles = run.started.roles || Object.keys(run.started.agents || {}) + const names = run.started.names || {} + const agents = (run.started.roles || Object.keys(run.started.agents || {})).map((role) => names[role] || role) const people = run.started.outworlders || [] - region('dock-offers', [roles, sessions, working, people], () => say.offers(roles, sessions, working, people)) + region('dock-offers', [agents, sessions, working, people], () => say.offers(agents, sessions, working, people)) // In a thread, a line goes to whose thread it is: a session, an agent, or a person. const to = mode.session || (mode.log ? (persons.has(mode.log) ? `outworlder:${mode.log}` : mode.log) : '') if (to && sayToKey !== to) { @@ -402,15 +403,15 @@ export function mount(root, { parts, query }) { } function overview(detail, run, model, focus, going, figures, standing) { - const agent = model.agents.find((one) => one.role === focus) + const agent = model.agents.find((one) => one.name === focus) const base = hash(`/epics/${encodeURIComponent(detail.name)}`) const parts = [] if (agent) { parts.push( section( - agent.role, + agent.name, h('div', { class: 'focus', style: { '--lane': agent.color }, 'data-hmz': 'focus' }, - h('div', { class: 'mono dim' }, agent.spec || '—'), + h('div', { class: 'mono dim' }, [agent.role !== agent.name && agent.role, agent.spec].filter(Boolean).join(' · ') || '—'), h('div', {}, [agent.status, agent.turns !== null && agent.turns !== undefined ? `${agent.turns} turn${agent.turns === 1 ? '' : 's'}` : '', agent.tokens ? `${agent.tokens} tokens` : ''].filter(Boolean).join(' · ')), agent.sessions.length ? h('ul', { class: 'links' }, agent.sessions.map((one) => h('li', {}, h('a', { href: `${base}/sessions/${encodeURIComponent(one.key)}` }, one.working ? '● ' : '○ ', h('span', { class: 'mono' }, one.key)), one.about ? h('span', { class: 'dim' }, ` ${one.about}`) : null))) @@ -435,7 +436,7 @@ export function mount(root, { parts, query }) { tile('spent', spent ? spent.money || '—' : '—', ''), tile('output tokens', spent ? spent.tokens : '—', ''), tile('took', took(detail, null) || '—', spent && spent.worked ? `${spent.worked} of it working` : ''), - tile('sessions', String(detail.sessions.length), `${detail.agents.length} agent role${detail.agents.length === 1 ? '' : 's'}`), + tile('sessions', String(detail.sessions.length), `${detail.agents.length} agent${detail.agents.length === 1 ? '' : 's'}`), ), ), ) @@ -555,7 +556,8 @@ function modeOf(parts) { return { session: parts[2] === 'sessions' ? parts[3] || '' : '', focus: parts[2] === 'agents' ? parts[3] || '' : '', - // A log: every agent's and person's lines, "" -- or one agent's, or one person's, by role. + // A log: every agent's and person's lines, "" -- or one agent's, by its name, or one + // person's, by the role they answer. log: parts[2] === 'logs' ? parts[3] || '' : null, } } @@ -614,19 +616,19 @@ function listOf(model, epic, focus) { return order.map((agent) => h( 'section', - { class: ['list-agent', agent.working && 'working', agent.role === focus && 'focused'], style: { '--lane': agent.color }, 'data-role': agent.role }, + { class: ['list-agent', agent.working && 'working', agent.name === focus && 'focused'], style: { '--lane': agent.color }, 'data-agent': agent.name }, h( 'a', - { class: 'list-head', href: agent.person ? `${base}/logs/${encodeURIComponent(agent.role)}` : `${base}/agents/${encodeURIComponent(agent.role)}` }, + { class: 'list-head', href: agent.person ? `${base}/logs/${encodeURIComponent(agent.name)}` : `${base}/agents/${encodeURIComponent(agent.name)}` }, h('i', { class: 'swatch', 'aria-hidden': 'true' }), - h('b', {}, agent.role), - h('span', { class: 'mono dim' }, agent.spec || '—'), + h('b', {}, agent.name), + h('span', { class: 'mono dim' }, [agent.role !== agent.name && agent.role, agent.spec].filter(Boolean).join(' · ') || '—'), h('span', { class: 'list-state' }, agent.working ? h('span', { class: 'dot live', 'aria-hidden': 'true' }) : null, [agent.status, agent.tokens ? `${agent.tokens} tokens` : ''].filter(Boolean).join(' · ')), ), h( 'ul', { class: 'links' }, - h('li', {}, h('a', { href: `${base}/logs/${encodeURIComponent(agent.role)}` }, '≡ log')), + h('li', {}, h('a', { href: `${base}/logs/${encodeURIComponent(agent.name)}` }, '≡ log')), agent.sessions.map((one) => h('li', {}, h('a', { href: `${base}/sessions/${encodeURIComponent(one.key)}` }, one.working ? '● ' : '○ ', h('span', { class: 'mono' }, one.key)), h('span', { class: 'dim' }, ` ${one.turns.length} turn${one.turns.length === 1 ? '' : 's'}${one.about ? ` · ${one.about}` : ''}`))), ), ), @@ -688,7 +690,7 @@ function facts(detail) { h('dt', {}, 'workdir'), h('dd', {}, pathed(detail.workspace)), h('dt', {}, 'agents'), - h('dd', {}, detail.agents.length ? detail.agents.map((one) => h('div', { class: 'mono' }, `${one.role}=${one.runs}`)) : '—'), + h('dd', {}, detail.agents.length ? detail.agents.map((one) => h('div', { class: 'mono' }, [one.name !== one.role && one.name, `${one.role}=${one.runs}`].filter(Boolean).join(' · '))) : '—'), h('dt', {}, 'envs'), h('dd', {}, detail.used.length ? detail.used.map((one) => h('div', { class: 'mono' }, one)) : '—'), h('dt', {}, 'params'), diff --git a/src/hmz/web/static/views/exomyth/flame.js b/src/hmz/web/static/views/exomyth/flame.js index b045c35a1..975502a09 100644 --- a/src/hmz/web/static/views/exomyth/flame.js +++ b/src/hmz/web/static/views/exomyth/flame.js @@ -2,14 +2,14 @@ // the comment at its top), over the stretch of time picked on the Timeline or the whole -- and // under it, every slice name in a table. // -// A frame's stack is the epic, then an agent (its role) -- or a flow, or a program -- each of -// its sessions merged into it, or apart as processes of their own, then its spans as they nest: -// turns, then model calls, tools, messages and events by name. A session's own span is its -// process's frame. Summed once for each stretch of time and way of merging, in one pass over -// the spans in it -- each depth of each track binary searched to the stretch and walked beside -// the depth over it, which holds each span's parent -- into typed columns every weight is read -// from; laid out once for each weight; drawn on the next frame after anything changes, a frame -// narrower than a pixel left out with all it holds. +// A frame's stack is the epic, then an agent (by its name, the role it fills after) -- or a +// flow, or a program -- each of its sessions merged into it, or apart as processes of their +// own, then its spans as they nest: turns, then model calls, tools, messages and events by +// name. A session's own span is its process's frame. Summed once for each stretch of time and +// way of merging, in one pass over the spans in it -- each depth of each track binary searched +// to the stretch and walked beside the depth over it, which holds each span's parent -- into +// typed columns every weight is read from; laid out once for each weight; drawn on the next +// frame after anything changes, a frame narrower than a pixel left out with all it holds. import { h } from '../../dom.js' import { clock, counted, firstEnding, lasted } from './model.js' diff --git a/src/hmz/web/static/views/search.js b/src/hmz/web/static/views/search.js index 5c6c84405..06ed25fa9 100644 --- a/src/hmz/web/static/views/search.js +++ b/src/hmz/web/static/views/search.js @@ -115,7 +115,7 @@ export function mount(root, { query }) { { class: 'result-meta' }, h('span', { class: 'mono', title: run.workspace }, nameOf(run.workspace)), h('span', { class: 'mono', title: run.flow }, flowName(run.flow)), - run.agents.map((one) => h('span', { class: 'chip', title: one.runs }, one.role, h('span', { class: 'dim' }, one.runs.split(':')[0]))), + run.agents.map((one) => h('span', { class: 'chip', title: `${one.role} · ${one.runs}` }, one.name, h('span', { class: 'dim' }, one.runs.split(':')[0]))), h('span', { class: 'mono' }, run.spent ? `${run.spent.money || '—'} · ${run.spent.tokens} out` : '—'), run.ended ? h('span', { class: 'mono' }, `took ${lasting((Date.parse(run.ended) - Date.parse(run.began)) / 1000)}`) : null, h('span', { class: 'mono' }, `${run.sessions} session${run.sessions === 1 ? '' : 's'}`), diff --git a/tests/integration/test_core_exec.py b/tests/integration/test_core_exec.py index 9fa102420..4e7a96eab 100644 --- a/tests/integration/test_core_exec.py +++ b/tests/integration/test_core_exec.py @@ -157,7 +157,11 @@ def test_chat_answers_on_stdout_and_works_in_the_project( assert (ran_as.flow, ran_as.task, ran_as.how) == ("chat", "hello there", "done") assert [one.spec for one in ran_as.agents] == [f"{AGENT}:high"] (session,) = ran_as.sessions - assert (session.agent, session.backend) == ("assistant", "claude") + # Under the agent's own name, which the epic says fills `assistant`. + (drove,) = ran_as.agents + assert (drove.agent, drove.name) == ("assistant", session.agent) + assert session.agent != "assistant" + assert session.backend == "claude" assert session.ident == start["session"] @@ -220,11 +224,10 @@ def test_json_writes_the_run_as_one_object_per_line() -> None: assert said[-1]["kind"] == "ends" (result,) = [one for one in said if one["kind"] == "result"] assert result["text"] == "goal met: win" - assert (result["agent"], result["cli"], result["model"]) == ( - "worker", - "claude", - MODEL, - ) + # The agent's own name, never the role it fills -- and the same on every line of it. + assert result["agent"] != "worker" + assert {one["agent"] for one in said} == {result["agent"]} + assert (result["cli"], result["model"]) == ("claude", MODEL) assert result["tokens"] == {MODEL: 10 + TOKENS} diff --git a/tests/integration/test_tui_exomyth.py b/tests/integration/test_tui_exomyth.py index e219113f3..540fb971a 100644 --- a/tests/integration/test_tui_exomyth.py +++ b/tests/integration/test_tui_exomyth.py @@ -40,7 +40,7 @@ def workspace(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, asking: None) -> workspace = stand_in(tmp_path, monkeypatch) began = time.time() with Epic("chat", "fix the bug", workspace) as epic: - epic.session("assistant", "claude", "", "sess-1") + epic.session("NeiKos496", "claude", "", "sess-1") _logged(tmp_path / "claude-home", workspace, "sess-1", began) return workspace diff --git a/tests/integration/test_tui_monitor.py b/tests/integration/test_tui_monitor.py index 4852c33b4..bd0c2e8a6 100644 --- a/tests/integration/test_tui_monitor.py +++ b/tests/integration/test_tui_monitor.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING import pytest @@ -57,7 +58,10 @@ async def test_left_goes_up_to_the_monitor_of_the_run_and_right_comes_back( await pilot.press("right") await until(pilot, lambda: len(app.screen_stack) == 1, "the prompt") - await shows(pilot, "assistant…") # reading it stopped nothing + # Reading it stopped nothing: the agent is still working, by its own name. + working_as = re.search(r"(\S+) is working", screen(app)) + assert working_as is not None + await shows(pilot, f"{working_as[1]}…", f"{working_as[1]} · assistant · ") await typed(pilot, "go on") await shows(pilot, "heard wait a moment then go on") diff --git a/tests/integration/test_web_runs.py b/tests/integration/test_web_runs.py index 474d007b9..5d6b29b1b 100644 --- a/tests/integration/test_web_runs.py +++ b/tests/integration/test_web_runs.py @@ -92,10 +92,13 @@ def test_a_line_said_in_the_conversation_between_turns_is_the_next_turn( browser.signs_in() with browser.follows() as heard: browser.post("/api/held/start", _CHAT) + _, opened = until(heard, _record("opened"), "the session opening") _, asked = until(heard, _record("asked", mode="listen"), "chat asking") assert asked["key"] == "" - said = {"text": "tell me more", "to": "assistant/1"} + # Its conversation goes by its agent's own name, never by the role it fills. + assert opened["key"] == f"{opened['agent']}/1" != "assistant/1" + said = {"text": "tell me more", "to": opened["key"]} assert browser.post("/api/held/say", said).status == 200 until( heard, @@ -154,20 +157,20 @@ def test_a_side_question_is_asked_beside_the_run_and_never_of_it( browser.signs_in() with browser.follows() as heard: browser.post("/api/held/start", _CHAT) + _, opened = until(heard, _record("opened"), "the session opening") until(heard, _record("asked"), "chat asking") + key = opened["key"] aside = browser.post("/api/btw", {"question": "what is it doing"}) assert aside.status == 200 assert aside.json()["answer"].startswith("did: ") - of_one = browser.post( - "/api/btw", {"question": "and you", "to": "assistant/1"} - ) + of_one = browser.post("/api/btw", {"question": "and you", "to": key}) assert of_one.json()["answer"].startswith("did: ") kept = browser.get("/api/btw").json() - assert kept["open"] == ["", "assistant/1"] + assert kept["open"] == ["", key] assert [(one["to"], one["question"]) for one in kept["said"]] == [ ("", "what is it doing"), - ("assistant/1", "and you"), + (key, "and you"), ] assert browser.post("/api/btw/leave").status == 200 assert browser.get("/api/btw").json() == {"open": [], "said": []} @@ -284,28 +287,32 @@ def test_a_session_of_an_earlier_epic_is_asked_beside_it_as_a_fork_of_its_histor earlier, latest = reversed(browser.get("/api/runs").json()["runs"]) assert latest["task"] == "and then this" - kept = ( - Path(browser.get(f"/api/runs/{earlier['name']}").json()["at"]) / "sessions" - ) + written = browser.get(f"/api/runs/{earlier['name']}").json() + kept = Path(written["at"]) / "sessions" before = sorted(str(one) for one in kept.rglob("*")) + # Its session goes by its agent's own name, and the role it filled is said beside it. + (agent,) = written["agents"] + assert (agent["role"], written["sessions"][0]["agent"]) == ( + "assistant", + agent["name"], + ) + assert agent["name"] != "assistant" aside = browser.post( "/api/btw", { "question": "what was the word", - "to": "assistant/1", + "to": f"{agent['name']}/1", "epic": earlier["name"], }, ) assert aside.status == 200, aside.body said = aside.json() - assert said["to"] == f"{earlier['name']}/assistant/1" + assert said["to"] == f"{earlier['name']}/{agent['name']}/1" # A fork carries the session's history and is told it is a side copy of it. assert "read-only side copy of the flow session" in said["answer"] - ident = browser.get(f"/api/runs/{earlier['name']}").json()["sessions"][0][ - "ident" - ] + ident = written["sessions"][0]["ident"] argv = started(tmp_path)[-1]["argv"] assert argv[argv.index("--resume") + 1] == ident assert "--fork-session" in argv @@ -315,7 +322,7 @@ def test_a_session_of_an_earlier_epic_is_asked_beside_it_as_a_fork_of_its_histor assert ( browser.post( "/api/btw", - {"question": "x", "to": "assistant/9", "epic": earlier["name"]}, + {"question": "x", "to": f"{agent['name']}/9", "epic": earlier["name"]}, ).status == 404 ) diff --git a/tests/system/test_tui_ralph_loop.py b/tests/system/test_tui_ralph_loop.py index fdb06527a..8afb42aaf 100644 --- a/tests/system/test_tui_ralph_loop.py +++ b/tests/system/test_tui_ralph_loop.py @@ -71,8 +71,8 @@ def _passes(workspace: Path) -> None: async def _watches(screen: Screen, model: str) -> None: - """Watches a run going: an agent working, and on the monitor its turns and their cost.""" - await screen.waits("agent is working", seconds=180) + """Watches a run going: its agent working, by its name, and on the monitor what it spent.""" + await screen.waits(" is working", seconds=180) # The monitor, with nothing typed: the agent's box, and the money under the drawing. await screen.press("left") shown = await screen.waits( @@ -81,7 +81,7 @@ async def _watches(screen: Screen, model: str) -> None: assert "Flow:" in shown assert "ralph_loop" in shown await screen.press("right") - await screen.waits("agent is working", "ctrl+c stop") + await screen.waits(" is working", "ctrl+c stop") @pytest.mark.timeout(20 * 60) @@ -146,7 +146,7 @@ async def test_a_run_left_going_is_found_again_by_the_next_interface( await screen.waits("The agent flow system") await screen.sets_up("ralph_loop", cli, model, BUDGET) await screen.starts(TASK) - await screen.waits("agent is working", seconds=180) + await screen.waits(" is working", seconds=180) # Leaving, and leaving it running. await screen.types("/exit") await screen.press("enter") diff --git a/tests/system/web/test_web_page.py b/tests/system/web/test_web_page.py index 8d4c8ea5a..311bbd9ae 100644 --- a/tests/system/web/test_web_page.py +++ b/tests/system/web/test_web_page.py @@ -68,7 +68,7 @@ def _start_chat(page: Page, task: str) -> None: ) page.locator("[data-hmz=composer-text]").fill(task) page.locator("[data-hmz=composer-text]").press("Enter") - page.wait_for_url(re.compile(r".*#/epics/[^/]+/sessions/assistant%2F1$")) + page.wait_for_url(re.compile(r".*#/epics/[^/]+/sessions/[^/]+%2F1$")) def test_a_new_epic_offers_each_role_the_models_its_cli_runs( @@ -143,7 +143,7 @@ def test_the_graph_is_dragged_anywhere_zoomed_by_the_wheel_and_recentred( _start_chat(page, "move me") expect(page.locator("[data-hmz=thread]")).to_contain_text("did: move me") page.locator("[data-hmz=bar] .crumbs a").click() - agent = page.locator("[data-hmz=graph-agent][data-role=assistant]") + agent = page.locator("[data-hmz=graph-agent]").filter(has_text="assistant") expect(agent.locator("[data-hmz=graph-session]")).to_be_visible() # Stopped, so nothing it goes on to do redraws the graph while it is moved. page.get_by_role("button", name="Stop").click() @@ -155,7 +155,7 @@ def test_the_graph_is_dragged_anywhere_zoomed_by_the_wheel_and_recentred( # settles around it -- what the run spent, how long it took -- resizes the canvas. page.wait_for_function( """() => new Promise((settled) => { - const at = () => JSON.stringify(['[data-hmz=graph-agent][data-role=assistant]', + const at = () => JSON.stringify(['[data-hmz=graph-agent]:not(.person)', '[data-hmz=graph]'].map((one) => document.querySelector(one) ?.getBoundingClientRect())) const before = at() @@ -233,7 +233,9 @@ def test_an_epic_started_from_the_page_is_drawn_answered_read_and_stopped( expect(thread).to_contain_text("did: hello there") expect(page.locator("[data-hmz=epic] .pill-live")).to_be_visible() say = page.locator("[data-hmz=say] textarea") - expect(say).to_have_attribute("placeholder", "Message assistant/1") + # Its conversation goes by its agent's own name, not by the role it fills. + expect(say).to_have_attribute("placeholder", re.compile(r"^Message \w+/1$")) + expect(say).not_to_have_attribute("placeholder", "Message assistant/1") say.fill("and again") say.press("Enter") expect(thread).to_contain_text("did: and again") @@ -243,7 +245,7 @@ def test_an_epic_started_from_the_page_is_drawn_answered_read_and_stopped( expect(page.locator("[data-hmz=waiting]")).to_have_count(0) page.locator("[data-hmz=bar] .crumbs a").click() - agent = page.locator("[data-hmz=graph-agent][data-role=assistant]") + agent = page.locator("[data-hmz=graph-agent]").filter(has_text="assistant") expect(agent).to_be_visible() expect(agent.locator("[data-hmz=graph-session] .unread")).to_have_count(0) page.get_by_role("button", name="Stop").click() @@ -341,7 +343,7 @@ def test_a_chat_asks_in_its_conversation_and_is_answered_there( ) assert started[0] == 200, started[1] go(page, "/live") - page.wait_for_url(re.compile(r".*#/epics/[^/]+/sessions/assistant%2F1$")) + page.wait_for_url(re.compile(r".*#/epics/[^/]+/sessions/[^/]+%2F1$")) # What it asks is asked in the thread, its answers there to pick, never as a card. thread = page.locator("[data-hmz=thread]") @@ -756,8 +758,10 @@ def test_an_epic_is_read_as_its_log_and_its_agents_as_a_list( page.locator("[data-hmz=bar] .crumbs a").click() page.locator(".shape-switch").get_by_role("button", name="List").click() - listed = page.locator("[data-hmz=agent-list] [data-role=assistant]") - expect(listed).to_contain_text("assistant/1") + listed = page.locator("[data-hmz=agent-list] [data-agent]").filter( + has_text="assistant" + ) + expect(listed).to_contain_text(re.compile(r"\w+/1")) listed.get_by_role("link", name="≡ log").click() expect(page.locator("[data-hmz=thread]")).to_contain_text("did: log me") page.locator("[data-hmz=bar] .crumbs a").click() @@ -825,7 +829,7 @@ def test_a_backup_target_is_added_picked_at_a_start_and_an_epic_uploaded_to_it( ).to_have_value("claude-haiku-4-5") page.locator("[data-hmz=composer-text]").fill("back me up") page.locator("[data-hmz=composer-text]").press("Enter") - page.wait_for_url(re.compile(r".*#/epics/[^/]+/sessions/assistant%2F1$")) + page.wait_for_url(re.compile(r".*#/epics/[^/]+/sessions/[^/]+%2F1$")) page.get_by_role("button", name="Stop").click() page.get_by_role("button", name="Stop").click() expect(page.locator("[data-hmz=bar] .badge")).to_have_text( @@ -881,7 +885,7 @@ def test_the_panel_says_every_figure_in_words_and_within_its_edges( page.locator("[data-hmz=bar] .panel-toggle").click() inspector = page.locator("[data-hmz=inspector]") expect(inspector.locator("[data-hmz=environments]")).to_contain_text( - "assistant/1 workspace · local" + re.compile(r"\w+/1 workspace · local") ) expect(inspector.locator("[data-hmz=spending]")).to_contain_text("cache write") assert not re.search( @@ -917,7 +921,7 @@ def test_an_earlier_epic_is_read_out_of_its_logs_and_asked_beside( page.locator("[data-hmz=epic]", has_text="remember papaya").click() page.locator(".graph-note").get_by_role("button", name="Read turns").click() - agent = page.locator("[data-hmz=graph-agent][data-role=assistant]") + agent = page.locator("[data-hmz=graph-agent]").filter(has_text="assistant") expect(agent.locator(".capsule")).to_have_count(1) agent.locator("[data-hmz=graph-session]").click() @@ -927,7 +931,7 @@ def test_an_earlier_epic_is_read_out_of_its_logs_and_asked_beside( page.get_by_role("tab", name="Aside").click() aside = page.locator("[data-hmz=btw]") - aside.get_by_role("combobox").select_option("assistant/1") + aside.get_by_role("combobox").select_option(index=1) aside.locator("textarea").fill("what was the word") aside.locator("textarea").press("Enter") expect(page.locator("[data-hmz=btw-log]")).to_contain_text( diff --git a/tests/system/web/test_web_scoping.py b/tests/system/web/test_web_scoping.py index b77ad3525..3cd9b00bd 100644 --- a/tests/system/web/test_web_scoping.py +++ b/tests/system/web/test_web_scoping.py @@ -100,7 +100,7 @@ def test_an_epic_started_on_a_daemon_stays_on_it(project: Path, engine: str) -> ).to_have_value("claude-haiku-4-5") page.locator("[data-hmz=composer-text]").fill("hello there") page.locator("[data-hmz=composer-text]").press("Enter") - page.wait_for_url(re.compile(r".*#/@local/epics/[^/]+/sessions/assistant%2F1$")) + page.wait_for_url(re.compile(r".*#/@local/epics/[^/]+/sessions/[^/]+%2F1$")) expect(page.locator("[data-hmz=thread]")).to_contain_text("did: hello there") # The epic's stream, and its links, are the daemon's. diff --git a/tests/unit/coganchor/agents/test_base.py b/tests/unit/coganchor/agents/test_base.py index e36c9c766..51e41da34 100644 --- a/tests/unit/coganchor/agents/test_base.py +++ b/tests/unit/coganchor/agents/test_base.py @@ -1032,18 +1032,16 @@ def _pursue(self, objective: str) -> str: # -- the agent ------------------------------------------------------------------------- -def test_an_unnamed_agent_gets_a_codename_and_may_be_renamed() -> None: +def test_an_unnamed_agent_gets_a_codename_of_its_own() -> None: first, second = Scripted(), Scripted() assert first.id != second.id - first.rename("builder") - assert first.id == "builder" - assert first.hooks.agent == "builder" + assert first.hooks.agent == first.id -def test_a_named_agent_keeps_its_name() -> None: +def test_a_named_agent_is_called_by_its_name() -> None: agent = Scripted(name="mine") - agent.rename("theirs") assert agent.id == "mine" + assert agent.hooks.agent == "mine" def test_the_backend_of_a_driver_in_no_table_is_its_class_name() -> None: diff --git a/tests/unit/runtime/doing/test_hosting.py b/tests/unit/runtime/doing/test_hosting.py index 0b12a5132..f52e74856 100644 --- a/tests/unit/runtime/doing/test_hosting.py +++ b/tests/unit/runtime/doing/test_hosting.py @@ -58,7 +58,8 @@ class Agent: counts: frozenset[str] = frozenset({"turns"}) - id: str = "builder" + #: Its own name, not the role it fills. + id: str = "NeiKos496" backend: str = "claude" config: Config = field(default_factory=Config) sessions: list[Session] = field(default_factory=list[Session]) @@ -88,7 +89,7 @@ def test_an_event_is_written_down_the_one_way_every_frontend_reads_it() -> None: Agent(), Session(named="abc"), Event("result", "done", tokens={"out": 3}, spent={"cost": 0.5}), - key="builder/2", + key="NeiKos496/2", run=3, ) @@ -110,14 +111,22 @@ def test_an_event_is_written_down_the_one_way_every_frontend_reads_it() -> None: "mono", } assert said["type"] == "event" - assert (said["run"], said["key"], said["session"]) == (3, "builder/2", "builder/2") - assert (said["agent"], said["cli"], said["model"]) == ("builder", "claude", "opus") + assert (said["run"], said["key"], said["session"]) == ( + 3, + "NeiKos496/2", + "NeiKos496/2", + ) + assert (said["agent"], said["cli"], said["model"]) == ( + "NeiKos496", + "claude", + "opus", + ) assert (said["ident"], said["kind"], said["text"]) == ("abc", "result", "done") assert (said["tokens"], said["spent"]) == ({"out": 3}, {"cost": 0.5}) def test_what_the_agent_said_for_every_conversation_names_none() -> None: - said = _record(Agent(), None, Event(), key="builder") + said = _record(Agent(), None, Event(), key="NeiKos496") assert (said["session"], said["ident"], said["run"]) == ("", "", 0) assert _record(Agent(), Session(), Event())["ident"] == "" @@ -153,6 +162,7 @@ class FakeRun: blind: str = "" raises: BaseException | None = None agents: list[Agent] = field(default_factory=list[Agent]) + names: dict[str, str] = field(default_factory=lambda: {"builder": "NeiKos496"}) listener: Callable[..., None] | None = None opener: Callable[..., None] | None = None noticer: Callable[[str], None] | None = None @@ -502,6 +512,7 @@ def test_a_flow_started_is_said_to_every_frontend( ) assert (started["by"], started["client"]) == ("alice", alice.client) assert started["roles"] == ["builder"] + assert started["names"] == {"builder": "NeiKos496"} assert started["outworlders"] == ["outworlder"] assert started["agents"] == {"builder": "claude/opus"} assert started["envs"] == {"box": "local/x"} @@ -739,7 +750,7 @@ def test_a_line_waits_and_what_never_went_is_said_when_the_run_ends( alice = Told(host, "alice") run = _started(alice, where) - assert alice.asks("say", text="also tests", to="builder") == {"ok": True} + assert alice.asks("say", text="also tests", to="NeiKos496") == {"ok": True} alice.waits( lambda one: ( @@ -767,7 +778,7 @@ def _heard(run: FakeRun, agent: Agent, session: Session | None, event: Event) -> run.listener(agent, session, event) -def test_a_session_opened_is_numbered_for_its_role( +def test_a_session_opened_is_numbered_for_its_agent_and_says_its_role( host: Host, where: Workspace ) -> None: alice = Told(host, "alice") @@ -777,10 +788,10 @@ def test_a_session_opened_is_numbered_for_its_role( _opened(run, agent, Session(forks=True)) _opened(run, agent, Session()) - first = alice.told("opened", key="builder/1") + first = alice.told("opened", key="NeiKos496/1") assert (first["role"], first["agent"], first["cli"], first["model"]) == ( "builder", - "builder", + "NeiKos496", "claude", "opus", ) @@ -790,10 +801,10 @@ def test_a_session_opened_is_numbered_for_its_role( ["turns"], ) assert (first["kept"], first["env"], first["harness"]) == ("", None, "") - alice.told("opened", key="builder/2") + alice.told("opened", key="NeiKos496/2") alice.waits( lambda one: ( - one["type"] == "sessions" and one["open"] == ["builder/1", "builder/2"] + one["type"] == "sessions" and one["open"] == ["NeiKos496/1", "NeiKos496/2"] ) ) run.released.set() @@ -813,16 +824,16 @@ def test_what_a_turn_says_is_told_on_its_conversations_transcript( said = alice.told("event", text="working on it") assert (said["key"], said["session"], said["ident"], said["run"]) == ( - "builder/1", - "builder/1", + "NeiKos496/1", + "NeiKos496/1", "abc", 1, ) alice.waits( - lambda one: one["type"] == "sessions" and one["working"] == ["builder/1"] + lambda one: one["type"] == "sessions" and one["working"] == ["NeiKos496/1"] ) # What no one conversation said goes on the one working. - assert alice.told("event", text="for all of them")["key"] == "builder/1" + assert alice.told("event", text="for all of them")["key"] == "NeiKos496/1" _heard(run, agent, session, Event("ends")) alice.waits(lambda one: one["type"] == "sessions" and one["working"] == []) run.released.set() @@ -849,7 +860,7 @@ def test_a_line_goes_into_the_turn_working_and_is_said_once_the_agent_has_it( ) _heard(run, agent, session, Event("took", "use the fixture")) said = alice.told("said", text="use the fixture") - assert (said["key"], said["by"]) == ("builder/1", "alice") + assert (said["key"], said["by"]) == ("NeiKos496/1", "alice") run.released.set() @@ -865,7 +876,7 @@ def test_a_line_the_agent_refused_goes_back_to_the_head_of_the_queue( _opened(run, agent, session) _heard(run, agent, session, Event("begins")) - alice.asks("say", text="late word", to="builder/1") + alice.asks("say", text="late word", to="NeiKos496/1") refused = alice.told("refused", text="late word") assert refused["because"] == "turn is over" @@ -885,13 +896,13 @@ def test_a_line_waiting_for_the_next_turn_is_folded_into_it( run = _started(alice, where) agent, session = Agent(), Session() _opened(run, agent, session) - alice.asks("say", text="next time", to="builder") + alice.asks("say", text="next time", to="NeiKos496") waiting = cast("Callable[[], list[str]]", agent.waiting) assert waiting() == ["next time"] assert waiting() == [] - assert alice.told("said", text="next time")["key"] == "builder/1" + assert alice.told("said", text="next time")["key"] == "NeiKos496/1" run.released.set() @@ -909,7 +920,7 @@ def test_what_an_agent_held_when_its_turn_ended_is_said( _heard(run, agent, session, Event("ends")) - assert alice.told("unheld", agent="builder")["texts"] == ["held word"] + assert alice.told("unheld", agent="NeiKos496")["texts"] == ["held word"] run.released.set() @@ -1129,7 +1140,7 @@ def test_what_to_say_next_is_answered_by_the_oldest_line_waiting( run.released.set() -@pytest.mark.parametrize("to", ["builder", "builder/1"]) +@pytest.mark.parametrize("to", ["NeiKos496", "NeiKos496/1"]) def test_a_line_said_to_a_conversation_between_turns_answers_what_to_say_next( to: str, host: Host, where: Workspace, outworlders: Outworlders ) -> None: @@ -1163,7 +1174,7 @@ def test_a_line_said_to_a_conversation_between_turns_of_another_flow_waits_for_i asking = Asking()(outworlders.ask, Question("which budget?")) alice.told("asked", run=1) - alice.asks("say", text="next time", to="builder/1") + alice.asks("say", text="next time", to="NeiKos496/1") alice.waits( lambda one: ( one["type"] == "waiting" @@ -1190,11 +1201,11 @@ def test_a_line_said_to_a_conversation_working_is_no_answer( assert outworlders.ask is not None asking = Asking()(outworlders.ask, Question("which?", ("a", "b"))) asked = alice.told("asked", run=1) - assert (asked["mode"], asked["key"]) == ("ask", "builder/1") + assert (asked["mode"], asked["key"]) == ("ask", "NeiKos496/1") pending = alice.waits(lambda one: one["type"] == "pending" and one["pending"]) - assert pending["pending"][0]["key"] == "builder/1" + assert pending["pending"][0]["key"] == "NeiKos496/1" - alice.asks("say", text="for the turn", to="builder/1") + alice.asks("say", text="for the turn", to="NeiKos496/1") assert session.given.wait(PATIENCE) assert not asking.done.is_set() @@ -1277,7 +1288,7 @@ def test_a_run_with_no_board_has_nothing_to_put_on_it(host: Host) -> None: ("said", "why"), [ ({}, "an aside is about a conversation, or asks an agent"), - ({"key": "builder/9"}, "builder/9 has no conversation to ask"), + ({"key": "NeiKos496/9"}, "NeiKos496/9 has no conversation to ask"), ({"side": "s9", "prompt": "hi"}, "no aside s9 is open"), ], ) diff --git a/tests/unit/runtime/doing/test_running.py b/tests/unit/runtime/doing/test_running.py index 44bbe3bf5..2516b92c9 100644 --- a/tests/unit/runtime/doing/test_running.py +++ b/tests/unit/runtime/doing/test_running.py @@ -72,6 +72,7 @@ class Runner: budget: Budget = field(default_factory=lambda: Budget(cost=1.0)) profile: bool = True recorder: Recorder | None = None + agents: dict[str, object] = field(default_factory=dict[str, object]) does: Callable[[dict[str, Any]], Any] | None = None blocks: bool = False entered: threading.Event = field(default_factory=threading.Event) @@ -135,6 +136,19 @@ def test_the_agents_are_those_behind_the_sessions_still_open() -> None: assert run.agents == (first, second) +@dataclass +class Driver: + name: str + + +def test_each_agent_role_says_what_the_agent_filling_it_is_called() -> None: + runner = Runner( + agents={"builder": Driver("NeiKos496"), "critic": Driver("Golem99")} + ) + + assert _run(runner).names == {"builder": "NeiKos496", "critic": "Golem99"} + + def test_a_run_here_returns_what_the_flow_did_and_tells_everyone_listening() -> None: runner = Runner() run = _run(runner, outworlder=cast("Any", "someone")) diff --git a/tests/unit/runtime/flowing/test_fakes.py b/tests/unit/runtime/flowing/test_fakes.py index 680dd6ec5..0fdb120c0 100644 --- a/tests/unit/runtime/flowing/test_fakes.py +++ b/tests/unit/runtime/flowing/test_fakes.py @@ -112,6 +112,11 @@ def test_a_fake_agent_is_its_harness_unless_told_otherwise() -> None: assert "codex/gpt" in repr(codex) +def test_a_fake_agent_is_called_what_it_is_told_or_a_codename_of_its_own() -> None: + assert FakeAgentDriver(name="NeiKos496").name == "NeiKos496" + assert FakeAgentDriver().name != FakeAgentDriver().name + + class Answer(pydantic.BaseModel): answer: str = "default" diff --git a/tests/unit/runtime/flowing/test_harnesses.py b/tests/unit/runtime/flowing/test_harnesses.py index cd2b86ed7..cec21ea2b 100644 --- a/tests/unit/runtime/flowing/test_harnesses.py +++ b/tests/unit/runtime/flowing/test_harnesses.py @@ -173,12 +173,12 @@ class CLI: built: list[CLI] = [] # noqa: RUF012 -- emptied per test reply: Callable[[str], str] = staticmethod(_echo) - def __init__(self, config: Config) -> None: + def __init__(self, config: Config, *, name: str | None = None) -> None: if config.model == "refused": raise ValueError("no such model") self.config = config self.backend = "claude" - self.id = "coder" + self.id = name or "coder" self.hooks = Hooks() self.ask: Any = None self.loaded: list[Any] = [] @@ -348,6 +348,19 @@ async def test_a_session_opens_in_its_workdir_with_its_skills( assert session.placement == _placed(tmp_path) +async def test_every_session_of_an_agent_is_called_by_its_name_and_not_its_role( + coganchor: type[CLI], tmp_path: Path +) -> None: + driver = open_agent(_spec()) + + first = await _session(driver, tmp_path) + second = await _session(driver, tmp_path) + + assert driver.name != "coder" + assert _cli(first).id == _cli(second).id == driver.name + assert open_agent(_spec()).name != driver.name + + async def test_a_session_is_refused_where_it_cannot_open( coganchor: type[CLI], tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/runtime/test_epic.py b/tests/unit/runtime/test_epic.py index 25d4c22bf..c90b4bd5c 100644 --- a/tests/unit/runtime/test_epic.py +++ b/tests/unit/runtime/test_epic.py @@ -61,8 +61,8 @@ def _journal(at: Path, *records: dict[str, Any] | str) -> None: @pytest.mark.parametrize( ("parts", "name"), [ - (("builder", "claude", "work", "abc-123"), "builder-claude@work-abc-123"), - (("builder", "claude", "", "abc"), f"builder-claude@{LOCAL}-abc"), + (("NeiKos496", "claude", "work", "abc-123"), "NeiKos496-claude@work-abc-123"), + (("NeiKos496", "claude", "", "abc"), f"NeiKos496-claude@{LOCAL}-abc"), (("a b/c", "co dex", "me!", "x/y"), "a-b-c-co-dex@me-x-y"), (("", "", "", "id"), "agent-cli@local-id"), ], @@ -118,7 +118,7 @@ def test_an_epic_opens_saying_what_it_is_a_run_of(workspace: Path) -> None: "fix it", workspace, ref="official/ralph", - agents=[Drove("builder", "claude", "opus", "high", "work")], + agents=[Drove("builder", "claude", "opus", "high", "work", "NeiKos496")], envs=["box=docker/srv"], params={"rounds": 3}, budget={"cost": 5.0}, @@ -145,6 +145,7 @@ def test_an_epic_opens_saying_what_it_is_a_run_of(workspace: Path) -> None: "model": "opus", "effort": "high", "provider": "work", + "name": "NeiKos496", } ] assert began["envs"] == ["box=docker/srv"] @@ -427,14 +428,14 @@ def test_a_run_reads_back_as_what_it_was(workspace: Path) -> None: "fix it", workspace, ref="official/ralph", - agents=[Drove("builder", "claude", "opus", "high")], + agents=[Drove("builder", "claude", "opus", "high", name="NeiKos496")], envs=["box=docker/srv"], used=["box=ssh@gpu/srv"], params={"rounds": 3}, budget={"cost": 1.0}, resumable=True, ) as one: - one.session("builder", "claude", "", "s1") + one.session("NeiKos496", "claude", "", "s1") inner = one.called("inner", "part") inner.session("helper", "codex", "team", "s2") inner.ended() @@ -449,7 +450,8 @@ def test_a_run_reads_back_as_what_it_was(workspace: Path) -> None: assert ran.began assert ran.ended assert ran.how == "stopped" - assert ran.agents == (Drove("builder", "claude", "opus", "high", ""),) + # The role, and what the agent filling it is called. + assert ran.agents == (Drove("builder", "claude", "opus", "high", "", "NeiKos496"),) assert ran.envs == ("box=docker/srv",) assert ran.used == ("box=ssh@gpu/srv",) assert ran.params == {"rounds": 3} @@ -458,7 +460,10 @@ def test_a_run_reads_back_as_what_it_was(workspace: Path) -> None: assert ran.picked_up == "" assert ran.profile is False assert ran.spent == {"cost": 0.5, "output_tokens": 1200, "seconds": 61.5} - assert [one.ident for one in ran.sessions] == ["s1", "s2"] + assert [(one.agent, one.ident) for one in ran.sessions] == [ + ("NeiKos496", "s1"), + ("helper", "s2"), + ] assert ran.sessions[1].flow == "inner" assert ran.sessions[1].record == inner.record assert ran.sessions[1].provider == "team" diff --git a/tests/unit/runtime/test_exporting.py b/tests/unit/runtime/test_exporting.py index d355f9d58..657c9ae0e 100644 --- a/tests/unit/runtime/test_exporting.py +++ b/tests/unit/runtime/test_exporting.py @@ -166,11 +166,20 @@ def ran(tmp_path: Path) -> Path: "ralph", "fix it with sk-ant-abcdefghijklmnopq", workspace, - agents=[Drove("builder", "claude", "fixture/fixture-model", "high", "work")], + agents=[ + Drove( + "builder", + "claude", + "fixture/fixture-model", + "high", + "work", + "NeiKos496", + ) + ], envs=["box=local/x"], resumable=True, ) as one: - one.session("builder", "claude", "work", "s1") + one.session("NeiKos496", "claude", "work", "s1") one.session("other", "opencode", "", "s2") one.session("ghost", "nobody", "", "s3") inner = one.called("inner", "a part") @@ -219,11 +228,11 @@ def test_a_whole_run_goes_in_one_archive( inner, f"{name}/{RESUME}", f"{name}/{TRACES}/t.trace.json", - f"{name}/{SESSIONS}/builder-claude@work-s1/projects/p/s1.jsonl", + f"{name}/{SESSIONS}/NeiKos496-claude@work-s1/projects/p/s1.jsonl", f"{name}/{MANIFEST}", } log = members[ - f"{name}/{SESSIONS}/builder-claude@work-s1/projects/p/s1.jsonl" + f"{name}/{SESSIONS}/NeiKos496-claude@work-s1/projects/p/s1.jsonl" ].decode() assert "SECRETVALUE-123" not in log assert '"input_tokens": 9' in log @@ -235,6 +244,12 @@ def test_a_whole_run_goes_in_one_archive( assert manifest["run"]["how"] == "done" assert manifest["run"]["task"] == f"fix it with {REDACTED}" assert manifest["workspace"]["head"] == "abc123" + # The role, and the agent that filled it under its own name, which its sessions are under. + assert (manifest["agents"][0]["agent"], manifest["agents"][0]["name"]) == ( + "builder", + "NeiKos496", + ) + assert manifest["sessions"][0]["agent"] == "NeiKos496" # What the run says it ran is never struck, though an account holds it. assert manifest["agents"][0]["model"] == "fixture/fixture-model" assert manifest["agents"][0]["runs"] == "claude@work/fixture/fixture-model:high" @@ -276,7 +291,7 @@ def test_unpacked_lays_out_what_a_bundle_holds_as_files( _, bundled = bundle(ran, tmp_path / "b.tar.gz") assert held == set(bundled["held"]) assert manifest["held"] == bundled["held"] - log = tmp_path / "out" / SESSIONS / "builder-claude@work-s1/projects/p/s1.jsonl" + log = tmp_path / "out" / SESSIONS / "NeiKos496-claude@work-s1/projects/p/s1.jsonl" assert "SECRETVALUE-123" not in log.read_text() assert '"input_tokens": 9' in log.read_text() assert "sk-ant-" not in (tmp_path / "out" / "epic.jsonl").read_text() @@ -343,7 +358,7 @@ def test_what_each_session_was_logged_to_is_found(world: World, ran: Path) -> No found = exporting.logged(ran) assert found == { - "builder-claude@work-s1": { + "NeiKos496-claude@work-s1": { "projects/p/s1.jsonl": ran / SESSIONS / "claude" diff --git a/tests/unit/runtime/test_runner.py b/tests/unit/runtime/test_runner.py index 140f05cc0..d13795bcd 100644 --- a/tests/unit/runtime/test_runner.py +++ b/tests/unit/runtime/test_runner.py @@ -219,6 +219,8 @@ def params_of(self, given: object) -> Params: class Agent: """An agent driver, as far as a runner reads one.""" + #: Its own name, not the role it fills. + name: str = "NeiKos496" harness: str = "claude" model: str = "opus" effort: str = "high" @@ -758,7 +760,10 @@ def test_a_run_is_written_down_and_returns_what_the_flow_did( "official/ralph", "done", ) - assert ran["agents"] == (epic.Drove("builder", "claude", "opus", "high"),) + # The role, and the agent filling it under its own name. + assert ran["agents"] == ( + epic.Drove("builder", "claude", "opus", "high", name="NeiKos496"), + ) assert ran["envs"] == ("box=local/srv",) assert ran["params"] == {"rounds": 2} assert ran["budget"]["cost"] == 5.0 @@ -1051,12 +1056,8 @@ class Handle: class Named: """A coganchor agent, as far as what writes a run down reads one.""" - renamed: list[str] = field(default_factory=list[str]) epic: object = None - def rename(self, role: str) -> None: - self.renamed.append(role) - def _events(at: Path) -> list[dict[str, Any]]: return [json.loads(one) for one in at.read_text().splitlines()] @@ -1137,7 +1138,7 @@ def test_a_session_of_a_driver_with_no_agent_is_written_down_once_it_is_named( engine_: Flowing, tmp_path: Path ) -> None: top = Call() - driver = Agent(harness="fake", provider="me") + driver = Agent(name="Mydei", harness="fake", provider="me") unnamed, named = Handle(), Handle(id="s2") held: list[tuple[Any, ...]] = [] @@ -1154,16 +1155,20 @@ def does(recorder: Any) -> None: runner, written = _recording(engine_, tmp_path, does) opened = [one for one in _events(written.journal) if one["event"] == "opened"] - assert [(one["session"], one["backend"], one["provider"]) for one in opened] == [ - ("s2", "fake", "me"), - ("s1", "fake", "me"), + # Under the driver's own name, not the role the flow took it as. + assert [ + (one["agent"], one["session"], one["backend"], one["provider"]) + for one in opened + ] == [ + ("Mydei", "s2", "fake", "me"), + ("Mydei", "s1", "fake", "me"), ] assert held == [(unnamed, named), (named,)] assert runner.recorder is not None assert runner.recorder.sessions == (named,) -def test_a_session_with_an_agent_behind_it_is_named_for_its_role( +def test_a_session_with_an_agent_behind_it_is_written_down_by_that_agent( engine_: Flowing, tmp_path: Path ) -> None: told: list[tuple[object, ...]] = [] @@ -1182,7 +1187,6 @@ def does(recorder: Any) -> None: _, written = _recording(engine_, tmp_path, does, tells) - assert agent.renamed == ["builder"] assert isinstance(agent.epic, epic.Epic) assert agent.epic.path == written.path assert told == [("builder", agent, conversation, "here")] diff --git a/tests/unit/runtime/tracing/test_timeline.py b/tests/unit/runtime/tracing/test_timeline.py index 3659301da..e01ee6a99 100644 --- a/tests/unit/runtime/tracing/test_timeline.py +++ b/tests/unit/runtime/tracing/test_timeline.py @@ -8,7 +8,7 @@ import pytest -from hmz.runtime.epic import Called, Ran +from hmz.runtime.epic import Called, Drove, Ran from hmz.runtime.epic import Session as Opened from hmz.runtime.tracing.profile import Process, Thread from hmz.runtime.tracing.session import Action, Session @@ -21,7 +21,12 @@ def iso(seconds: float) -> str: return datetime.datetime.fromtimestamp(T0 + seconds, datetime.UTC).isoformat() -def ran(*, ended: float | None = 100.0, opened: tuple[Opened, ...] = ()) -> Ran: +def ran( + *, + ended: float | None = 100.0, + opened: tuple[Opened, ...] = (), + agents: tuple[Drove, ...] = (), +) -> Ran: return Ran( at=Path("/epics/20260101T000000.000Z"), flow="ralph", @@ -30,6 +35,7 @@ def ran(*, ended: float | None = 100.0, opened: tuple[Opened, ...] = ()) -> Ran: ended=iso(ended) if ended is not None else "", how="done" if ended is not None else "", sessions=opened, + agents=agents, ) @@ -100,8 +106,10 @@ def epic_with_everything() -> tuple[dict[str, Any], Any]: ) worker = Process(42 + 1000, 42, "node", ("node",), T0 + 19, T0 + 20) opened = ( - Opened("builder", "claude", "local", "r", "n", record="epic.ralph_1.jsonl"), + Opened("NeiKos496", "claude", "local", "r", "n", record="epic.ralph_1.jsonl"), ) + # The agent filling `builder` is called by its own name, which its session is under. + agents = (Drove("builder", "claude", "opus", "high", name="NeiKos496"),) calls = ( Called( "official/review", @@ -114,7 +122,7 @@ def epic_with_everything() -> tuple[dict[str, Any], Any]: ) laid = build( [child, root], - ran=ran(opened=opened), + ran=ran(opened=opened, agents=agents), calls=calls, profiled=[worker, program], ) @@ -274,6 +282,24 @@ def test_a_running_epic_runs_until_the_last_thing_in_it() -> None: ] +def test_a_session_of_an_epic_written_before_agents_had_names_is_its_roles() -> None: + one = Session( + "claude:r", + "claude", + "r", + "main", + "r", + actions=[Action("t", "turn", T0, T0 + 1)], + ) + # Then an agent was called by the role it filled, and nothing else was written. + opened = (Opened("builder", "claude", "local", "r", "n"),) + agents = (Drove("builder", "claude", "opus", "high"),) + + document = build([one], ran=ran(opened=opened, agents=agents)).document + + assert text(document, document["processes"][1]["role"]) == "builder" + + def test_the_layout_says_whether_span_ids_moved() -> None: def laid(*spans: tuple[float, float]) -> str: actions = [Action("x", "tool", T0 + s, T0 + e) for s, e in spans] diff --git a/tests/unit/runtime/watching/test_btw.py b/tests/unit/runtime/watching/test_btw.py index 1273edc0f..c386cd55d 100644 --- a/tests/unit/runtime/watching/test_btw.py +++ b/tests/unit/runtime/watching/test_btw.py @@ -179,7 +179,7 @@ def test_a_snapshot_says_each_agent_and_handover() -> None: said = format_snapshot( _snapshot( agents=( - AgentProgress("claude#ab", "opus", 3, working=True, role="builder"), + AgentProgress("NeiKos496", "opus", 3, working=True, role="builder"), AgentProgress("codex#cd", "", -1, working=False), ), handovers=(("builder", "reviewer", 2),), @@ -189,7 +189,7 @@ def test_a_snapshot_says_each_agent_and_handover() -> None: ) assert _block(said, "agents:") == [ - "- builder (claude#ab): working, 3 turn(s), model=opus", + "- NeiKos496 (builder): working, 3 turn(s), model=opus", "- codex#cd: idle, 0 turn(s), model=(unknown)", ] assert _block(said, "handovers:") == ["- builder -> reviewer: 2 time(s)"] @@ -440,27 +440,32 @@ def opens(**said: object) -> dict[str, object]: @pytest.mark.parametrize( - ("chosen", "roles", "sessions", "expected"), + ("chosen", "agents", "sessions", "expected"), [ - ("codex/gpt", {"builder": "claude/opus"}, ["builder/1"], {"runs": "codex/gpt"}), + ( + "codex/gpt", + {"NeiKos496": "claude/opus"}, + ["NeiKos496/1"], + {"runs": "codex/gpt"}, + ), ( "", - {"builder": "claude/opus"}, - ["critic/1", "builder/1"], - {"key": "builder/1"}, + {"NeiKos496": "claude/opus"}, + ["Mydei/1", "NeiKos496/1"], + {"key": "NeiKos496/1"}, ), - ("", {"builder": "claude/opus"}, ["critic/1"], {"runs": "claude/opus"}), - ("", {"builder": ""}, ["critic/1"], {"key": "critic/1"}), + ("", {"NeiKos496": "claude/opus"}, ["Mydei/1"], {"runs": "claude/opus"}), + ("", {"NeiKos496": ""}, ["Mydei/1"], {"key": "Mydei/1"}), ("", {}, [], None), ], ) def test_the_btw_agent_is_the_one_settings_name_or_the_flows_first( chosen: str, - roles: dict[str, str], + agents: dict[str, str], sessions: list[str], expected: dict[str, str] | None, ) -> None: - assert btw.opened_as(chosen, roles, sessions) == expected + assert btw.opened_as(chosen, agents, sessions) == expected #: How a session an earlier run kept is opened: its agent, its conversation, where it is. diff --git a/tests/unit/runtime/watching/test_following.py b/tests/unit/runtime/watching/test_following.py index d323b5270..fbea35495 100644 --- a/tests/unit/runtime/watching/test_following.py +++ b/tests/unit/runtime/watching/test_following.py @@ -36,9 +36,9 @@ def _event(**said: Any) -> dict[str, Any]: """One `event` record as a host sends it, with whatever the test says over the rest.""" record: dict[str, Any] = { "run": 1, - "key": "builder/1", - "session": "builder/1", - "agent": "builder", + "key": "NeiKos496/1", + "session": "NeiKos496/1", + "agent": "NeiKos496", "cli": "claude", "model": "big", "ident": "", @@ -79,7 +79,7 @@ def test_a_run_unwinding_behind_the_next_is_its_agents_alone( ) assert said is None - assert following.monitor.shape().turns == {"builder": 1} + assert following.monitor.shape().turns == {"NeiKos496": 1} assert following.monitor.shape(sessions=True).turns == {} following.ended({"run": 2}) @@ -100,16 +100,16 @@ def test_a_side_question_is_told_what_the_run_in_front_did_and_not_its_thinking( task="build it", workspace="/w", going=True, - working=["builder/1"], + working=["NeiKos496/1"], waiting=0, waiting_for_input=False, ) said = [(one.agent, one.kind, one.text) for one in snapshot.observations] assert said == [ - ("builder", "begins", "build it"), + ("NeiKos496", "begins", "build it"), ("", "flow", "round 2 of 5"), - ("builder", "ends", "turn ended"), + ("NeiKos496", "ends", "turn ended"), ] following.started({"run": 2, "began": 0.0}) assert not following.snapshot( @@ -122,3 +122,35 @@ def test_a_side_question_is_told_what_the_run_in_front_did_and_not_its_thinking( waiting_for_input=False, ).observations following.ended({"run": 2}) + + +def test_a_side_question_is_told_each_agent_by_its_name_and_the_role_it_fills( + following: Following, +) -> None: + following.opened( + { + "run": 1, + "role": "builder", + "key": "NeiKos496/1", + "agent": "NeiKos496", + "cli": "claude", + "model": "big", + "counts": ["output"], + "person": False, + } + ) + following.heard(_event(kind="begins")) + + snapshot = following.snapshot( + flow="ralph", + task="build it", + workspace="/w", + going=True, + working=["NeiKos496/1"], + waiting=0, + waiting_for_input=False, + ) + + (agent,) = snapshot.agents + assert (agent.agent, agent.role, agent.turns) == ("NeiKos496", "builder", 1) + assert [key for key, _ in snapshot.sessions] == ["NeiKos496/1"] diff --git a/tests/unit/runtime/watching/test_monitor.py b/tests/unit/runtime/watching/test_monitor.py index 3045f1d21..58b86ff1d 100644 --- a/tests/unit/runtime/watching/test_monitor.py +++ b/tests/unit/runtime/watching/test_monitor.py @@ -179,18 +179,18 @@ def test_sessions_are_kept_as_a_graph_of_their_own() -> None: monitor = Monitor() assert monitor.shape(sessions=True) == Shape({}, frozenset(), {}) - monitor.begins("builder", "m", session="builder/1") - monitor.started("builder", "sub", "x", session="builder/1") - monitor.spend("builder", 50, session="builder/1") - monitor.ends("builder", session="builder/1") - monitor.begins("builder", "m", session="builder/2") - monitor.finished("builder", "sub", session="builder/1") + monitor.begins("NeiKos496", "m", session="NeiKos496/1") + monitor.started("NeiKos496", "sub", "x", session="NeiKos496/1") + monitor.spend("NeiKos496", 50, session="NeiKos496/1") + monitor.ends("NeiKos496", session="NeiKos496/1") + monitor.begins("NeiKos496", "m", session="NeiKos496/2") + monitor.finished("NeiKos496", "sub", session="NeiKos496/1") sessions = monitor.shape(sessions=True) - assert sessions.turns == {"builder/1": 1, "builder/2": 1} - assert sessions.handovers == {("builder/1", "builder/2"): 1} - assert sessions.under == {"builder/1": (Under("sub", "x", working=False),)} - assert sessions.used == {"builder/1": 50} + assert sessions.turns == {"NeiKos496/1": 1, "NeiKos496/2": 1} + assert sessions.handovers == {("NeiKos496/1", "NeiKos496/2"): 1} + assert sessions.under == {"NeiKos496/1": (Under("sub", "x", working=False),)} + assert sessions.used == {"NeiKos496/1": 50} # The bill is the run's, counted once: the sessions say only who spent it. assert [one.tokens for one in monitor.spending()] == [50] diff --git a/tests/unit/runtime/watching/test_tally.py b/tests/unit/runtime/watching/test_tally.py index d445fb504..0bb8502ea 100644 --- a/tests/unit/runtime/watching/test_tally.py +++ b/tests/unit/runtime/watching/test_tally.py @@ -123,7 +123,7 @@ def test_reported_says_the_kinds_a_backend_log_holds( def test_a_session_whose_logs_are_not_there_tells_nothing( home: Path, told: _Told ) -> None: - tally = _tally(told, Seen("builder", "claude", "opus", idents=frozenset({"s1"}))) + tally = _tally(told, Seen("NeiKos496", "claude", "opus", idents=frozenset({"s1"}))) tally.read() @@ -159,7 +159,7 @@ def test_claude_spending_is_read_by_kind_and_said_as_read( tally = _tally( told, Seen( - "builder", + "NeiKos496", "claude", "opus", counts=frozenset({"reasoning"}), @@ -176,7 +176,7 @@ def test_claude_spending_is_read_by_kind_and_said_as_read( {"input": 10.0, "output": 20.0, "cache_read": 30.0, "cache_write": 40.0}, ) } - assert told.reports == {"builder": reported("claude") | {"reasoning"}} + assert told.reports == {"NeiKos496": reported("claude") | {"reasoning"}} def test_a_claude_message_said_on_several_rows_is_counted_once( diff --git a/tests/unit/tui/test_monitoring.py b/tests/unit/tui/test_monitoring.py index 36cb89e0a..3a95110d0 100644 --- a/tests/unit/tui/test_monitoring.py +++ b/tests/unit/tui/test_monitoring.py @@ -197,28 +197,31 @@ def test_a_node_and_a_place_default_to_saying_nothing() -> None: def test_a_diagram_is_a_box_per_agent_in_order() -> None: - drawn = [Drawn("builder", runs="claude · opus", working=True), Drawn("reviewer")] + drawn = [ + Drawn("NeiKos496", runs="builder · claude · opus", working=True), + Drawn("Ajax17"), + ] shape = _shape( - turns={"builder": 1, "reviewer": 2}, - working=frozenset({"builder"}), - handovers={("builder", "reviewer"): 2, ("reviewer", "builder"): 1}, - since={"builder": 75.0, "reviewer": 5.0}, - latest=("builder", "reviewer"), - used={"reviewer": 1500}, + turns={"NeiKos496": 1, "Ajax17": 2}, + working=frozenset({"NeiKos496"}), + handovers={("NeiKos496", "Ajax17"): 2, ("Ajax17", "NeiKos496"): 1}, + since={"NeiKos496": 75.0, "Ajax17": 5.0}, + latest=("NeiKos496", "Ajax17"), + used={"Ajax17": 1500}, ) first, second = diagram(drawn, shape, 80) plain = [_plain(line) for line in first] assert plain[0].strip().startswith("┌") - assert "▸ ● builder" in plain[1] + assert "▸ ● NeiKos496" in plain[1] assert plain[1].rstrip().endswith("1m15s │") - assert "claude · opus · 1 turn" in plain[2] + assert "builder · claude · opus · 1 turn" in plain[2] assert plain[-1].strip().startswith("└") # The arrows say which way and how often, lit for the handover taken last. assert "↓ 2 · ↑ 1" in _plain(second[0]) assert "$secondary" in second[0] - assert "○ reviewer" in _plain(second[2]) + assert "○ Ajax17" in _plain(second[2]) assert "idle 5s" in _plain(second[2]) assert "2 turns · 1.5k tokens" in _plain(second[3]) @@ -291,22 +294,24 @@ def test_a_short_fleet_ends_its_branch_and_none_draws_nothing() -> None: def test_a_session_row_hangs_under_its_agent() -> None: shape = _shape( - turns={"a/1": 2}, - since={"a/1": 3.0}, - used={"a/1": 2000}, - under={"a/1": (Under("x", "sub"),)}, + turns={"NeiKos496/1": 2}, + since={"NeiKos496/1": 3.0}, + used={"NeiKos496/1": 2000}, + under={"NeiKos496/1": (Under("x", "sub"),)}, ) - row = session_row(Drawn("a/1", working=True, reading=True), shape, 80, last=True) + row = session_row( + Drawn("NeiKos496/1", working=True, reading=True), shape, 80, last=True + ) first, sub = (_plain(line) for line in row.splitlines()) - assert "└╴● a/1 · 2 turns · 2.0k tokens" in first + assert "└╴● NeiKos496/1 · 2 turns · 2.0k tokens" in first assert first.rstrip().endswith("reading · 3s") assert sub.endswith("└╴◆ sub") def test_a_session_row_is_called_by_the_last_part_of_its_name() -> None: - row = session_row(Drawn("a/1", named="builder · first"), _shape(), 80) + row = session_row(Drawn("NeiKos496/1", named="NeiKos496 · first"), _shape(), 80) assert "├╴○ first" in _plain(row) assert "idle 0s" in _plain(row) @@ -332,8 +337,8 @@ def test_a_listed_row_puts_each_figure_in_its_column() -> None: listed_row( 120, mark="●", - named="builder", - what="claude · opus", + named="NeiKos496", + what="builder · claude · opus", turns="3 turns", clock="1m00s", tokens="1.2k", @@ -343,7 +348,7 @@ def test_a_listed_row_puts_each_figure_in_its_column() -> None: ) ) - assert row.startswith(" ▸ ● builder unread") + assert row.startswith(" ▸ ● NeiKos496 unread") assert row.endswith("3 turns 1m00s 1.2k") assert len(row) == len(_plain(columns(120))) diff --git a/tests/unit/web/test_runs.py b/tests/unit/web/test_runs.py index 5412c8adc..05f0471ef 100644 --- a/tests/unit/web/test_runs.py +++ b/tests/unit/web/test_runs.py @@ -214,3 +214,24 @@ def test_a_run_is_said_running_while_any_process_holds_it( assert listed["how"] == said assert listed["held"] is held + + +@dataclass(frozen=True) +class _Drove: + """As much of an agent a run wrote down as a row reads.""" + + agent: str + name: str + spec: str = "claude/opus" + + +def test_a_run_lists_each_agent_by_its_own_name_and_the_role_it_filled_beside() -> None: + # One written before an agent had a name of its own went by its role. + ran = _Row(agents=(_Drove("builder", "NeiKos496"), _Drove("critic", ""))) + + listed = row(cast("Ran", ran), held=False) + + assert [(one["name"], one["role"]) for one in listed["agents"]] == [ + ("NeiKos496", "builder"), + ("critic", "critic"), + ] From 0b3b1eeb2bb6b761f89fcac22b5afb565d5501b8 Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:11:23 +0000 Subject: [PATCH 6/7] fix(desktop): keep a strip for the traffic lights over the sidebar's top row (#402) The lights' position the app set never took: they stood where macOS puts them, in the window's top 32 points, with the sidebar's top row beside them and centred lower. The app now leaves them there, and the sidebar keeps that strip for them alone, drawing its top row under it, from its left edge as every row below it is. Co-authored-by: Claude Opus 5.5 (cherry picked from commit 01d54c43feaf4edea2ab91c360c5c0a7dc2f69dd) --- desktop/src-tauri/src/chrome.rs | 16 ++++------------ src/hmz/web/static/style.css | 14 ++++++++------ tests/system/web/test_web_page.py | 13 +++++++++++-- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/desktop/src-tauri/src/chrome.rs b/desktop/src-tauri/src/chrome.rs index 0bde9d702..bb87732c7 100644 --- a/desktop/src-tauri/src/chrome.rs +++ b/desktop/src-tauri/src/chrome.rs @@ -1,17 +1,10 @@ -//! The window's chrome, one with the page: no title bar of its own but the traffic lights, -//! standing over the page's top row, and the page told it is in the app, and when the window is -//! full screen and they are gone, to draw that row around them. +//! The window's chrome, one with the page: no title bar of its own but the traffic lights, where +//! macOS puts them, over a strip the page keeps for them, and the page told it is in the app, and +//! when the window is full screen and they are gone, to keep it no more. use std::sync::atomic::{AtomicBool, Ordering}; -use tauri::{ - LogicalPosition, Manager, Runtime, TitleBarStyle, WebviewWindow, WebviewWindowBuilder, Window, -}; - -/// Where the traffic lights stand: centred on the page's top row, 57 pixels tall with the rule -/// under it, so 28 down; the close button centred on the sidebar's column of icons, 26 in. `y` is -/// where the title bar is made to end, and AppKit keeps its 14-point buttons 9 points above that. -const LIGHTS: LogicalPosition = LogicalPosition { x: 19.0, y: 30.0 }; +use tauri::{Manager, Runtime, TitleBarStyle, WebviewWindow, WebviewWindowBuilder, Window}; /// What WKWebView says it is where it is not told otherwise. const WEBKIT: &str = @@ -28,7 +21,6 @@ pub fn dress<'a, R: Runtime, M: Manager>( window .title_bar_style(TitleBarStyle::Overlay) .hidden_title(true) - .traffic_light_position(LIGHTS) .user_agent(&user_agent(version)) } diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index e110390df..0a3bb024e 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3746,17 +3746,19 @@ pre.exo-args { /* ------------------------------------------------------------------ the macOS app's window */ -/* There the page runs to the window's top edge, and the window's traffic lights stand over its - top left, centred on the top row: what is drawn first along it starts past them -- the - sidebar's top row, or the bar over the view where the sidebar is folded away or a drawer, or a - dialog that fills a narrow window -- and full screen, where they are gone, the page is drawn - as anywhere else. */ +/* There the page runs to the window's top edge, and the window's traffic lights stand where + macOS puts them, in its top 32 pixels at the left. The sidebar keeps that strip for them alone, + drawing its top row under it; where the sidebar is folded away or a drawer, the bar over the + view, or a dialog that fills a narrow window, starts past them. Full screen, where they are + gone, the page is drawn as anywhere else. */ :root[data-shell='macos']:not([data-fullscreen]) { --lights: 80px; + --lights-strip: 32px; } :root[data-shell='macos']:not([data-fullscreen]) .side-top { - padding-left: calc(var(--lights) + 8px); + /* The band across the sidebar's top runs under the lights, inside the strip. */ + padding-top: calc(var(--lights-strip) - 4px + 8px); } :root[data-shell='macos']:not([data-fullscreen]) .side-folded .bar { diff --git a/tests/system/web/test_web_page.py b/tests/system/web/test_web_page.py index 311bbd9ae..7434be6ca 100644 --- a/tests/system/web/test_web_page.py +++ b/tests/system/web/test_web_page.py @@ -634,8 +634,9 @@ def test_in_the_macos_app_the_top_row_is_drawn_around_the_traffic_lights( project: Path, engine: str ) -> None: """The app's window draws the page to its top edge, its traffic lights over the top left.""" - # How far across they reach, at most. + # How far across, and how far down, they reach, at most. lights = 76 + strip = 30 clicked = "a, button, input, select, textarea, summary, [role=tab]" def left(page: Page, selector: str) -> float: @@ -643,10 +644,16 @@ def left(page: Page, selector: str) -> float: assert box return box["x"] + def top(page: Page, selector: str) -> float: + box = page.locator(selector).first.bounding_box() + assert box + return box["y"] + with served() as site, opened(site, engine=engine) as page: assert page.evaluate("document.documentElement.dataset.shell") is None expect(page.locator("[data-tauri-drag-region]")).to_have_count(0) assert left(page, ".brand") < lights + assert top(page, ".brand") < strip # Said before the page is drawn: by the time its body is, so it never moves. at_body = ( @@ -659,7 +666,9 @@ def left(page: Page, selector: str) -> float: opened(site, engine=engine, user_agent=_APP, init=at_body) as page, ): assert page.evaluate("window.shellAtBody") == "macos" - assert left(page, ".brand") >= lights + # The sidebar keeps a strip for them, its top row drawn under it. + assert top(page, ".brand") >= strip + assert top(page, ".side-fold") >= strip for row in ("[data-hmz=sidebar] .side-top", "[data-hmz=bar]"): expect(page.locator(row)).to_have_attribute( "data-tauri-drag-region", "deep" From 21aaee4ebac9fe584513f0aad8ffc9de8994a4eb Mon Sep 17 00:00:00 2001 From: Zijian Zhang <35801754+futrime@users.noreply.github.com> Date: Tue, 13 Oct 2026 04:26:44 +0000 Subject: [PATCH 7/7] fix(desktop): keep the traffic lights' strip across the whole window (#403) The sidebar kept a strip for the lights, but folded away the bar over the view stood beside them, centred lower, as it did before. The strip now runs across the whole window: the view is drawn under it too, and moves the window where it is dragged, and a dialog that fills a narrow window starts under it. Nothing is pushed right past the lights any more. Co-authored-by: Claude Opus 5.5 (cherry picked from commit dcf9d02508a7ed79999de8402e5a49ea1b47aa77) --- src/hmz/web/static/app.js | 5 +++-- src/hmz/web/static/style.css | 17 ++++++++--------- tests/system/web/test_web_page.py | 12 +++++++++--- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/hmz/web/static/app.js b/src/hmz/web/static/app.js index 6d9762810..062f9a6f5 100644 --- a/src/hmz/web/static/app.js +++ b/src/hmz/web/static/app.js @@ -8,7 +8,7 @@ import { daemon, get, hash } from './api.js' import { fill, h, problem } from './dom.js' import { mountSidebar } from './sidebar.js' -import { kept, toggleSidebar } from './shell.js' +import { dragged, kept, toggleSidebar } from './shell.js' import { paint } from './themes.js' import './ui.js' import './importing.js' @@ -28,7 +28,8 @@ const DIALOGS = { settings: () => import('./views/settings.js'), } -const page = document.getElementById('page') +// The strip kept across its top for the window's traffic lights moves the window, as a title bar. +const page = dragged(document.getElementById('page'), '') const overlay = document.querySelector('.overlay') /** The view drawn, what it was loaded by, and how it is left or moved within. */ let mounted = null diff --git a/src/hmz/web/static/style.css b/src/hmz/web/static/style.css index 0a3bb024e..de919944a 100644 --- a/src/hmz/web/static/style.css +++ b/src/hmz/web/static/style.css @@ -3747,12 +3747,11 @@ pre.exo-args { /* ------------------------------------------------------------------ the macOS app's window */ /* There the page runs to the window's top edge, and the window's traffic lights stand where - macOS puts them, in its top 32 pixels at the left. The sidebar keeps that strip for them alone, - drawing its top row under it; where the sidebar is folded away or a drawer, the bar over the - view, or a dialog that fills a narrow window, starts past them. Full screen, where they are - gone, the page is drawn as anywhere else. */ + macOS puts them, in its top 32 pixels at the left. That strip is kept for them alone, across + the whole window: the sidebar draws its top row under it, the view under it, and a dialog that + fills a narrow window under it. Full screen, where they are gone, the page is drawn as anywhere + else. */ :root[data-shell='macos']:not([data-fullscreen]) { - --lights: 80px; --lights-strip: 32px; } @@ -3761,13 +3760,13 @@ pre.exo-args { padding-top: calc(var(--lights-strip) - 4px + 8px); } -:root[data-shell='macos']:not([data-fullscreen]) .side-folded .bar { - padding-left: var(--lights); +:root[data-shell='macos']:not([data-fullscreen]) .main { + padding-top: var(--lights-strip); } @media (max-width: 860px) { - :root[data-shell='macos']:not([data-fullscreen]) :is(header.bar, .settings-nav, .dialog-head) { - padding-left: var(--lights); + :root[data-shell='macos']:not([data-fullscreen]) .overlay { + padding-top: var(--lights-strip); } } diff --git a/tests/system/web/test_web_page.py b/tests/system/web/test_web_page.py index 7434be6ca..3223a1cca 100644 --- a/tests/system/web/test_web_page.py +++ b/tests/system/web/test_web_page.py @@ -673,6 +673,10 @@ def top(page: Page, selector: str) -> float: expect(page.locator(row)).to_have_attribute( "data-tauri-drag-region", "deep" ) + # The view itself, whose strip over what it draws is all of it not covered. + expect(page.locator("[data-hmz=page]")).to_have_attribute( + "data-tauri-drag-region", "" + ) expect(page.locator(f":is({clicked})[data-tauri-drag-region]")).to_have_count(0) # A menu opened in a row is not what the window is dragged by. page.locator("[data-hmz=daemon-bar]").click() @@ -681,19 +685,21 @@ def top(page: Page, selector: str) -> float: ) page.keyboard.press("Escape") + # Across the whole window: the view is drawn under the strip too. page.locator(".side-fold").click() - assert left(page, "[data-hmz=bar] .bar-side") >= lights + assert top(page, "[data-hmz=bar] .bar-side") >= strip + assert left(page, "[data-hmz=bar] .bar-side") < lights # Full screen, where they are gone, it is drawn as in a browser. page.evaluate( "document.documentElement.toggleAttribute('data-fullscreen', true)" ) - assert left(page, "[data-hmz=bar] .bar-side") < lights + assert top(page, "[data-hmz=bar] .bar-side") < strip page.set_viewport_size({"width": 700, "height": 800}) page.evaluate( "document.documentElement.toggleAttribute('data-fullscreen', false)" ) - assert left(page, "[data-hmz=bar] .bar-side") >= lights + assert top(page, "[data-hmz=bar] .bar-side") >= strip def test_every_label_is_a_word_or_a_phrase(project: Path, engine: str) -> None: