diff --git a/package-lock.json b/package-lock.json index e68857b2..f97ee66e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "co-mono", + "name": "co-mono", "version": "0.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "co-mono", + "name": "co-mono", "version": "0.0.3", "workspaces": [ "packages/*", @@ -958,6 +958,30 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@e9n/pi-channels": { "resolved": "packages/pi-channels", "link": true @@ -3538,6 +3562,13 @@ "node": ">=18.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/cli": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.1.tgz", @@ -3843,6 +3874,34 @@ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -4214,6 +4273,32 @@ "dev": true, "license": "MIT" }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -4295,6 +4380,22 @@ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "license": "MIT" }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4817,6 +4918,13 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/croner": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/croner/-/croner-9.1.0.tgz", @@ -5254,6 +5362,18 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -5701,6 +5821,21 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5907,6 +6042,15 @@ "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", "license": "MIT" }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5990,6 +6134,19 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -6115,6 +6272,15 @@ "node": ">= 12" } }, + "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/koffi": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.1.tgz", @@ -6458,6 +6624,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/marked": { "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", @@ -6726,6 +6899,17 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ollama": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", @@ -6976,6 +7160,14 @@ "resolved": "packages/coding-agent/examples/extensions/with-deps", "link": true }, + "node_modules/pi-memory-md": { + "resolved": "packages/pi-memory-md", + "link": true + }, + "node_modules/pi-teams": { + "resolved": "packages/pi-teams", + "link": true + }, "node_modules/pi-web-ui-example": { "resolved": "packages/web-ui/example", "link": true @@ -7399,6 +7591,19 @@ ], "license": "MIT" }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -7606,6 +7811,12 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -7679,6 +7890,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-eof": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", @@ -8004,6 +8224,60 @@ "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", "license": "MIT" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8099,6 +8373,26 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -8494,6 +8788,16 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", @@ -8764,6 +9068,66 @@ "@sinclair/typebox": "*" } }, + "packages/pi-memory-md": { + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "gray-matter": "^4.0.3" + }, + "devDependencies": { + "@mariozechner/pi-coding-agent": "latest", + "@types/node": "^20.0.0", + "husky": "^9.1.7", + "typescript": "^5.0.0" + } + }, + "packages/pi-memory-md/node_modules/@mariozechner/pi-coding-agent": { + "version": "0.56.2", + "resolved": "https://registry.npmjs.org/@mariozechner/pi-coding-agent/-/pi-coding-agent-0.56.2.tgz", + "integrity": "sha512-svK9zg5f+I4yko57MzdfBQBqZpFT1Hr8nZ3o7nYMTuIFcf2vABylA8lNI57Avjg38js1PToc6jXXFa/3JWqELg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mariozechner/jiti": "^2.6.2", + "@mariozechner/pi-agent-core": "^0.56.2", + "@mariozechner/pi-ai": "^0.56.2", + "@mariozechner/pi-tui": "^0.56.2", + "@silvia-odwyer/photon-node": "^0.3.4", + "chalk": "^5.5.0", + "cli-highlight": "^2.1.11", + "diff": "^8.0.2", + "extract-zip": "^2.0.1", + "file-type": "^21.1.1", + "glob": "^13.0.1", + "hosted-git-info": "^9.0.2", + "ignore": "^7.0.5", + "marked": "^15.0.12", + "minimatch": "^10.2.3", + "proper-lockfile": "^4.1.2", + "strip-ansi": "^7.1.0", + "undici": "^7.19.1", + "yaml": "^2.8.2" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=20.6.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "^0.3.2" + } + }, + "packages/pi-memory-md/node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "packages/pi-runtime-daemon": { "name": "@local/pi-runtime-daemon", "version": "0.0.1", @@ -8775,6 +9139,272 @@ "node": ">=20.0.0" } }, + "packages/pi-teams": { + "version": "0.8.6", + "license": "MIT", + "dependencies": { + "uuid": "^11.1.0" + }, + "devDependencies": { + "@types/node": "^25.3.0", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "peerDependencies": { + "@mariozechner/pi-coding-agent": "*", + "@sinclair/typebox": "*" + } + }, + "packages/pi-teams/node_modules/@types/node": { + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "packages/pi-teams/node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/pi-teams/node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "packages/pi-teams/node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/pi-teams/node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/pi-teams/node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/pi-teams/node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/pi-teams/node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "packages/pi-teams/node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/pi-teams/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/pi-teams/node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "packages/pi-teams/node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "packages/pi-teams/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "packages/pi-teams/node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "packages/pods": { "name": "@mariozechner/pi", "version": "0.56.2", diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 40dcffa4..578f0d35 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -187,7 +187,8 @@ ${chalk.bold("Commands:")} ${APP_NAME} remove [-l] Remove extension source from settings ${APP_NAME} update [source] Update installed extensions (skips pinned sources) ${APP_NAME} list List installed extensions from settings - ${APP_NAME} daemon Run in long-lived daemon mode (extensions stay active) + ${APP_NAME} gateway Run the always-on gateway process + ${APP_NAME} daemon Alias for gateway ${APP_NAME} config Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/update/list diff --git a/packages/coding-agent/src/core/gateway-runtime.ts b/packages/coding-agent/src/core/gateway-runtime.ts new file mode 100644 index 00000000..c8b15990 --- /dev/null +++ b/packages/coding-agent/src/core/gateway-runtime.ts @@ -0,0 +1,652 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; +import { join } from "node:path"; +import { URL } from "node:url"; +import type { ImageContent } from "@mariozechner/pi-ai"; +import type { AgentSession, AgentSessionEvent } from "./agent-session.js"; +import { SessionManager } from "./session-manager.js"; + +export interface GatewayConfig { + bind: string; + port: number; + bearerToken?: string; + session: { + idleMinutes: number; + maxQueuePerSession: number; + }; + webhook: { + enabled: boolean; + basePath: string; + secret?: string; + }; +} + +export type GatewaySessionFactory = (sessionKey: string) => Promise; + +export interface GatewayMessageRequest { + sessionKey: string; + text: string; + source?: "interactive" | "rpc" | "extension"; + images?: ImageContent[]; + metadata?: Record; +} + +export interface GatewayMessageResult { + ok: boolean; + response: string; + error?: string; + sessionKey: string; +} + +export interface GatewaySessionSnapshot { + sessionKey: string; + sessionId: string; + messageCount: number; + queueDepth: number; + processing: boolean; + lastActiveAt: number; + createdAt: number; +} + +export interface GatewayRuntimeOptions { + config: GatewayConfig; + primarySessionKey: string; + primarySession: AgentSession; + createSession: GatewaySessionFactory; + log?: (message: string) => void; +} + +interface GatewayQueuedMessage { + request: GatewayMessageRequest; + resolve: (result: GatewayMessageResult) => void; +} + +type GatewayEvent = + | { type: "hello"; sessionKey: string; snapshot: GatewaySessionSnapshot } + | { type: "session_state"; sessionKey: string; snapshot: GatewaySessionSnapshot } + | { type: "turn_start"; sessionKey: string } + | { type: "turn_end"; sessionKey: string } + | { type: "message_start"; sessionKey: string; role?: string } + | { type: "token"; sessionKey: string; delta: string; contentIndex: number } + | { type: "thinking"; sessionKey: string; delta: string; contentIndex: number } + | { type: "tool_start"; sessionKey: string; toolCallId: string; toolName: string; args: unknown } + | { type: "tool_update"; sessionKey: string; toolCallId: string; toolName: string; partialResult: unknown } + | { + type: "tool_complete"; + sessionKey: string; + toolCallId: string; + toolName: string; + result: unknown; + isError: boolean; + } + | { type: "message_complete"; sessionKey: string; text: string } + | { type: "error"; sessionKey: string; error: string } + | { type: "aborted"; sessionKey: string }; + +interface ManagedGatewaySession { + sessionKey: string; + session: AgentSession; + queue: GatewayQueuedMessage[]; + processing: boolean; + createdAt: number; + lastActiveAt: number; + listeners: Set<(event: GatewayEvent) => void>; + unsubscribe: () => void; +} + +let activeGatewayRuntime: GatewayRuntime | null = null; + +export function setActiveGatewayRuntime(runtime: GatewayRuntime | null): void { + activeGatewayRuntime = runtime; +} + +export function getActiveGatewayRuntime(): GatewayRuntime | null { + return activeGatewayRuntime; +} + +export class GatewayRuntime { + private readonly config: GatewayConfig; + private readonly primarySessionKey: string; + private readonly primarySession: AgentSession; + private readonly createSession: GatewaySessionFactory; + private readonly log: (message: string) => void; + private readonly sessions = new Map(); + private readonly sessionDirRoot: string; + private server: Server | null = null; + private idleSweepTimer: NodeJS.Timeout | null = null; + private ready = false; + + constructor(options: GatewayRuntimeOptions) { + this.config = options.config; + this.primarySessionKey = options.primarySessionKey; + this.primarySession = options.primarySession; + this.createSession = options.createSession; + this.log = options.log ?? (() => {}); + this.sessionDirRoot = join(options.primarySession.sessionManager.getSessionDir(), "..", "gateway-sessions"); + } + + async start(): Promise { + if (this.server) return; + + await this.ensureSession(this.primarySessionKey, this.primarySession); + this.server = createServer((request, response) => { + void this.handleHttpRequest(request, response).catch((error) => { + const message = error instanceof Error ? error.message : String(error); + this.writeJson(response, 500, { error: message }); + }); + }); + + await new Promise((resolve, reject) => { + this.server?.once("error", reject); + this.server?.listen(this.config.port, this.config.bind, () => { + this.server?.off("error", reject); + resolve(); + }); + }); + + this.idleSweepTimer = setInterval(() => { + void this.evictIdleSessions(); + }, 60_000); + this.ready = true; + } + + async stop(): Promise { + this.ready = false; + if (this.idleSweepTimer) { + clearInterval(this.idleSweepTimer); + this.idleSweepTimer = null; + } + if (this.server) { + await new Promise((resolve, reject) => { + this.server?.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + this.server = null; + } + for (const [sessionKey, managedSession] of this.sessions) { + managedSession.unsubscribe(); + if (sessionKey !== this.primarySessionKey) { + managedSession.session.dispose(); + } + } + this.sessions.clear(); + } + + isReady(): boolean { + return this.ready; + } + + getAddress(): { bind: string; port: number } { + return { bind: this.config.bind, port: this.config.port }; + } + + async enqueueMessage(request: GatewayMessageRequest): Promise { + const managedSession = await this.ensureSession(request.sessionKey); + if (managedSession.queue.length >= this.config.session.maxQueuePerSession) { + return { + ok: false, + response: "", + error: `Queue full (${this.config.session.maxQueuePerSession} pending).`, + sessionKey: request.sessionKey, + }; + } + + return new Promise((resolve) => { + managedSession.queue.push({ request, resolve }); + this.emitState(managedSession); + void this.processNext(managedSession); + }); + } + + async addSubscriber(sessionKey: string, listener: (event: GatewayEvent) => void): Promise<() => void> { + const managedSession = await this.ensureSession(sessionKey); + managedSession.listeners.add(listener); + listener({ type: "hello", sessionKey, snapshot: this.createSnapshot(managedSession) }); + return () => { + managedSession.listeners.delete(listener); + }; + } + + abortSession(sessionKey: string): boolean { + const managedSession = this.sessions.get(sessionKey); + if (!managedSession?.processing) { + return false; + } + void managedSession.session.abort().catch((error) => { + this.emit(managedSession, { + type: "error", + sessionKey, + error: error instanceof Error ? error.message : String(error), + }); + }); + return true; + } + + clearQueue(sessionKey: string): void { + const managedSession = this.sessions.get(sessionKey); + if (!managedSession) return; + managedSession.queue.length = 0; + this.emitState(managedSession); + } + + async resetSession(sessionKey: string): Promise { + const managedSession = this.sessions.get(sessionKey); + if (!managedSession) return; + + if (sessionKey === this.primarySessionKey) { + await managedSession.session.newSession(); + managedSession.queue.length = 0; + managedSession.processing = false; + managedSession.lastActiveAt = Date.now(); + this.emitState(managedSession); + return; + } + + if (managedSession.processing) { + await managedSession.session.abort(); + } + managedSession.unsubscribe(); + managedSession.session.dispose(); + this.sessions.delete(sessionKey); + } + + listSessions(): GatewaySessionSnapshot[] { + return Array.from(this.sessions.values()).map((session) => this.createSnapshot(session)); + } + + getSession(sessionKey: string): GatewaySessionSnapshot | undefined { + const session = this.sessions.get(sessionKey); + return session ? this.createSnapshot(session) : undefined; + } + + private async ensureSession(sessionKey: string, existingSession?: AgentSession): Promise { + const found = this.sessions.get(sessionKey); + if (found) { + found.lastActiveAt = Date.now(); + return found; + } + + const session = existingSession ?? (await this.createSession(sessionKey)); + const managedSession: ManagedGatewaySession = { + sessionKey, + session, + queue: [], + processing: false, + createdAt: Date.now(), + lastActiveAt: Date.now(), + listeners: new Set(), + unsubscribe: () => {}, + }; + managedSession.unsubscribe = session.subscribe((event) => { + this.handleSessionEvent(managedSession, event); + }); + this.sessions.set(sessionKey, managedSession); + this.emitState(managedSession); + return managedSession; + } + + private async processNext(managedSession: ManagedGatewaySession): Promise { + if (managedSession.processing || managedSession.queue.length === 0) { + return; + } + + const queued = managedSession.queue.shift(); + if (!queued) return; + + managedSession.processing = true; + managedSession.lastActiveAt = Date.now(); + this.emitState(managedSession); + + try { + await managedSession.session.prompt(queued.request.text, { + images: queued.request.images, + source: queued.request.source ?? "extension", + }); + const response = getLastAssistantText(managedSession.session); + queued.resolve({ + ok: true, + response, + sessionKey: managedSession.sessionKey, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("aborted")) { + this.emit(managedSession, { type: "aborted", sessionKey: managedSession.sessionKey }); + } else { + this.emit(managedSession, { type: "error", sessionKey: managedSession.sessionKey, error: message }); + } + queued.resolve({ + ok: false, + response: "", + error: message, + sessionKey: managedSession.sessionKey, + }); + } finally { + managedSession.processing = false; + managedSession.lastActiveAt = Date.now(); + this.emitState(managedSession); + if (managedSession.queue.length > 0) { + void this.processNext(managedSession); + } + } + } + + private handleSessionEvent(managedSession: ManagedGatewaySession, event: AgentSessionEvent): void { + switch (event.type) { + case "turn_start": + this.emit(managedSession, { type: "turn_start", sessionKey: managedSession.sessionKey }); + return; + case "turn_end": + this.emit(managedSession, { type: "turn_end", sessionKey: managedSession.sessionKey }); + return; + case "message_start": + this.emit(managedSession, { + type: "message_start", + sessionKey: managedSession.sessionKey, + role: event.message.role, + }); + return; + case "message_update": + switch (event.assistantMessageEvent.type) { + case "text_delta": + this.emit(managedSession, { + type: "token", + sessionKey: managedSession.sessionKey, + delta: event.assistantMessageEvent.delta, + contentIndex: event.assistantMessageEvent.contentIndex, + }); + return; + case "thinking_delta": + this.emit(managedSession, { + type: "thinking", + sessionKey: managedSession.sessionKey, + delta: event.assistantMessageEvent.delta, + contentIndex: event.assistantMessageEvent.contentIndex, + }); + return; + } + return; + case "message_end": + if (event.message.role === "assistant") { + this.emit(managedSession, { + type: "message_complete", + sessionKey: managedSession.sessionKey, + text: extractMessageText(event.message), + }); + } + return; + case "tool_execution_start": + this.emit(managedSession, { + type: "tool_start", + sessionKey: managedSession.sessionKey, + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }); + return; + case "tool_execution_update": + this.emit(managedSession, { + type: "tool_update", + sessionKey: managedSession.sessionKey, + toolCallId: event.toolCallId, + toolName: event.toolName, + partialResult: event.partialResult, + }); + return; + case "tool_execution_end": + this.emit(managedSession, { + type: "tool_complete", + sessionKey: managedSession.sessionKey, + toolCallId: event.toolCallId, + toolName: event.toolName, + result: event.result, + isError: event.isError, + }); + return; + } + } + + private emit(managedSession: ManagedGatewaySession, event: GatewayEvent): void { + for (const listener of managedSession.listeners) { + listener(event); + } + } + + private emitState(managedSession: ManagedGatewaySession): void { + this.emit(managedSession, { + type: "session_state", + sessionKey: managedSession.sessionKey, + snapshot: this.createSnapshot(managedSession), + }); + } + + private createSnapshot(managedSession: ManagedGatewaySession): GatewaySessionSnapshot { + return { + sessionKey: managedSession.sessionKey, + sessionId: managedSession.session.sessionId, + messageCount: managedSession.session.messages.length, + queueDepth: managedSession.queue.length, + processing: managedSession.processing, + lastActiveAt: managedSession.lastActiveAt, + createdAt: managedSession.createdAt, + }; + } + + private async evictIdleSessions(): Promise { + const cutoff = Date.now() - this.config.session.idleMinutes * 60_000; + for (const [sessionKey, managedSession] of this.sessions) { + if (sessionKey === this.primarySessionKey) { + continue; + } + if (managedSession.processing || managedSession.queue.length > 0) { + continue; + } + if (managedSession.lastActiveAt > cutoff) { + continue; + } + if (managedSession.listeners.size > 0) { + continue; + } + managedSession.unsubscribe(); + managedSession.session.dispose(); + this.sessions.delete(sessionKey); + this.log(`evicted idle session ${sessionKey}`); + } + } + + private async handleHttpRequest(request: IncomingMessage, response: ServerResponse): Promise { + const method = request.method ?? "GET"; + const url = new URL( + request.url ?? "/", + `http://${request.headers.host ?? `${this.config.bind}:${this.config.port}`}`, + ); + const path = url.pathname; + + if (method === "GET" && path === "/health") { + this.writeJson(response, 200, { ok: true, ready: this.ready }); + return; + } + + if (method === "GET" && path === "/ready") { + this.requireAuth(request, response); + if (response.writableEnded) return; + this.writeJson(response, 200, { ok: true, ready: this.ready, sessions: this.sessions.size }); + return; + } + + if (this.config.webhook.enabled && method === "POST" && path.startsWith(this.config.webhook.basePath)) { + await this.handleWebhookRequest(path, request, response); + return; + } + + this.requireAuth(request, response); + if (response.writableEnded) return; + + if (method === "GET" && path === "/sessions") { + this.writeJson(response, 200, { sessions: this.listSessions() }); + return; + } + + const sessionMatch = path.match(/^\/sessions\/([^/]+)(?:\/(events|messages|abort|reset))?$/); + if (!sessionMatch) { + this.writeJson(response, 404, { error: "Not found" }); + return; + } + + const sessionKey = decodeURIComponent(sessionMatch[1]); + const action = sessionMatch[2]; + + if (!action && method === "GET") { + const session = await this.ensureSession(sessionKey); + this.writeJson(response, 200, { session: this.createSnapshot(session) }); + return; + } + + if (action === "events" && method === "GET") { + await this.handleSse(sessionKey, request, response); + return; + } + + if (action === "messages" && method === "POST") { + const body = await this.readJsonBody(request); + const text = typeof body.text === "string" ? body.text : ""; + if (!text.trim()) { + this.writeJson(response, 400, { error: "Missing text" }); + return; + } + const result = await this.enqueueMessage({ + sessionKey, + text, + source: "extension", + }); + this.writeJson(response, result.ok ? 200 : 500, result); + return; + } + + if (action === "abort" && method === "POST") { + this.writeJson(response, 200, { ok: this.abortSession(sessionKey) }); + return; + } + + if (action === "reset" && method === "POST") { + await this.resetSession(sessionKey); + this.writeJson(response, 200, { ok: true }); + return; + } + + this.writeJson(response, 405, { error: "Method not allowed" }); + } + + private async handleWebhookRequest(path: string, request: IncomingMessage, response: ServerResponse): Promise { + const route = path.slice(this.config.webhook.basePath.length).replace(/^\/+/, "") || "default"; + if (this.config.webhook.secret) { + const presentedSecret = request.headers["x-pi-webhook-secret"]; + if (presentedSecret !== this.config.webhook.secret) { + this.writeJson(response, 401, { error: "Invalid webhook secret" }); + return; + } + } + + const body = await this.readJsonBody(request); + const text = typeof body.text === "string" ? body.text : ""; + if (!text.trim()) { + this.writeJson(response, 400, { error: "Missing text" }); + return; + } + + const conversationId = + typeof body.sessionKey === "string" + ? body.sessionKey + : `webhook:${route}:${typeof body.sender === "string" ? body.sender : "default"}`; + const result = await this.enqueueMessage({ + sessionKey: conversationId, + text, + source: "extension", + metadata: typeof body.metadata === "object" && body.metadata ? (body.metadata as Record) : {}, + }); + this.writeJson(response, result.ok ? 200 : 500, result); + } + + private async handleSse(sessionKey: string, request: IncomingMessage, response: ServerResponse): Promise { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }); + response.write("\n"); + + const unsubscribe = await this.addSubscriber(sessionKey, (event) => { + response.write(`data: ${JSON.stringify(event)}\n\n`); + }); + request.on("close", () => { + unsubscribe(); + }); + } + + private requireAuth(request: IncomingMessage, response: ServerResponse): void { + if (!this.config.bearerToken) { + return; + } + const header = request.headers.authorization; + if (header === `Bearer ${this.config.bearerToken}`) { + return; + } + this.writeJson(response, 401, { error: "Unauthorized" }); + } + + private async readJsonBody(request: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + if (chunks.length === 0) { + return {}; + } + const body = Buffer.concat(chunks).toString("utf8"); + return JSON.parse(body) as Record; + } + + private writeJson(response: ServerResponse, statusCode: number, payload: unknown): void { + response.statusCode = statusCode; + response.setHeader("content-type", "application/json; charset=utf-8"); + response.end(JSON.stringify(payload)); + } + + getGatewaySessionDir(sessionKey: string): string { + return join(this.sessionDirRoot, sanitizeSessionKey(sessionKey)); + } +} + +function extractMessageText(message: { content: unknown }): string { + if (!Array.isArray(message.content)) { + return ""; + } + return message.content + .filter((part): part is { type: "text"; text: string } => { + return typeof part === "object" && part !== null && "type" in part && "text" in part && part.type === "text"; + }) + .map((part) => part.text) + .join(""); +} + +function getLastAssistantText(session: AgentSession): string { + for (let index = session.messages.length - 1; index >= 0; index--) { + const message = session.messages[index]; + if (message.role === "assistant") { + return extractMessageText(message); + } + } + return ""; +} + +export function sanitizeSessionKey(sessionKey: string): string { + return sessionKey.replace(/[^a-zA-Z0-9._-]/g, "_"); +} + +export function createGatewaySessionManager(cwd: string, sessionKey: string, sessionDirRoot: string): SessionManager { + return SessionManager.create(cwd, join(sessionDirRoot, sanitizeSessionKey(sessionKey))); +} diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index e9996240..60e5fd7d 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -43,6 +43,26 @@ export interface MarkdownSettings { codeBlockIndent?: string; // default: " " } +export interface GatewaySessionSettings { + idleMinutes?: number; + maxQueuePerSession?: number; +} + +export interface GatewayWebhookSettings { + enabled?: boolean; + basePath?: string; + secret?: string; +} + +export interface GatewaySettings { + enabled?: boolean; + bind?: string; + port?: number; + bearerToken?: string; + session?: GatewaySessionSettings; + webhook?: GatewayWebhookSettings; +} + export type TransportSetting = Transport; /** @@ -93,6 +113,7 @@ export interface Settings { autocompleteMaxVisible?: number; // Max visible items in autocomplete dropdown (default: 5) showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME markdown?: MarkdownSettings; + gateway?: GatewaySettings; } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -912,4 +933,8 @@ export class SettingsManager { getCodeBlockIndent(): string { return this.settings.markdown?.codeBlockIndent ?? " "; } + + getGatewaySettings(): GatewaySettings { + return structuredClone(this.settings.gateway ?? {}); + } } diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 7a15cc82..f8491558 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -140,6 +140,19 @@ export { } from "./core/extensions/index.js"; // Footer data provider (git branch + extension statuses - data not otherwise available to extensions) export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js"; +export { + createGatewaySessionManager, + type GatewayConfig, + type GatewayMessageRequest, + type GatewayMessageResult, + GatewayRuntime, + type GatewayRuntimeOptions, + type GatewaySessionFactory, + type GatewaySessionSnapshot, + getActiveGatewayRuntime, + sanitizeSessionKey, + setActiveGatewayRuntime, +} from "./core/gateway-runtime.js"; export { convertToLlm } from "./core/messages.js"; export { ModelRegistry } from "./core/model-registry.js"; export type { @@ -198,6 +211,7 @@ export { } from "./core/session-manager.js"; export { type CompactionSettings, + type GatewaySettings, type ImageSettings, type PackageSource, type RetrySettings, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 7852eca1..735cad15 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -5,6 +5,7 @@ * createAgentSession() options. The SDK does the heavy lifting. */ +import { join } from "node:path"; import { type ImageContent, modelsAreEqual, supportsXhigh } from "@mariozechner/pi-ai"; import chalk from "chalk"; import { createInterface } from "readline"; @@ -17,6 +18,7 @@ import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js"; import { AuthStorage } from "./core/auth-storage.js"; import { exportFromFile } from "./core/export-html/index.js"; import type { LoadExtensionsResult } from "./core/extensions/index.js"; +import { createGatewaySessionManager } from "./core/gateway-runtime.js"; import { KeybindingsManager } from "./core/keybindings.js"; import { ModelRegistry } from "./core/model-registry.js"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.js"; @@ -81,9 +83,10 @@ interface PackageCommandOptions { function printDaemonHelp(): void { console.log(`${chalk.bold("Usage:")} + ${APP_NAME} gateway [options] [messages...] ${APP_NAME} daemon [options] [messages...] -Run pi as a long-lived daemon (non-interactive) with extensions enabled. +Run pi as a long-lived gateway (non-interactive) with extensions enabled. Messages passed as positional args are sent once at startup. Options: @@ -553,9 +556,9 @@ async function handleConfigCommand(args: string[]): Promise { } export async function main(args: string[]) { - const isDaemonCommand = args[0] === "daemon"; - const parsedArgs = isDaemonCommand ? args.slice(1) : args; - const offlineMode = args.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE); + const isGatewayCommand = args[0] === "daemon" || args[0] === "gateway"; + const parsedArgs = isGatewayCommand ? args.slice(1) : args; + const offlineMode = parsedArgs.includes("--offline") || isTruthyEnvFlag(process.env.PI_OFFLINE); if (offlineMode) { process.env.PI_OFFLINE = "1"; process.env.PI_SKIP_VERSION_CHECK = "1"; @@ -634,7 +637,7 @@ export async function main(args: string[]) { } if (parsed.help) { - if (isDaemonCommand) { + if (isGatewayCommand) { printDaemonHelp(); } else { printHelp(); @@ -648,13 +651,13 @@ export async function main(args: string[]) { process.exit(0); } - if (isDaemonCommand && parsed.mode === "rpc") { - console.error(chalk.red("Cannot use --mode rpc with the daemon command.")); + if (isGatewayCommand && parsed.mode === "rpc") { + console.error(chalk.red("Cannot use --mode rpc with the gateway command.")); process.exit(1); } // Read piped stdin content (if any) - skip for daemon and RPC modes - if (!isDaemonCommand && parsed.mode !== "rpc") { + if (!isGatewayCommand && parsed.mode !== "rpc") { const stdinContent = await readPipedStdin(); if (stdinContent !== undefined) { // Force print mode since interactive mode requires a TTY for keyboard input @@ -684,7 +687,7 @@ export async function main(args: string[]) { } const { initialMessage, initialImages } = await prepareInitialMessage(parsed, settingsManager.getImageAutoResize()); - const isInteractive = !isDaemonCommand && !parsed.print && parsed.mode === undefined; + const isInteractive = !isGatewayCommand && !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; initTheme(settingsManager.getTheme(), isInteractive); @@ -789,11 +792,44 @@ export async function main(args: string[]) { verbose: parsed.verbose, }); await mode.run(); - } else if (isDaemonCommand) { + } else if (isGatewayCommand) { + const gatewayLoaderOptions = { + additionalExtensionPaths: firstPass.extensions, + additionalSkillPaths: firstPass.skills, + additionalPromptTemplatePaths: firstPass.promptTemplates, + additionalThemePaths: firstPass.themes, + noExtensions: firstPass.noExtensions, + noSkills: firstPass.noSkills, + noPromptTemplates: firstPass.noPromptTemplates, + noThemes: firstPass.noThemes, + systemPrompt: firstPass.systemPrompt, + appendSystemPrompt: firstPass.appendSystemPrompt, + }; + const gatewaySessionRoot = join(agentDir, "gateway-sessions"); const daemonOptions: DaemonModeOptions = { initialMessage, initialImages, messages: parsed.messages, + gateway: settingsManager.getGatewaySettings(), + createSession: async (sessionKey) => { + const gatewayResourceLoader = new DefaultResourceLoader({ + cwd, + agentDir, + settingsManager, + ...gatewayLoaderOptions, + }); + await gatewayResourceLoader.reload(); + const gatewaySessionOptions: CreateAgentSessionOptions = { + ...sessionOptions, + authStorage, + modelRegistry, + settingsManager, + resourceLoader: gatewayResourceLoader, + sessionManager: createGatewaySessionManager(cwd, sessionKey, gatewaySessionRoot), + }; + const { session: gatewaySession } = await createAgentSession(gatewaySessionOptions); + return gatewaySession; + }, }; await runDaemonMode(session, daemonOptions); } else { diff --git a/packages/coding-agent/src/modes/daemon-mode.ts b/packages/coding-agent/src/modes/daemon-mode.ts index d3790a45..b56953cf 100644 --- a/packages/coding-agent/src/modes/daemon-mode.ts +++ b/packages/coding-agent/src/modes/daemon-mode.ts @@ -8,6 +8,8 @@ import type { ImageContent } from "@mariozechner/pi-ai"; import type { AgentSession } from "../core/agent-session.js"; +import { GatewayRuntime, type GatewaySessionFactory, setActiveGatewayRuntime } from "../core/gateway-runtime.js"; +import type { GatewaySettings } from "../core/settings-manager.js"; /** * Options for daemon mode. @@ -19,6 +21,10 @@ export interface DaemonModeOptions { initialImages?: ImageContent[]; /** Additional startup messages (sent after initialMessage, one by one). */ messages?: string[]; + /** Factory for creating additional gateway-owned sessions. */ + createSession: GatewaySessionFactory; + /** Gateway config from settings/env. */ + gateway: GatewaySettings; } function createCommandContextActions(session: AgentSession) { @@ -71,12 +77,39 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp const ready = new Promise((resolve) => { resolveReady = resolve; }); + const gatewayBind = process.env.PI_GATEWAY_BIND ?? options.gateway.bind ?? "127.0.0.1"; + const gatewayPort = Number.parseInt(process.env.PI_GATEWAY_PORT ?? "", 10) || options.gateway.port || 8787; + const gatewayToken = process.env.PI_GATEWAY_TOKEN ?? options.gateway.bearerToken; + const gateway = new GatewayRuntime({ + config: { + bind: gatewayBind, + port: gatewayPort, + bearerToken: gatewayToken, + session: { + idleMinutes: options.gateway.session?.idleMinutes ?? 60, + maxQueuePerSession: options.gateway.session?.maxQueuePerSession ?? 8, + }, + webhook: { + enabled: options.gateway.webhook?.enabled ?? true, + basePath: options.gateway.webhook?.basePath ?? "/webhooks", + secret: process.env.PI_GATEWAY_WEBHOOK_SECRET ?? options.gateway.webhook?.secret, + }, + }, + primarySessionKey: "web:main", + primarySession: session, + createSession: options.createSession, + log: (message) => { + console.error(`[pi-gateway] ${message}`); + }, + }); const shutdown = async (reason: "signal" | "extension"): Promise => { if (isShuttingDown) return; isShuttingDown = true; - console.error(`[co-mono-daemon] shutdown requested: ${reason}`); + console.error(`[pi-gateway] shutdown requested: ${reason}`); + setActiveGatewayRuntime(null); + await gateway.stop(); const runner = session.extensionRunner; if (runner?.hasHandlers("session_shutdown")) { @@ -90,7 +123,7 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp const handleShutdownSignal = (signal: NodeJS.Signals) => { void shutdown("signal").catch((error) => { console.error( - `[co-mono-daemon] shutdown failed for ${signal}: ${error instanceof Error ? error.message : String(error)}`, + `[pi-gateway] shutdown failed for ${signal}: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); }); @@ -102,7 +135,7 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp process.once("SIGHUP", () => handleShutdownSignal("SIGHUP")); process.on("unhandledRejection", (error) => { - console.error(`[co-mono-daemon] unhandled rejection: ${error instanceof Error ? error.message : String(error)}`); + console.error(`[pi-gateway] unhandled rejection: ${error instanceof Error ? error.message : String(error)}`); }); await session.bindExtensions({ @@ -110,7 +143,7 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp shutdownHandler: () => { void shutdown("extension").catch((error) => { console.error( - `[co-mono-daemon] extension shutdown failed: ${error instanceof Error ? error.message : String(error)}`, + `[pi-gateway] extension shutdown failed: ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); }); @@ -135,7 +168,11 @@ export async function runDaemonMode(session: AgentSession, options: DaemonModeOp await session.prompt(message); } - console.error(`[co-mono-daemon] startup complete (session=${session.sessionId ?? "unknown"})`); + await gateway.start(); + setActiveGatewayRuntime(gateway); + console.error( + `[pi-gateway] startup complete (session=${session.sessionId ?? "unknown"}, bind=${gatewayBind}, port=${gatewayPort})`, + ); // Keep process alive forever. const keepAlive = setInterval(() => { diff --git a/packages/pi-channels/package.json b/packages/pi-channels/package.json index a821b46d..981d0277 100644 --- a/packages/pi-channels/package.json +++ b/packages/pi-channels/package.json @@ -2,6 +2,7 @@ "name": "@e9n/pi-channels", "version": "0.1.0", "description": "Two-way channel extension for pi — route messages between agents and Telegram, webhooks, and custom adapters", + "type": "module", "keywords": [ "pi-package" ], diff --git a/packages/pi-channels/src/adapters/slack.ts b/packages/pi-channels/src/adapters/slack.ts index d58c5411..037bb843 100644 --- a/packages/pi-channels/src/adapters/slack.ts +++ b/packages/pi-channels/src/adapters/slack.ts @@ -39,8 +39,8 @@ import { SocketModeClient } from "@slack/socket-mode"; import { WebClient } from "@slack/web-api"; -import { getChannelSetting } from "../config.ts"; -import type { AdapterConfig, ChannelAdapter, ChannelMessage, OnIncomingMessage } from "../types.ts"; +import { getChannelSetting } from "../config.js"; +import type { AdapterConfig, ChannelAdapter, ChannelMessage, OnIncomingMessage } from "../types.js"; const MAX_LENGTH = 3000; // Slack block text limit; actual API limit is 4000 but leave margin @@ -146,7 +146,7 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl return { direction: "bidirectional" as const, - async sendTyping(recipient: string): Promise { + async sendTyping(_recipient: string): Promise { // Slack doesn't have a direct "typing" API for bots in channels. // We can use a reaction or simply no-op. For DMs, there's no API either. // Best we can do is nothing — Slack bots don't show typing indicators. @@ -309,7 +309,7 @@ export function createSlackAdapter(config: AdapterConfig, cwd?: string, log?: Sl ); // ── Interactive payloads (future: button clicks, modals) ── - socketClient.on("interactive", async ({ body, ack }: { body: any; ack: () => Promise }) => { + socketClient.on("interactive", async ({ body: _body, ack }: { body: any; ack: () => Promise }) => { try { await ack(); // TODO: handle interactive payloads (block actions, modals) diff --git a/packages/pi-channels/src/adapters/telegram.ts b/packages/pi-channels/src/adapters/telegram.ts index 534cb54e..1d2d5fd4 100644 --- a/packages/pi-channels/src/adapters/telegram.ts +++ b/packages/pi-channels/src/adapters/telegram.ts @@ -36,8 +36,8 @@ import type { IncomingMessage, OnIncomingMessage, TranscriptionConfig, -} from "../types.ts"; -import { createTranscriptionProvider, type TranscriptionProvider } from "./transcription.ts"; +} from "../types.js"; +import { createTranscriptionProvider, type TranscriptionProvider } from "./transcription.js"; const MAX_LENGTH = 4096; const MAX_FILE_SIZE = 1_048_576; // 1MB @@ -388,7 +388,6 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter { }; } - const ext = path.extname(filename || "").toLowerCase(); const attachment: IncomingAttachment = { type: "image", path: downloaded.localPath, @@ -472,7 +471,7 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter { return { adapter: "telegram", sender: chatId, - text: `🎵 ${filename || "audio"} (transcription failed${result.error ? ": " + result.error : ""})`, + text: `🎵 ${filename || "audio"} (transcription failed${result.error ? `: ${result.error}` : ""})`, metadata: { ...metadata, hasAudio: true }, }; } @@ -535,7 +534,7 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter { return { adapter: "telegram", sender: chatId, - text: `🎤 (voice message — transcription failed${result.error ? ": " + result.error : ""})`, + text: `🎤 (voice message — transcription failed${result.error ? `: ${result.error}` : ""})`, metadata: { ...metadata, hasVoice: true, voiceDuration: voice.duration }, }; } @@ -588,7 +587,7 @@ export function createTelegramAdapter(config: AdapterConfig): ChannelAdapter { return { adapter: "telegram", sender: chatId, - text: `🎵 ${audioName} (transcription failed${result.error ? ": " + result.error : ""})`, + text: `🎵 ${audioName} (transcription failed${result.error ? `: ${result.error}` : ""})`, metadata: { ...metadata, hasAudio: true, audioTitle: audio.title, audioDuration: audio.duration }, }; } diff --git a/packages/pi-channels/src/adapters/transcription.ts b/packages/pi-channels/src/adapters/transcription.ts index 50e6ec3c..55b169a1 100644 --- a/packages/pi-channels/src/adapters/transcription.ts +++ b/packages/pi-channels/src/adapters/transcription.ts @@ -14,7 +14,7 @@ import { execFile } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; -import type { TranscriptionConfig } from "../types.ts"; +import type { TranscriptionConfig } from "../types.js"; // ── Public interface ──────────────────────────────────────────── diff --git a/packages/pi-channels/src/adapters/webhook.ts b/packages/pi-channels/src/adapters/webhook.ts index cfffa80f..f4d44ca1 100644 --- a/packages/pi-channels/src/adapters/webhook.ts +++ b/packages/pi-channels/src/adapters/webhook.ts @@ -11,7 +11,7 @@ * } */ -import type { AdapterConfig, ChannelAdapter, ChannelMessage } from "../types.ts"; +import type { AdapterConfig, ChannelAdapter, ChannelMessage } from "../types.js"; export function createWebhookAdapter(config: AdapterConfig): ChannelAdapter { const method = (config.method as string) ?? "POST"; diff --git a/packages/pi-channels/src/bridge/bridge.ts b/packages/pi-channels/src/bridge/bridge.ts index 0d86a8ca..c93046ad 100644 --- a/packages/pi-channels/src/bridge/bridge.ts +++ b/packages/pi-channels/src/bridge/bridge.ts @@ -2,18 +2,18 @@ * pi-channels — Chat bridge. * * Listens for incoming messages (channel:receive), serializes per sender, - * runs prompts via isolated subprocesses, and sends responses back via - * the same adapter. Each sender gets their own FIFO queue. Multiple - * senders run concurrently up to maxConcurrent. + * routes prompts into the live pi gateway runtime, and sends responses + * back via the same adapter. Each sender gets their own FIFO queue. + * Multiple senders run concurrently up to maxConcurrent. */ -import type { EventBus } from "@mariozechner/pi-coding-agent"; -import type { ChannelRegistry } from "../registry.ts"; -import type { BridgeConfig, IncomingAttachment, IncomingMessage, QueuedPrompt, SenderSession } from "../types.ts"; -import { type CommandContext, handleCommand, isCommand } from "./commands.ts"; -import { RpcSessionManager } from "./rpc-runner.ts"; -import { runPrompt } from "./runner.ts"; -import { startTyping } from "./typing.ts"; +import { readFileSync } from "node:fs"; +import type { ImageContent } from "@mariozechner/pi-ai"; +import { type EventBus, getActiveGatewayRuntime } from "@mariozechner/pi-coding-agent"; +import type { ChannelRegistry } from "../registry.js"; +import type { BridgeConfig, IncomingMessage, QueuedPrompt, SenderSession } from "../types.js"; +import { type CommandContext, handleCommand, isCommand } from "./commands.js"; +import { startTyping } from "./typing.js"; const BRIDGE_DEFAULTS: Required = { enabled: false, @@ -38,24 +38,21 @@ function nextId(): string { export class ChatBridge { private config: Required; - private cwd: string; private registry: ChannelRegistry; private events: EventBus; private log: LogFn; private sessions = new Map(); private activeCount = 0; private running = false; - private rpcManager: RpcSessionManager | null = null; constructor( bridgeConfig: BridgeConfig | undefined, - cwd: string, + _cwd: string, registry: ChannelRegistry, events: EventBus, log: LogFn = () => {}, ) { this.config = { ...BRIDGE_DEFAULTS, ...bridgeConfig }; - this.cwd = cwd; this.registry = registry; this.events = events; this.log = log; @@ -65,18 +62,11 @@ export class ChatBridge { start(): void { if (this.running) return; + if (!getActiveGatewayRuntime()) { + this.log("bridge-unavailable", { reason: "no active pi gateway runtime" }, "WARN"); + return; + } this.running = true; - - // Always create the RPC manager — it's used on-demand for persistent senders - this.rpcManager = new RpcSessionManager( - { - cwd: this.cwd, - model: this.config.model, - timeoutMs: this.config.timeoutMs, - extensions: this.config.extensions, - }, - this.config.idleTimeoutMinutes * 60_000, - ); } stop(): void { @@ -86,8 +76,6 @@ export class ChatBridge { } this.sessions.clear(); this.activeCount = 0; - this.rpcManager?.killAll(); - this.rpcManager = null; } isActive(): boolean { @@ -180,38 +168,32 @@ export class ChatBridge { // Typing indicator const adapter = this.registry.getAdapter(prompt.adapter); const typing = this.config.typingIndicators ? startTyping(adapter, prompt.sender) : { stop() {} }; - - const ac = new AbortController(); - session.abortController = ac; - - const usePersistent = this.shouldUsePersistent(senderKey); + const gateway = getActiveGatewayRuntime(); + if (!gateway) { + typing.stop(); + session.processing = false; + this.activeCount--; + this.sendReply(prompt.adapter, prompt.sender, "❌ pi gateway is not running."); + return; + } this.events.emit("bridge:start", { id: prompt.id, adapter: prompt.adapter, sender: prompt.sender, text: prompt.text.slice(0, 100), - persistent: usePersistent, + persistent: true, }); try { - let result; - - if (usePersistent && this.rpcManager) { - // Persistent mode: use RPC session - result = await this.runWithRpc(senderKey, prompt, ac.signal); - } else { - // Stateless mode: spawn subprocess - result = await runPrompt({ - prompt: prompt.text, - cwd: this.cwd, - timeoutMs: this.config.timeoutMs, - model: this.config.model, - signal: ac.signal, - attachments: prompt.attachments, - extensions: this.config.extensions, - }); - } + session.abortController = new AbortController(); + const result = await gateway.enqueueMessage({ + sessionKey: senderKey, + text: buildPromptText(prompt), + images: collectImageAttachments(prompt.attachments), + source: "extension", + metadata: prompt.metadata, + }); typing.stop(); @@ -229,8 +211,7 @@ export class ChatBridge { adapter: prompt.adapter, sender: prompt.sender, ok: result.ok, - durationMs: result.durationMs, - persistent: usePersistent, + persistent: true, }); this.log( "bridge-complete", @@ -238,15 +219,15 @@ export class ChatBridge { id: prompt.id, adapter: prompt.adapter, ok: result.ok, - durationMs: result.durationMs, - persistent: usePersistent, + persistent: true, }, result.ok ? "INFO" : "WARN", ); - } catch (err: any) { + } catch (err: unknown) { typing.stop(); - this.log("bridge-error", { adapter: prompt.adapter, sender: prompt.sender, error: err.message }, "ERROR"); - this.sendReply(prompt.adapter, prompt.sender, `❌ Unexpected error: ${err.message}`); + const message = err instanceof Error ? err.message : String(err); + this.log("bridge-error", { adapter: prompt.adapter, sender: prompt.sender, error: message }, "ERROR"); + this.sendReply(prompt.adapter, prompt.sender, `❌ Unexpected error: ${message}`); } finally { session.abortController = null; session.processing = false; @@ -257,29 +238,6 @@ export class ChatBridge { } } - /** Run a prompt via persistent RPC session. */ - private async runWithRpc( - senderKey: string, - prompt: QueuedPrompt, - signal?: AbortSignal, - ): Promise { - try { - const rpcSession = await this.rpcManager!.getSession(senderKey); - return await rpcSession.runPrompt(prompt.text, { - signal, - attachments: prompt.attachments, - }); - } catch (err: any) { - return { - ok: false, - response: "", - error: err.message, - durationMs: 0, - exitCode: 1, - }; - } - } - /** After a slot frees up, check other senders waiting for concurrency. */ private drainWaiting(): void { if (this.activeCount >= this.config.maxConcurrent) return; @@ -327,37 +285,17 @@ export class ChatBridge { return this.sessions; } - // ── Session mode resolution ─────────────────────────────── - - /** - * Determine if a sender should use persistent (RPC) or stateless mode. - * Checks sessionRules first (first match wins), falls back to sessionMode default. - */ - private shouldUsePersistent(senderKey: string): boolean { - for (const rule of this.config.sessionRules) { - if (globMatch(rule.match, senderKey)) { - return rule.mode === "persistent"; - } - } - return this.config.sessionMode === "persistent"; - } - // ── Command context ─────────────────────────────────────── private commandContext(): CommandContext { + const gateway = getActiveGatewayRuntime(); return { - isPersistent: (sender: string) => { - // Find the sender key to check mode - for (const [key, session] of this.sessions) { - if (session.sender === sender) return this.shouldUsePersistent(key); - } - return this.config.sessionMode === "persistent"; - }, + isPersistent: () => true, abortCurrent: (sender: string): boolean => { - for (const session of this.sessions.values()) { + if (!gateway) return false; + for (const [key, session] of this.sessions) { if (session.sender === sender && session.abortController) { - session.abortController.abort(); - return true; + return gateway.abortSession(key); } } return false; @@ -368,13 +306,11 @@ export class ChatBridge { } }, resetSession: (sender: string): void => { + if (!gateway) return; for (const [key, session] of this.sessions) { if (session.sender === sender) { this.sessions.delete(key); - // Also reset persistent RPC session - if (this.rpcManager) { - this.rpcManager.resetSession(key).catch(() => {}); - } + void gateway.resetSession(key); } } }, @@ -388,21 +324,6 @@ export class ChatBridge { } } -// ── Helpers ─────────────────────────────────────────────────── - -/** - * Simple glob matcher supporting `*` (any chars) and `?` (single char). - * Used for sessionRules pattern matching against "adapter:senderId" keys. - */ -function globMatch(pattern: string, text: string): boolean { - // Escape regex special chars except * and ? - const re = pattern - .replace(/[.+^${}()|[\]\\]/g, "\\$&") - .replace(/\*/g, ".*") - .replace(/\?/g, "."); - return new RegExp(`^${re}$`).test(text); -} - const MAX_ERROR_LENGTH = 200; /** @@ -428,5 +349,36 @@ function sanitizeError(error: string | undefined): string { const msg = meaningful?.trim() || "Something went wrong. Please try again."; - return msg.length > MAX_ERROR_LENGTH ? msg.slice(0, MAX_ERROR_LENGTH) + "…" : msg; + return msg.length > MAX_ERROR_LENGTH ? `${msg.slice(0, MAX_ERROR_LENGTH)}…` : msg; +} + +function collectImageAttachments(attachments: QueuedPrompt["attachments"]): ImageContent[] | undefined { + if (!attachments || attachments.length === 0) { + return undefined; + } + const images = attachments + .filter((attachment) => attachment.type === "image") + .map((attachment) => ({ + type: "image" as const, + data: readFileSync(attachment.path).toString("base64"), + mimeType: attachment.mimeType || "image/jpeg", + })); + return images.length > 0 ? images : undefined; +} + +function buildPromptText(prompt: QueuedPrompt): string { + if (!prompt.attachments || prompt.attachments.length === 0) { + return prompt.text; + } + + const attachmentNotes = prompt.attachments + .filter((attachment) => attachment.type !== "image") + .map((attachment) => { + const label = attachment.filename ?? attachment.path; + return `Attachment (${attachment.type}): ${label}`; + }); + if (attachmentNotes.length === 0) { + return prompt.text; + } + return `${prompt.text}\n\n${attachmentNotes.join("\n")}`; } diff --git a/packages/pi-channels/src/bridge/commands.ts b/packages/pi-channels/src/bridge/commands.ts index 6f5e882e..6f893343 100644 --- a/packages/pi-channels/src/bridge/commands.ts +++ b/packages/pi-channels/src/bridge/commands.ts @@ -7,7 +7,7 @@ * Built-in: /start, /help, /abort, /status, /new */ -import type { SenderSession } from "../types.ts"; +import type { SenderSession } from "../types.js"; export interface BotCommand { name: string; diff --git a/packages/pi-channels/src/bridge/rpc-runner.ts b/packages/pi-channels/src/bridge/rpc-runner.ts index c9047a81..91a453e1 100644 --- a/packages/pi-channels/src/bridge/rpc-runner.ts +++ b/packages/pi-channels/src/bridge/rpc-runner.ts @@ -14,7 +14,7 @@ import { type ChildProcess, spawn } from "node:child_process"; import * as readline from "node:readline"; -import type { IncomingAttachment, RunResult } from "../types.ts"; +import type { IncomingAttachment, RunResult } from "../types.js"; export interface RpcRunnerOptions { cwd: string; @@ -118,95 +118,97 @@ export class RpcSession { onStreaming?: (text: string) => void; }, ): Promise { - return new Promise(async (resolve) => { - // Ensure subprocess is running - if (!this.ready) { - const ok = await this.start(); - if (!ok) { - resolve({ - ok: false, - response: "", - error: "Failed to start RPC session", - durationMs: 0, - exitCode: 1, - }); - return; - } - } - - const startTime = Date.now(); - this._onStreaming = options?.onStreaming ?? null; - - // Timeout - const timer = setTimeout(() => { - if (this.pending) { - const p = this.pending; - this.pending = null; - const text = p.textChunks.join(""); - p.resolve({ - ok: false, - response: text || "(timed out)", - error: "Timeout", - durationMs: Date.now() - p.startTime, - exitCode: 124, - }); - // Kill and restart on next message - this.cleanup(); - } - }, this.options.timeoutMs); - - this.pending = { resolve, startTime, timer, textChunks: [] }; - - // Abort handler - const onAbort = () => { - this.sendCommand({ type: "abort" }); - }; - if (options?.signal) { - if (options.signal.aborted) { - clearTimeout(timer); - this.pending = null; - this.sendCommand({ type: "abort" }); - resolve({ - ok: false, - response: "(aborted)", - error: "Aborted by user", - durationMs: Date.now() - startTime, - exitCode: 130, - }); - return; - } - options.signal.addEventListener("abort", onAbort, { once: true }); - this.pending.abortHandler = () => options.signal?.removeEventListener("abort", onAbort); - } - - // Build prompt command - const cmd: Record = { - type: "prompt", - message: prompt, - }; - - // Attach images as base64 - if (options?.attachments?.length) { - const images: Array> = []; - for (const att of options.attachments) { - if (att.type === "image") { - try { - const fs = await import("node:fs"); - const data = fs.readFileSync(att.path).toString("base64"); - images.push({ - type: "image", - data, - mimeType: att.mimeType || "image/jpeg", - }); - } catch { - // Skip unreadable attachments - } + return new Promise((resolve) => { + void (async () => { + // Ensure subprocess is running + if (!this.ready) { + const ok = await this.start(); + if (!ok) { + resolve({ + ok: false, + response: "", + error: "Failed to start RPC session", + durationMs: 0, + exitCode: 1, + }); + return; } } - if (images.length > 0) cmd.images = images; - } - this.sendCommand(cmd); + const startTime = Date.now(); + this._onStreaming = options?.onStreaming ?? null; + + // Timeout + const timer = setTimeout(() => { + if (this.pending) { + const p = this.pending; + this.pending = null; + const text = p.textChunks.join(""); + p.resolve({ + ok: false, + response: text || "(timed out)", + error: "Timeout", + durationMs: Date.now() - p.startTime, + exitCode: 124, + }); + // Kill and restart on next message + this.cleanup(); + } + }, this.options.timeoutMs); + + this.pending = { resolve, startTime, timer, textChunks: [] }; + + // Abort handler + const onAbort = () => { + this.sendCommand({ type: "abort" }); + }; + if (options?.signal) { + if (options.signal.aborted) { + clearTimeout(timer); + this.pending = null; + this.sendCommand({ type: "abort" }); + resolve({ + ok: false, + response: "(aborted)", + error: "Aborted by user", + durationMs: Date.now() - startTime, + exitCode: 130, + }); + return; + } + options.signal.addEventListener("abort", onAbort, { once: true }); + this.pending.abortHandler = () => options.signal?.removeEventListener("abort", onAbort); + } + + // Build prompt command + const cmd: Record = { + type: "prompt", + message: prompt, + }; + + // Attach images as base64 + if (options?.attachments?.length) { + const images: Array> = []; + for (const att of options.attachments) { + if (att.type === "image") { + try { + const fs = await import("node:fs"); + const data = fs.readFileSync(att.path).toString("base64"); + images.push({ + type: "image", + data, + mimeType: att.mimeType || "image/jpeg", + }); + } catch { + // Skip unreadable attachments + } + } + } + if (images.length > 0) cmd.images = images; + } + + this.sendCommand(cmd); + })(); }); } @@ -253,7 +255,7 @@ export class RpcSession { private sendCommand(cmd: Record): void { if (!this.child?.stdin?.writable) return; - this.child.stdin.write(JSON.stringify(cmd) + "\n"); + this.child.stdin.write(`${JSON.stringify(cmd)}\n`); } private handleLine(line: string): void { @@ -358,7 +360,7 @@ export class RpcSessionManager { /** Get or create a session for a sender. */ async getSession(senderKey: string): Promise { let session = this.sessions.get(senderKey); - if (session && session.isAlive()) { + if (session?.isAlive()) { this.resetIdleTimer(senderKey); return session; } @@ -403,7 +405,7 @@ export class RpcSessionManager { /** Kill all sessions. */ killAll(): void { - for (const [key, session] of this.sessions) { + for (const session of this.sessions.values()) { session.cleanup(); } this.sessions.clear(); diff --git a/packages/pi-channels/src/bridge/runner.ts b/packages/pi-channels/src/bridge/runner.ts index 828b510b..25ae5bb3 100644 --- a/packages/pi-channels/src/bridge/runner.ts +++ b/packages/pi-channels/src/bridge/runner.ts @@ -7,7 +7,7 @@ */ import { type ChildProcess, spawn } from "node:child_process"; -import type { IncomingAttachment, RunResult } from "../types.ts"; +import type { IncomingAttachment, RunResult } from "../types.js"; export interface RunOptions { prompt: string; diff --git a/packages/pi-channels/src/bridge/typing.ts b/packages/pi-channels/src/bridge/typing.ts index ba880919..9b8e9198 100644 --- a/packages/pi-channels/src/bridge/typing.ts +++ b/packages/pi-channels/src/bridge/typing.ts @@ -6,7 +6,7 @@ * For adapters without sendTyping, this is a no-op. */ -import type { ChannelAdapter } from "../types.ts"; +import type { ChannelAdapter } from "../types.js"; const TYPING_INTERVAL_MS = 4_000; diff --git a/packages/pi-channels/src/config.ts b/packages/pi-channels/src/config.ts index 37780a97..2f539585 100644 --- a/packages/pi-channels/src/config.ts +++ b/packages/pi-channels/src/config.ts @@ -29,7 +29,7 @@ */ import { getAgentDir, SettingsManager } from "@mariozechner/pi-coding-agent"; -import type { ChannelConfig } from "./types.ts"; +import type { ChannelConfig } from "./types.js"; const SETTINGS_KEY = "pi-channels"; diff --git a/packages/pi-channels/src/events.ts b/packages/pi-channels/src/events.ts index 8b9cb683..662bb62a 100644 --- a/packages/pi-channels/src/events.ts +++ b/packages/pi-channels/src/events.ts @@ -15,9 +15,9 @@ */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import type { ChatBridge } from "./bridge/bridge.ts"; -import type { ChannelRegistry } from "./registry.ts"; -import type { ChannelAdapter, ChannelMessage, IncomingMessage } from "./types.ts"; +import type { ChatBridge } from "./bridge/bridge.js"; +import type { ChannelRegistry } from "./registry.js"; +import type { ChannelAdapter, ChannelMessage, IncomingMessage } from "./types.js"; /** Reference to the active bridge, set by index.ts after construction. */ let activeBridge: ChatBridge | null = null; diff --git a/packages/pi-channels/src/index.ts b/packages/pi-channels/src/index.ts index f9661571..fb9cfb89 100644 --- a/packages/pi-channels/src/index.ts +++ b/packages/pi-channels/src/index.ts @@ -35,12 +35,12 @@ */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; -import { ChatBridge } from "./bridge/bridge.ts"; -import { loadConfig } from "./config.ts"; -import { registerChannelEvents, setBridge } from "./events.ts"; -import { createLogger } from "./logger.ts"; -import { ChannelRegistry } from "./registry.ts"; -import { registerChannelTool } from "./tool.ts"; +import { ChatBridge } from "./bridge/bridge.js"; +import { loadConfig } from "./config.js"; +import { registerChannelEvents, setBridge } from "./events.js"; +import { createLogger } from "./logger.js"; +import { ChannelRegistry } from "./registry.js"; +import { registerChannelTool } from "./tool.js"; export default function (pi: ExtensionAPI) { const log = createLogger(pi); diff --git a/packages/pi-channels/src/registry.ts b/packages/pi-channels/src/registry.ts index ca8c27d8..c6ffb262 100644 --- a/packages/pi-channels/src/registry.ts +++ b/packages/pi-channels/src/registry.ts @@ -2,9 +2,9 @@ * pi-channels — Adapter registry + route resolution. */ -import { createSlackAdapter } from "./adapters/slack.ts"; -import { createTelegramAdapter } from "./adapters/telegram.ts"; -import { createWebhookAdapter } from "./adapters/webhook.ts"; +import { createSlackAdapter } from "./adapters/slack.js"; +import { createTelegramAdapter } from "./adapters/telegram.js"; +import { createWebhookAdapter } from "./adapters/webhook.js"; import type { AdapterConfig, AdapterDirection, @@ -13,7 +13,7 @@ import type { ChannelMessage, IncomingMessage, OnIncomingMessage, -} from "./types.ts"; +} from "./types.js"; // ── Built-in adapter factories ────────────────────────────────── diff --git a/packages/pi-channels/src/tool.ts b/packages/pi-channels/src/tool.ts index 8f5d85e7..38422e58 100644 --- a/packages/pi-channels/src/tool.ts +++ b/packages/pi-channels/src/tool.ts @@ -5,7 +5,7 @@ import { StringEnum } from "@mariozechner/pi-ai"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; -import type { ChannelRegistry } from "./registry.ts"; +import type { ChannelRegistry } from "./registry.js"; interface ChannelToolParams { action: "send" | "list" | "test"; diff --git a/packages/pi-teams/src/adapters/iterm2-adapter.ts b/packages/pi-teams/src/adapters/iterm2-adapter.ts index e1676fb4..ae722db5 100644 --- a/packages/pi-teams/src/adapters/iterm2-adapter.ts +++ b/packages/pi-teams/src/adapters/iterm2-adapter.ts @@ -6,7 +6,7 @@ */ import { spawnSync } from "node:child_process"; -import { execCommand, type SpawnOptions, type TerminalAdapter } from "../utils/terminal-adapter"; +import type { SpawnOptions, TerminalAdapter } from "../utils/terminal-adapter"; /** * Context needed for iTerm2 spawning (tracks last pane for layout) diff --git a/packages/pi-teams/src/adapters/wezterm-adapter.test.ts b/packages/pi-teams/src/adapters/wezterm-adapter.test.ts index db67fd2f..97866884 100644 --- a/packages/pi-teams/src/adapters/wezterm-adapter.test.ts +++ b/packages/pi-teams/src/adapters/wezterm-adapter.test.ts @@ -39,7 +39,7 @@ describe("WezTermAdapter", () => { describe("spawn", () => { it("should spawn first pane to the right with 50%", () => { // Mock getPanes finding only current pane - mockExecCommand.mockImplementation((bin, args) => { + mockExecCommand.mockImplementation((_bin: string, args: string[]) => { if (args.includes("list")) { return { stdout: JSON.stringify([{ pane_id: 0, tab_id: 0 }]), @@ -69,7 +69,7 @@ describe("WezTermAdapter", () => { it("should spawn subsequent panes by splitting the sidebar", () => { // Mock getPanes finding current pane (0) and sidebar pane (1) - mockExecCommand.mockImplementation((bin, args) => { + mockExecCommand.mockImplementation((_bin: string, args: string[]) => { if (args.includes("list")) { return { stdout: JSON.stringify([ diff --git a/packages/pi-teams/src/utils/lock.race.test.ts b/packages/pi-teams/src/utils/lock.race.test.ts index 20cf25ff..35681845 100644 --- a/packages/pi-teams/src/utils/lock.race.test.ts +++ b/packages/pi-teams/src/utils/lock.race.test.ts @@ -1,13 +1,12 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { withLock } from "./lock"; describe("withLock race conditions", () => { - const testDir = path.join(os.tmpdir(), "pi-lock-race-test-" + Date.now()); + const testDir = path.join(os.tmpdir(), `pi-lock-race-test-${Date.now()}`); const lockPath = path.join(testDir, "test"); - const lockFile = `${lockPath}.lock`; beforeEach(() => { if (!fs.existsSync(testDir)) fs.mkdirSync(testDir, { recursive: true }); diff --git a/packages/pi-teams/src/utils/lock.test.ts b/packages/pi-teams/src/utils/lock.test.ts index a46da18e..2a0f6775 100644 --- a/packages/pi-teams/src/utils/lock.test.ts +++ b/packages/pi-teams/src/utils/lock.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { withLock } from "./lock"; describe("withLock", () => { - const testDir = path.join(os.tmpdir(), "pi-lock-test-" + Date.now()); + const testDir = path.join(os.tmpdir(), `pi-lock-test-${Date.now()}`); const lockPath = path.join(testDir, "test"); const lockFile = `${lockPath}.lock`; diff --git a/packages/pi-teams/src/utils/lock.ts b/packages/pi-teams/src/utils/lock.ts index fd69f4fc..54eacbe7 100644 --- a/packages/pi-teams/src/utils/lock.ts +++ b/packages/pi-teams/src/utils/lock.ts @@ -1,8 +1,6 @@ // Project: pi-teams import fs from "node:fs"; -import path from "node:path"; -const LOCK_TIMEOUT = 5000; // 5 seconds of retrying const STALE_LOCK_TIMEOUT = 30000; // 30 seconds for a lock to be considered stale export async function withLock(lockPath: string, fn: () => Promise, retries: number = 50): Promise { @@ -18,7 +16,7 @@ export async function withLock(lockPath: string, fn: () => Promise, retrie // Attempt to remove stale lock try { fs.unlinkSync(lockFile); - } catch (e) { + } catch (_error) { // ignore, another process might have already removed it } } @@ -26,7 +24,7 @@ export async function withLock(lockPath: string, fn: () => Promise, retrie fs.writeFileSync(lockFile, process.pid.toString(), { flag: "wx" }); break; - } catch (e) { + } catch (_error) { retries--; await new Promise((resolve) => setTimeout(resolve, 100)); } @@ -41,7 +39,7 @@ export async function withLock(lockPath: string, fn: () => Promise, retrie } finally { try { fs.unlinkSync(lockFile); - } catch (e) { + } catch (_error) { // ignore } } diff --git a/packages/pi-teams/src/utils/messaging.test.ts b/packages/pi-teams/src/utils/messaging.test.ts index cde6fc50..75f03673 100644 --- a/packages/pi-teams/src/utils/messaging.test.ts +++ b/packages/pi-teams/src/utils/messaging.test.ts @@ -6,7 +6,7 @@ import { appendMessage, broadcastMessage, readInbox, sendPlainMessage } from "./ import * as paths from "./paths"; // Mock the paths to use a temporary directory -const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now()); +const testDir = path.join(os.tmpdir(), `pi-teams-test-${Date.now()}`); describe("Messaging Utilities", () => { beforeEach(() => { @@ -14,11 +14,11 @@ describe("Messaging Utilities", () => { fs.mkdirSync(testDir, { recursive: true }); // Override paths to use testDir - vi.spyOn(paths, "inboxPath").mockImplementation((teamName, agentName) => { + vi.spyOn(paths, "inboxPath").mockImplementation((_teamName, agentName) => { return path.join(testDir, "inboxes", `${agentName}.json`); }); vi.spyOn(paths, "teamDir").mockReturnValue(testDir); - vi.spyOn(paths, "configPath").mockImplementation((teamName) => { + vi.spyOn(paths, "configPath").mockImplementation((_teamName) => { return path.join(testDir, "config.json"); }); }); diff --git a/packages/pi-teams/src/utils/messaging.ts b/packages/pi-teams/src/utils/messaging.ts index ab9a76dd..3c5fc4e3 100644 --- a/packages/pi-teams/src/utils/messaging.ts +++ b/packages/pi-teams/src/utils/messaging.ts @@ -103,6 +103,8 @@ export async function broadcastMessage( if (failures.length > 0) { console.error(`Broadcast partially failed: ${failures.length} messages could not be delivered.`); // Optionally log individual errors - failures.forEach((f) => console.error(`- Delivery error:`, f.reason)); + for (const failure of failures) { + console.error("- Delivery error:", failure.reason); + } } } diff --git a/packages/pi-teams/src/utils/security.test.ts b/packages/pi-teams/src/utils/security.test.ts index ba3b3b9d..22781a8e 100644 --- a/packages/pi-teams/src/utils/security.test.ts +++ b/packages/pi-teams/src/utils/security.test.ts @@ -1,6 +1,3 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; import { inboxPath, sanitizeName, teamDir } from "./paths"; @@ -17,7 +14,6 @@ describe("Security Audit - Path Traversal (Prevention Check)", () => { }); it("should throw an error for path traversal via taskId", () => { - const teamName = "audit-team"; const maliciousTaskId = "../../../etc/passwd"; // We need to import readTask/updateTask or just sanitizeName directly if we want to test the logic // But since we already tested sanitizeName via other paths, this is just for completeness. diff --git a/packages/pi-teams/src/utils/tasks.race.test.ts b/packages/pi-teams/src/utils/tasks.race.test.ts index ade9138c..dad1c956 100644 --- a/packages/pi-teams/src/utils/tasks.race.test.ts +++ b/packages/pi-teams/src/utils/tasks.race.test.ts @@ -3,9 +3,9 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as paths from "./paths"; -import { createTask, listTasks } from "./tasks"; +import { createTask } from "./tasks"; -const testDir = path.join(os.tmpdir(), "pi-tasks-race-test-" + Date.now()); +const testDir = path.join(os.tmpdir(), `pi-tasks-race-test-${Date.now()}`); describe("Tasks Race Condition Bug", () => { beforeEach(() => { diff --git a/packages/pi-teams/src/utils/tasks.test.ts b/packages/pi-teams/src/utils/tasks.test.ts index 246ffe26..1dc18267 100644 --- a/packages/pi-teams/src/utils/tasks.test.ts +++ b/packages/pi-teams/src/utils/tasks.test.ts @@ -6,10 +6,9 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as paths from "./paths"; import { createTask, evaluatePlan, listTasks, readTask, submitPlan, updateTask } from "./tasks"; -import * as teams from "./teams"; // Mock the paths to use a temporary directory -const testDir = path.join(os.tmpdir(), "pi-teams-test-" + Date.now()); +const testDir = path.join(os.tmpdir(), `pi-teams-test-${Date.now()}`); describe("Tasks Utilities", () => { beforeEach(() => { diff --git a/packages/pi-teams/src/utils/tasks.ts b/packages/pi-teams/src/utils/tasks.ts index 6ef5ccc6..691e90cd 100644 --- a/packages/pi-teams/src/utils/tasks.ts +++ b/packages/pi-teams/src/utils/tasks.ts @@ -10,7 +10,7 @@ import { teamExists } from "./teams"; export function getTaskId(teamName: string): string { const dir = taskDir(teamName); const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); - const ids = files.map((f) => parseInt(path.parse(f).name, 10)).filter((id) => !isNaN(id)); + const ids = files.map((f) => parseInt(path.parse(f).name, 10)).filter((id) => !Number.isNaN(id)); return ids.length > 0 ? (Math.max(...ids) + 1).toString() : "1"; } @@ -169,7 +169,7 @@ export async function listTasks(teamName: string): Promise { const tasks: TaskFile[] = files .map((f) => { const id = parseInt(path.parse(f).name, 10); - if (isNaN(id)) return null; + if (Number.isNaN(id)) return null; return JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")); }) .filter((t) => t !== null); diff --git a/packages/pi-teams/src/utils/teams.ts b/packages/pi-teams/src/utils/teams.ts index 500df6a2..88f7111b 100644 --- a/packages/pi-teams/src/utils/teams.ts +++ b/packages/pi-teams/src/utils/teams.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import path from "node:path"; import { withLock } from "./lock"; import type { Member, TeamConfig } from "./models"; import { configPath, taskDir, teamDir } from "./paths";