From 5587293ee4ca24b08fcc0cd8ef15636e7e08a41c Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 01:05:07 -0600 Subject: [PATCH 1/3] feat: implement agent routes for REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Fastify route plugin with four agent interaction endpoints: - GET /api/agents/:id/logs?lines=N — capture tmux pane output (default 200) - POST /api/agents/:id/send — send text/keys with raw, literal, or with-enter modes - POST /api/agents/:id/kill — kill agent via core kill logic with manifest update - POST /api/agents/:id/restart — restart agent with optional prompt override Includes input validation, PpgError-to-HTTP status mapping, and 16 tests covering all endpoints with mocked core functions. Closes #69 --- package-lock.json | 653 ++++++++++++++++++++++++++++++- package.json | 4 +- src/lib/paths.ts | 8 + src/server/index.ts | 128 ++++++ src/server/routes/agents.test.ts | 427 ++++++++++++++++++++ src/server/routes/agents.ts | 309 +++++++++++++++ 6 files changed, 1524 insertions(+), 5 deletions(-) create mode 100644 src/server/index.ts create mode 100644 src/server/routes/agents.test.ts create mode 100644 src/server/routes/agents.ts diff --git a/package-lock.json b/package-lock.json index a036a8f..468a87d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.3.3", "license": "MIT", "dependencies": { + "@fastify/cors": "^11.2.0", "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", @@ -21,7 +23,7 @@ "ppg": "dist/cli.js" }, "devDependencies": { - "@types/node": "^22.13.4", + "@types/node": "^22.19.13", "@types/proper-lockfile": "^4.1.4", "tsup": "^8.4.0", "tsx": "^4.19.3", @@ -474,6 +476,137 @@ "node": ">=18" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz", + "integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^6.0.0" + } + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -513,6 +646,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", @@ -907,9 +1046,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", - "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", "dev": true, "license": "MIT", "dependencies": { @@ -1048,6 +1187,12 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1061,6 +1206,39 @@ "node": ">=0.4.0" } }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1078,6 +1256,35 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/avvio": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz", + "integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -1173,6 +1380,19 @@ "node": "^14.18.0 || >=16.10.0" } }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cron-parser": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", @@ -1227,6 +1447,15 @@ "node": ">=6" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1322,6 +1551,125 @@ "node": ">=12.0.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stringify": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.3.0.tgz", + "integrity": "sha512-oRCntNDY/329HJPlmdNLIdogNtt6Vyjb1WuT01Soss3slIdyUp8kAcDU3saQTOquEK8KFVfwIIF7FebxUAu+yA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", + "integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^6.0.0", + "find-my-way": "^9.0.0", + "light-my-request": "^6.0.0", + "pino": "^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1355,6 +1703,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/find-my-way": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.5.0.tgz", + "integrity": "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/fix-dts-default-cjs-exports": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", @@ -1435,6 +1797,15 @@ "node": ">=0.8.19" } }, + "node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -1494,6 +1865,68 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -1638,6 +2071,15 @@ "node": ">=0.10.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -1696,6 +2138,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -1824,6 +2303,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -1841,6 +2336,12 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -1855,6 +2356,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -1875,6 +2394,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -1884,6 +2412,22 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.58.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", @@ -1929,6 +2473,68 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-regex2": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz", + "integrity": "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1969,6 +2575,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", @@ -1989,6 +2604,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -2084,6 +2708,18 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", + "integrity": "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2145,6 +2781,15 @@ "node": ">=14.0.0" } }, + "node_modules/toad-cache": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz", + "integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", diff --git a/package.json b/package.json index b4cd8bf..73cfc58 100644 --- a/package.json +++ b/package.json @@ -45,16 +45,18 @@ ], "license": "MIT", "dependencies": { + "@fastify/cors": "^11.2.0", "commander": "^14.0.0", "cron-parser": "^5.5.0", "execa": "^9.5.2", + "fastify": "^5.7.4", "nanoid": "^5.1.5", "proper-lockfile": "^4.1.2", "write-file-atomic": "^7.0.0", "yaml": "^2.7.1" }, "devDependencies": { - "@types/node": "^22.13.4", + "@types/node": "^22.19.13", "@types/proper-lockfile": "^4.1.4", "tsup": "^8.4.0", "tsx": "^4.19.3", diff --git a/src/lib/paths.ts b/src/lib/paths.ts index d456f5f..92ce513 100644 --- a/src/lib/paths.ts +++ b/src/lib/paths.ts @@ -86,3 +86,11 @@ export function worktreeBaseDir(projectRoot: string): string { export function worktreePath(projectRoot: string, id: string): string { return path.join(worktreeBaseDir(projectRoot), id); } + +export function serveStatePath(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve.state.json'); +} + +export function servePidPath(projectRoot: string): string { + return path.join(ppgDir(projectRoot), 'serve.pid'); +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..0cb243e --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,128 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { createRequire } from 'node:module'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import { agentRoutes } from './routes/agents.js'; +import { serveStatePath, servePidPath } from '../lib/paths.js'; +import { info, success } from '../lib/output.js'; + +const require = createRequire(import.meta.url); +const pkg = require('../../package.json') as { version: string }; + +export interface ServeOptions { + projectRoot: string; + port: number; + host: string; + token?: string; + json?: boolean; +} + +export interface ServeState { + pid: number; + port: number; + host: string; + lanAddress?: string; + startedAt: string; + version: string; +} + +export function detectLanAddress(): string | undefined { + const interfaces = os.networkInterfaces(); + for (const addrs of Object.values(interfaces)) { + if (!addrs) continue; + for (const addr of addrs) { + if (addr.family === 'IPv4' && !addr.internal) { + return addr.address; + } + } + } + return undefined; +} + +async function writeStateFile(projectRoot: string, state: ServeState): Promise { + const statePath = serveStatePath(projectRoot); + await fs.writeFile(statePath, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 }); +} + +async function writePidFile(projectRoot: string, pid: number): Promise { + const pidPath = servePidPath(projectRoot); + await fs.writeFile(pidPath, String(pid) + '\n', { mode: 0o600 }); +} + +async function removeStateFiles(projectRoot: string): Promise { + for (const filePath of [serveStatePath(projectRoot), servePidPath(projectRoot)]) { + try { + await fs.unlink(filePath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } +} + +export async function startServer(options: ServeOptions): Promise { + const { projectRoot, port, host, token, json } = options; + + const app = Fastify({ logger: false }); + + await app.register(cors, { origin: true }); + + if (token) { + app.addHook('onRequest', async (request, reply) => { + if (request.url === '/health') return; + const authHeader = request.headers.authorization; + if (authHeader !== `Bearer ${token}`) { + reply.code(401).send({ error: 'Unauthorized' }); + } + }); + } + + app.get('/health', async () => { + return { + status: 'ok', + uptime: process.uptime(), + version: pkg.version, + }; + }); + + // Register route plugins + await app.register(agentRoutes, { prefix: '/api', projectRoot }); + + const lanAddress = detectLanAddress(); + + const shutdown = async (signal: string) => { + if (!json) info(`Received ${signal}, shutting down...`); + await removeStateFiles(projectRoot); + await app.close(); + process.exit(0); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + await app.listen({ port, host }); + + const state: ServeState = { + pid: process.pid, + port, + host, + lanAddress, + startedAt: new Date().toISOString(), + version: pkg.version, + }; + + await writeStateFile(projectRoot, state); + await writePidFile(projectRoot, process.pid); + + if (json) { + console.log(JSON.stringify(state)); + } else { + success(`Server listening on http://${host}:${port}`); + if (lanAddress) { + info(`LAN address: http://${lanAddress}:${port}`); + } + if (token) { + info('Bearer token authentication enabled'); + } + } +} diff --git a/src/server/routes/agents.test.ts b/src/server/routes/agents.test.ts new file mode 100644 index 0000000..ca721b9 --- /dev/null +++ b/src/server/routes/agents.test.ts @@ -0,0 +1,427 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import Fastify from 'fastify'; +import { agentRoutes } from './agents.js'; +import type { Manifest } from '../../types/manifest.js'; +import { makeAgent, makeWorktree } from '../../test-fixtures.js'; + +// ---- Mocks ---- + +const mockAgent = makeAgent({ id: 'ag-test1234', tmuxTarget: 'ppg:1.0' }); +const mockWorktree = makeWorktree({ + id: 'wt-abc123', + agents: { 'ag-test1234': mockAgent }, +}); + +function makeManifest(overrides?: Partial): Manifest { + return { + version: 1, + projectRoot: '/tmp/project', + sessionName: 'ppg', + worktrees: { 'wt-abc123': makeWorktree({ agents: { 'ag-test1234': makeAgent() } }) }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +vi.mock('../../core/manifest.js', () => ({ + requireManifest: vi.fn(), + findAgent: vi.fn(), + updateManifest: vi.fn(), +})); + +vi.mock('../../core/agent.js', () => ({ + killAgent: vi.fn(), + spawnAgent: vi.fn(), +})); + +vi.mock('../../core/tmux.js', () => ({ + capturePane: vi.fn(), + sendKeys: vi.fn(), + sendLiteral: vi.fn(), + sendRawKeys: vi.fn(), + ensureSession: vi.fn(), + createWindow: vi.fn(), +})); + +vi.mock('../../core/config.js', () => ({ + loadConfig: vi.fn(), + resolveAgentConfig: vi.fn(), +})); + +vi.mock('../../core/template.js', () => ({ + renderTemplate: vi.fn((content: string) => content), +})); + +vi.mock('../../lib/id.js', () => ({ + agentId: vi.fn(() => 'ag-new12345'), + sessionId: vi.fn(() => 'session-uuid-123'), +})); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { + ...actual, + default: { + ...actual, + readFile: vi.fn(), + }, + }; +}); + +import { requireManifest, findAgent, updateManifest } from '../../core/manifest.js'; +import { killAgent, spawnAgent } from '../../core/agent.js'; +import * as tmux from '../../core/tmux.js'; +import { loadConfig, resolveAgentConfig } from '../../core/config.js'; +import fs from 'node:fs/promises'; + +const PROJECT_ROOT = '/tmp/project'; + +async function buildApp() { + const app = Fastify(); + await app.register(agentRoutes, { prefix: '/api', projectRoot: PROJECT_ROOT }); + return app; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// ---------- GET /api/agents/:id/logs ---------- + +describe('GET /api/agents/:id/logs', () => { + test('returns captured pane output with default 200 lines', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + vi.mocked(tmux.capturePane).mockResolvedValue('line1\nline2\nline3'); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs' }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.agentId).toBe('ag-test1234'); + expect(body.output).toBe('line1\nline2\nline3'); + expect(body.lines).toBe(200); + expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 200); + }); + + test('respects custom lines parameter', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + vi.mocked(tmux.capturePane).mockResolvedValue('output'); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs?lines=50' }); + + expect(res.statusCode).toBe(200); + expect(res.json().lines).toBe(50); + expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 50); + }); + + test('returns 400 for invalid lines', async () => { + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs?lines=abc' }); + + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('INVALID_ARGS'); + }); + + test('returns 404 for unknown agent', async () => { + vi.mocked(requireManifest).mockResolvedValue(makeManifest()); + vi.mocked(findAgent).mockReturnValue(undefined); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-unknown/logs' }); + + expect(res.statusCode).toBe(404); + expect(res.json().code).toBe('AGENT_NOT_FOUND'); + }); +}); + +// ---------- POST /api/agents/:id/send ---------- + +describe('POST /api/agents/:id/send', () => { + test('sends text with Enter by default', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: { text: 'hello' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + expect(res.json().mode).toBe('with-enter'); + expect(tmux.sendKeys).toHaveBeenCalledWith('ppg:1.0', 'hello'); + }); + + test('sends literal text without Enter', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: { text: 'hello', mode: 'literal' }, + }); + + expect(res.statusCode).toBe(200); + expect(tmux.sendLiteral).toHaveBeenCalledWith('ppg:1.0', 'hello'); + }); + + test('sends raw tmux keys', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: { text: 'C-c', mode: 'raw' }, + }); + + expect(res.statusCode).toBe(200); + expect(tmux.sendRawKeys).toHaveBeenCalledWith('ppg:1.0', 'C-c'); + }); + + test('rejects invalid mode', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: { text: 'hello', mode: 'invalid' }, + }); + + expect(res.statusCode).toBe(400); + }); + + test('rejects missing text field', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/send', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + }); + + test('returns 404 for unknown agent', async () => { + vi.mocked(requireManifest).mockResolvedValue(makeManifest()); + vi.mocked(findAgent).mockReturnValue(undefined); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-unknown/send', + payload: { text: 'hello' }, + }); + + expect(res.statusCode).toBe(404); + }); +}); + +// ---------- POST /api/agents/:id/kill ---------- + +describe('POST /api/agents/:id/kill', () => { + test('kills a running agent', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent) + .mockReturnValueOnce({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }) + .mockReturnValueOnce({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + vi.mocked(killAgent).mockResolvedValue(undefined); + vi.mocked(updateManifest).mockImplementation(async (_root, updater) => { + const m = makeManifest(); + return updater(m); + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/kill', + }); + + expect(res.statusCode).toBe(200); + expect(res.json().success).toBe(true); + expect(res.json().killed).toBe(true); + expect(killAgent).toHaveBeenCalled(); + expect(updateManifest).toHaveBeenCalled(); + }); + + test('returns success without killing already-stopped agent', async () => { + const stoppedAgent = makeAgent({ status: 'gone' }); + const manifest = makeManifest({ + worktrees: { 'wt-abc123': makeWorktree({ agents: { 'ag-test1234': stoppedAgent } }) }, + }); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: stoppedAgent, + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/kill', + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toMatch(/already gone/); + expect(killAgent).not.toHaveBeenCalled(); + }); + + test('returns 404 for unknown agent', async () => { + vi.mocked(requireManifest).mockResolvedValue(makeManifest()); + vi.mocked(findAgent).mockReturnValue(undefined); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-unknown/kill', + }); + + expect(res.statusCode).toBe(404); + }); +}); + +// ---------- POST /api/agents/:id/restart ---------- + +describe('POST /api/agents/:id/restart', () => { + test('restarts a running agent with original prompt', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + vi.mocked(killAgent).mockResolvedValue(undefined); + vi.mocked(fs.readFile).mockResolvedValue('original prompt'); + vi.mocked(loadConfig).mockResolvedValue({ + sessionName: 'ppg', + defaultAgent: 'claude', + agents: { claude: { name: 'claude', command: 'claude', interactive: true } }, + envFiles: [], + symlinkNodeModules: true, + }); + vi.mocked(resolveAgentConfig).mockReturnValue({ + name: 'claude', + command: 'claude', + interactive: true, + }); + vi.mocked(tmux.ensureSession).mockResolvedValue(undefined); + vi.mocked(tmux.createWindow).mockResolvedValue('ppg:2'); + vi.mocked(spawnAgent).mockResolvedValue(makeAgent({ + id: 'ag-new12345', + tmuxTarget: 'ppg:2', + })); + vi.mocked(updateManifest).mockImplementation(async (_root, updater) => { + const m = makeManifest(); + return updater(m); + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/restart', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.success).toBe(true); + expect(body.oldAgentId).toBe('ag-test1234'); + expect(body.newAgent.id).toBe('ag-new12345'); + expect(killAgent).toHaveBeenCalled(); + expect(spawnAgent).toHaveBeenCalled(); + }); + + test('uses prompt override when provided', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + vi.mocked(killAgent).mockResolvedValue(undefined); + vi.mocked(loadConfig).mockResolvedValue({ + sessionName: 'ppg', + defaultAgent: 'claude', + agents: { claude: { name: 'claude', command: 'claude', interactive: true } }, + envFiles: [], + symlinkNodeModules: true, + }); + vi.mocked(resolveAgentConfig).mockReturnValue({ + name: 'claude', + command: 'claude', + interactive: true, + }); + vi.mocked(tmux.ensureSession).mockResolvedValue(undefined); + vi.mocked(tmux.createWindow).mockResolvedValue('ppg:2'); + vi.mocked(spawnAgent).mockResolvedValue(makeAgent({ id: 'ag-new12345', tmuxTarget: 'ppg:2' })); + vi.mocked(updateManifest).mockImplementation(async (_root, updater) => { + const m = makeManifest(); + return updater(m); + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/restart', + payload: { prompt: 'new task' }, + }); + + expect(res.statusCode).toBe(200); + // Should NOT read the old prompt file + expect(fs.readFile).not.toHaveBeenCalled(); + // spawnAgent should receive the override prompt + expect(spawnAgent).toHaveBeenCalledWith( + expect.objectContaining({ prompt: 'new task' }), + ); + }); + + test('returns 404 for unknown agent', async () => { + vi.mocked(requireManifest).mockResolvedValue(makeManifest()); + vi.mocked(findAgent).mockReturnValue(undefined); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-unknown/restart', + payload: {}, + }); + + expect(res.statusCode).toBe(404); + }); +}); diff --git a/src/server/routes/agents.ts b/src/server/routes/agents.ts new file mode 100644 index 0000000..cae8767 --- /dev/null +++ b/src/server/routes/agents.ts @@ -0,0 +1,309 @@ +import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; +import { requireManifest, findAgent, updateManifest } from '../../core/manifest.js'; +import { killAgent } from '../../core/agent.js'; +import { loadConfig, resolveAgentConfig } from '../../core/config.js'; +import { spawnAgent } from '../../core/agent.js'; +import * as tmux from '../../core/tmux.js'; +import { PpgError, AgentNotFoundError } from '../../lib/errors.js'; +import { agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; +import { agentPromptFile } from '../../lib/paths.js'; +import { renderTemplate, type TemplateContext } from '../../core/template.js'; +import fs from 'node:fs/promises'; + +export interface AgentRoutesOptions extends FastifyPluginOptions { + projectRoot: string; +} + +function mapErrorToStatus(err: unknown): number { + if (err instanceof PpgError) { + switch (err.code) { + case 'AGENT_NOT_FOUND': return 404; + case 'NOT_INITIALIZED': return 503; + case 'MANIFEST_LOCK': return 409; + case 'TMUX_NOT_FOUND': return 503; + case 'INVALID_ARGS': return 400; + default: return 500; + } + } + return 500; +} + +function errorPayload(err: unknown): { error: string; code?: string } { + if (err instanceof PpgError) { + return { error: err.message, code: err.code }; + } + return { error: err instanceof Error ? err.message : String(err) }; +} + +export async function agentRoutes( + app: FastifyInstance, + opts: AgentRoutesOptions, +): Promise { + const { projectRoot } = opts; + + // ---------- GET /api/agents/:id/logs ---------- + app.get<{ + Params: { id: string }; + Querystring: { lines?: string }; + }>('/agents/:id/logs', { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + querystring: { + type: 'object', + properties: { lines: { type: 'string' } }, + }, + }, + }, async (request, reply) => { + try { + const { id } = request.params; + const lines = request.query.lines ? parseInt(request.query.lines, 10) : 200; + + if (isNaN(lines) || lines < 1) { + return reply.code(400).send({ error: 'lines must be a positive integer', code: 'INVALID_ARGS' }); + } + + const manifest = await requireManifest(projectRoot); + const found = findAgent(manifest, id); + if (!found) throw new AgentNotFoundError(id); + + const { agent } = found; + const content = await tmux.capturePane(agent.tmuxTarget, lines); + + return { + agentId: agent.id, + status: agent.status, + tmuxTarget: agent.tmuxTarget, + lines, + output: content, + }; + } catch (err) { + const status = mapErrorToStatus(err); + return reply.code(status).send(errorPayload(err)); + } + }); + + // ---------- POST /api/agents/:id/send ---------- + app.post<{ + Params: { id: string }; + Body: { text: string; mode?: 'raw' | 'literal' | 'with-enter' }; + }>('/agents/:id/send', { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + required: ['text'], + properties: { + text: { type: 'string' }, + mode: { type: 'string', enum: ['raw', 'literal', 'with-enter'] }, + }, + }, + }, + }, async (request, reply) => { + try { + const { id } = request.params; + const { text, mode = 'with-enter' } = request.body; + + const manifest = await requireManifest(projectRoot); + const found = findAgent(manifest, id); + if (!found) throw new AgentNotFoundError(id); + + const { agent } = found; + + switch (mode) { + case 'raw': + await tmux.sendRawKeys(agent.tmuxTarget, text); + break; + case 'literal': + await tmux.sendLiteral(agent.tmuxTarget, text); + break; + case 'with-enter': + default: + await tmux.sendKeys(agent.tmuxTarget, text); + break; + } + + return { + success: true, + agentId: agent.id, + tmuxTarget: agent.tmuxTarget, + text, + mode, + }; + } catch (err) { + const status = mapErrorToStatus(err); + return reply.code(status).send(errorPayload(err)); + } + }); + + // ---------- POST /api/agents/:id/kill ---------- + app.post<{ + Params: { id: string }; + }>('/agents/:id/kill', { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + }, + }, async (request, reply) => { + try { + const { id } = request.params; + + const manifest = await requireManifest(projectRoot); + const found = findAgent(manifest, id); + if (!found) throw new AgentNotFoundError(id); + + const { agent } = found; + + if (agent.status !== 'running') { + return { + success: true, + agentId: agent.id, + message: `Agent already ${agent.status}`, + }; + } + + await killAgent(agent); + + await updateManifest(projectRoot, (m) => { + const f = findAgent(m, id); + if (f) { + f.agent.status = 'gone'; + } + return m; + }); + + return { + success: true, + agentId: agent.id, + killed: true, + }; + } catch (err) { + const status = mapErrorToStatus(err); + return reply.code(status).send(errorPayload(err)); + } + }); + + // ---------- POST /api/agents/:id/restart ---------- + app.post<{ + Params: { id: string }; + Body: { prompt?: string; agent?: string }; + }>('/agents/:id/restart', { + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + properties: { + prompt: { type: 'string' }, + agent: { type: 'string' }, + }, + }, + }, + }, async (request, reply) => { + try { + const { id } = request.params; + const { prompt: promptOverride, agent: agentType } = request.body ?? {}; + + const manifest = await requireManifest(projectRoot); + const config = await loadConfig(projectRoot); + + const found = findAgent(manifest, id); + if (!found) throw new AgentNotFoundError(id); + + const { worktree: wt, agent: oldAgent } = found; + + // Kill old agent if still running + if (oldAgent.status === 'running') { + await killAgent(oldAgent); + } + + // Read original prompt or use override + let promptText: string; + if (promptOverride) { + promptText = promptOverride; + } else { + const pFile = agentPromptFile(projectRoot, oldAgent.id); + try { + promptText = await fs.readFile(pFile, 'utf-8'); + } catch { + throw new PpgError( + `Could not read original prompt for agent ${oldAgent.id}. Provide a prompt in the request body.`, + 'PROMPT_NOT_FOUND', + ); + } + } + + const agentConfig = resolveAgentConfig(config, agentType ?? oldAgent.agentType); + + await tmux.ensureSession(manifest.sessionName); + const newAgentId = genAgentId(); + const windowTarget = await tmux.createWindow(manifest.sessionName, `${wt.name}-restart`, wt.path); + + // Render template vars + const ctx: TemplateContext = { + WORKTREE_PATH: wt.path, + BRANCH: wt.branch, + AGENT_ID: newAgentId, + PROJECT_ROOT: projectRoot, + TASK_NAME: wt.name, + PROMPT: promptText, + }; + const renderedPrompt = renderTemplate(promptText, ctx); + + const newSessionId = genSessionId(); + const agentEntry = await spawnAgent({ + agentId: newAgentId, + agentConfig, + prompt: renderedPrompt, + worktreePath: wt.path, + tmuxTarget: windowTarget, + projectRoot, + branch: wt.branch, + sessionId: newSessionId, + }); + + // Update manifest: mark old agent as gone, add new agent + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + const mOldAgent = mWt.agents[oldAgent.id]; + if (mOldAgent && mOldAgent.status === 'running') { + mOldAgent.status = 'gone'; + } + mWt.agents[newAgentId] = agentEntry; + } + return m; + }); + + return { + success: true, + oldAgentId: oldAgent.id, + newAgent: { + id: newAgentId, + tmuxTarget: windowTarget, + sessionId: newSessionId, + worktreeId: wt.id, + worktreeName: wt.name, + branch: wt.branch, + path: wt.path, + }, + }; + } catch (err) { + const status = mapErrorToStatus(err); + return reply.code(status).send(errorPayload(err)); + } + }); +} From 41e180b0fdb27929f68f5375dea62ce8b7698184 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 07:56:58 -0600 Subject: [PATCH 2/3] fix: address code review findings for agent routes - P1: merge duplicate import from core/agent.js - P2: use crypto.timingSafeEqual for bearer token comparison (OWASP A2) - P2: refresh live agent status via checkAgentStatus in kill route - P2: extract shared restartAgent() into core/agent.ts (DRY with CLI) - P2: remove unused module-level test fixtures, add setupAgentMocks helper - P3: map PROMPT_NOT_FOUND error code to HTTP 400 - P3: map PANE_NOT_FOUND to HTTP 410, catch capturePane failures - P3: add tests for pane-gone, restart idle agent, missing prompt file - P4: cap lines query param at 10,000 --- src/commands/restart.ts | 85 ++++-------- src/core/agent.ts | 86 +++++++++++- src/server/index.ts | 8 +- src/server/routes/agents.test.ts | 224 ++++++++++++++++++++----------- src/server/routes/agents.ts | 96 ++++++------- 5 files changed, 299 insertions(+), 200 deletions(-) diff --git a/src/commands/restart.ts b/src/commands/restart.ts index c2627e5..f8790aa 100644 --- a/src/commands/restart.ts +++ b/src/commands/restart.ts @@ -1,15 +1,12 @@ import fs from 'node:fs/promises'; -import { requireManifest, updateManifest, findAgent } from '../core/manifest.js'; +import { requireManifest, findAgent } from '../core/manifest.js'; import { loadConfig, resolveAgentConfig } from '../core/config.js'; -import { spawnAgent, killAgent } from '../core/agent.js'; +import { restartAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; -import * as tmux from '../core/tmux.js'; import { openTerminalWindow } from '../core/terminal.js'; -import { agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; import { agentPromptFile } from '../lib/paths.js'; import { PpgError, AgentNotFoundError } from '../lib/errors.js'; import { output, success, info } from '../lib/output.js'; -import { renderTemplate, type TemplateContext } from '../core/template.js'; export interface RestartOptions { prompt?: string; @@ -29,12 +26,6 @@ export async function restartCommand(agentRef: string, options: RestartOptions): const { worktree: wt, agent: oldAgent } = found; - // Kill old agent if still running - if (oldAgent.status === 'running') { - info(`Killing existing agent ${oldAgent.id}`); - await killAgent(oldAgent); - } - // Read original prompt from prompt file, or use override let promptText: string; if (options.prompt) { @@ -51,73 +42,43 @@ export async function restartCommand(agentRef: string, options: RestartOptions): } } - // Resolve agent config const agentConfig = resolveAgentConfig(config, options.agent ?? oldAgent.agentType); - // Ensure tmux session - await tmux.ensureSession(manifest.sessionName); - - // Create new tmux window in same worktree - const newAgentId = genAgentId(); - const windowTarget = await tmux.createWindow(manifest.sessionName, `${wt.name}-restart`, wt.path); - - // Render template vars - const ctx: TemplateContext = { - WORKTREE_PATH: wt.path, - BRANCH: wt.branch, - AGENT_ID: newAgentId, - PROJECT_ROOT: projectRoot, - TASK_NAME: wt.name, - PROMPT: promptText, - }; - const renderedPrompt = renderTemplate(promptText, ctx); + if (oldAgent.status === 'running') { + info(`Killing existing agent ${oldAgent.id}`); + } - const newSessionId = genSessionId(); - const agentEntry = await spawnAgent({ - agentId: newAgentId, - agentConfig, - prompt: renderedPrompt, - worktreePath: wt.path, - tmuxTarget: windowTarget, + const result = await restartAgent({ projectRoot, - branch: wt.branch, - sessionId: newSessionId, - }); - - // Update manifest: mark old agent as gone, add new agent - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (mWt) { - const mOldAgent = mWt.agents[oldAgent.id]; - if (mOldAgent && mOldAgent.status === 'running') { - mOldAgent.status = 'gone'; - } - mWt.agents[newAgentId] = agentEntry; - } - return m; + agentId: oldAgent.id, + worktree: wt, + oldAgent, + sessionName: manifest.sessionName, + agentConfig, + promptText, }); // Only open Terminal window when explicitly requested via --open (fire-and-forget) if (options.open === true) { - openTerminalWindow(manifest.sessionName, windowTarget, `${wt.name}-restart`).catch(() => {}); + openTerminalWindow(manifest.sessionName, result.tmuxTarget, `${wt.name}-restart`).catch(() => {}); } if (options.json) { output({ success: true, - oldAgentId: oldAgent.id, + oldAgentId: result.oldAgentId, newAgent: { - id: newAgentId, - tmuxTarget: windowTarget, - sessionId: newSessionId, - worktreeId: wt.id, - worktreeName: wt.name, - branch: wt.branch, - path: wt.path, + id: result.newAgentId, + tmuxTarget: result.tmuxTarget, + sessionId: result.sessionId, + worktreeId: result.worktreeId, + worktreeName: result.worktreeName, + branch: result.branch, + path: result.path, }, }, true); } else { - success(`Restarted agent ${oldAgent.id} → ${newAgentId} in worktree ${wt.name}`); - info(` New agent ${newAgentId} → ${windowTarget}`); + success(`Restarted agent ${result.oldAgentId} → ${result.newAgentId} in worktree ${wt.name}`); + info(` New agent ${result.newAgentId} → ${result.tmuxTarget}`); } } diff --git a/src/core/agent.ts b/src/core/agent.ts index be24e82..def1c23 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -3,7 +3,9 @@ import { agentPromptFile, agentPromptsDir } from '../lib/paths.js'; import { getPaneInfo, listSessionPanes, type PaneInfo } from './tmux.js'; import { updateManifest } from './manifest.js'; import { PpgError } from '../lib/errors.js'; -import type { AgentEntry, AgentStatus } from '../types/manifest.js'; +import { agentId as genAgentId, sessionId as genSessionId } from '../lib/id.js'; +import { renderTemplate, type TemplateContext } from './template.js'; +import type { AgentEntry, AgentStatus, WorktreeEntry } from '../types/manifest.js'; import type { AgentConfig } from '../types/config.js'; import * as tmux from './tmux.js'; @@ -242,6 +244,88 @@ export async function killAgents(agents: AgentEntry[]): Promise { })); } +export interface RestartAgentOptions { + projectRoot: string; + agentId: string; + worktree: WorktreeEntry; + oldAgent: AgentEntry; + sessionName: string; + agentConfig: AgentConfig; + promptText: string; +} + +export interface RestartAgentResult { + oldAgentId: string; + newAgentId: string; + tmuxTarget: string; + sessionId: string; + worktreeId: string; + worktreeName: string; + branch: string; + path: string; +} + +/** + * Restart an agent: kill old, spawn new in a fresh tmux window, update manifest. + */ +export async function restartAgent(opts: RestartAgentOptions): Promise { + const { projectRoot, worktree: wt, oldAgent, sessionName, agentConfig, promptText } = opts; + + // Kill old agent if still running + if (oldAgent.status === 'running') { + await killAgent(oldAgent); + } + + await tmux.ensureSession(sessionName); + const newAgentId = genAgentId(); + const windowTarget = await tmux.createWindow(sessionName, `${wt.name}-restart`, wt.path); + + const ctx: TemplateContext = { + WORKTREE_PATH: wt.path, + BRANCH: wt.branch, + AGENT_ID: newAgentId, + PROJECT_ROOT: projectRoot, + TASK_NAME: wt.name, + PROMPT: promptText, + }; + const renderedPrompt = renderTemplate(promptText, ctx); + + const newSessionId = genSessionId(); + const agentEntry = await spawnAgent({ + agentId: newAgentId, + agentConfig, + prompt: renderedPrompt, + worktreePath: wt.path, + tmuxTarget: windowTarget, + projectRoot, + branch: wt.branch, + sessionId: newSessionId, + }); + + await updateManifest(projectRoot, (m) => { + const mWt = m.worktrees[wt.id]; + if (mWt) { + const mOldAgent = mWt.agents[oldAgent.id]; + if (mOldAgent && mOldAgent.status === 'running') { + mOldAgent.status = 'gone'; + } + mWt.agents[newAgentId] = agentEntry; + } + return m; + }); + + return { + oldAgentId: oldAgent.id, + newAgentId, + tmuxTarget: windowTarget, + sessionId: newSessionId, + worktreeId: wt.id, + worktreeName: wt.name, + branch: wt.branch, + path: wt.path, + }; +} + async function fileExists(filePath: string): Promise { try { await fs.access(filePath); diff --git a/src/server/index.ts b/src/server/index.ts index 0cb243e..78f99b1 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; import { createRequire } from 'node:module'; @@ -68,10 +69,13 @@ export async function startServer(options: ServeOptions): Promise { await app.register(cors, { origin: true }); if (token) { + const expected = Buffer.from(`Bearer ${token}`); app.addHook('onRequest', async (request, reply) => { if (request.url === '/health') return; - const authHeader = request.headers.authorization; - if (authHeader !== `Bearer ${token}`) { + const authHeader = request.headers.authorization ?? ''; + const supplied = Buffer.from(authHeader); + if (expected.length !== supplied.length || + !crypto.timingSafeEqual(expected, supplied)) { reply.code(401).send({ error: 'Unauthorized' }); } }); diff --git a/src/server/routes/agents.test.ts b/src/server/routes/agents.test.ts index ca721b9..a4b3a0f 100644 --- a/src/server/routes/agents.test.ts +++ b/src/server/routes/agents.test.ts @@ -6,12 +6,6 @@ import { makeAgent, makeWorktree } from '../../test-fixtures.js'; // ---- Mocks ---- -const mockAgent = makeAgent({ id: 'ag-test1234', tmuxTarget: 'ppg:1.0' }); -const mockWorktree = makeWorktree({ - id: 'wt-abc123', - agents: { 'ag-test1234': mockAgent }, -}); - function makeManifest(overrides?: Partial): Manifest { return { version: 1, @@ -32,7 +26,8 @@ vi.mock('../../core/manifest.js', () => ({ vi.mock('../../core/agent.js', () => ({ killAgent: vi.fn(), - spawnAgent: vi.fn(), + checkAgentStatus: vi.fn(), + restartAgent: vi.fn(), })); vi.mock('../../core/tmux.js', () => ({ @@ -40,8 +35,6 @@ vi.mock('../../core/tmux.js', () => ({ sendKeys: vi.fn(), sendLiteral: vi.fn(), sendRawKeys: vi.fn(), - ensureSession: vi.fn(), - createWindow: vi.fn(), })); vi.mock('../../core/config.js', () => ({ @@ -49,15 +42,6 @@ vi.mock('../../core/config.js', () => ({ resolveAgentConfig: vi.fn(), })); -vi.mock('../../core/template.js', () => ({ - renderTemplate: vi.fn((content: string) => content), -})); - -vi.mock('../../lib/id.js', () => ({ - agentId: vi.fn(() => 'ag-new12345'), - sessionId: vi.fn(() => 'session-uuid-123'), -})); - vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); return { @@ -70,7 +54,7 @@ vi.mock('node:fs/promises', async () => { }); import { requireManifest, findAgent, updateManifest } from '../../core/manifest.js'; -import { killAgent, spawnAgent } from '../../core/agent.js'; +import { killAgent, checkAgentStatus, restartAgent } from '../../core/agent.js'; import * as tmux from '../../core/tmux.js'; import { loadConfig, resolveAgentConfig } from '../../core/config.js'; import fs from 'node:fs/promises'; @@ -83,6 +67,16 @@ async function buildApp() { return app; } +function setupAgentMocks(manifest?: Manifest) { + const m = manifest ?? makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(m); + vi.mocked(findAgent).mockReturnValue({ + worktree: m.worktrees['wt-abc123'], + agent: m.worktrees['wt-abc123'].agents['ag-test1234'], + }); + return m; +} + beforeEach(() => { vi.clearAllMocks(); }); @@ -91,12 +85,7 @@ beforeEach(() => { describe('GET /api/agents/:id/logs', () => { test('returns captured pane output with default 200 lines', async () => { - const manifest = makeManifest(); - vi.mocked(requireManifest).mockResolvedValue(manifest); - vi.mocked(findAgent).mockReturnValue({ - worktree: manifest.worktrees['wt-abc123'], - agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], - }); + setupAgentMocks(); vi.mocked(tmux.capturePane).mockResolvedValue('line1\nline2\nline3'); const app = await buildApp(); @@ -111,12 +100,7 @@ describe('GET /api/agents/:id/logs', () => { }); test('respects custom lines parameter', async () => { - const manifest = makeManifest(); - vi.mocked(requireManifest).mockResolvedValue(manifest); - vi.mocked(findAgent).mockReturnValue({ - worktree: manifest.worktrees['wt-abc123'], - agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], - }); + setupAgentMocks(); vi.mocked(tmux.capturePane).mockResolvedValue('output'); const app = await buildApp(); @@ -127,6 +111,18 @@ describe('GET /api/agents/:id/logs', () => { expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 50); }); + test('caps lines at 10000', async () => { + setupAgentMocks(); + vi.mocked(tmux.capturePane).mockResolvedValue('output'); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs?lines=999999' }); + + expect(res.statusCode).toBe(200); + expect(res.json().lines).toBe(10000); + expect(tmux.capturePane).toHaveBeenCalledWith('ppg:1.0', 10000); + }); + test('returns 400 for invalid lines', async () => { const app = await buildApp(); const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs?lines=abc' }); @@ -145,18 +141,24 @@ describe('GET /api/agents/:id/logs', () => { expect(res.statusCode).toBe(404); expect(res.json().code).toBe('AGENT_NOT_FOUND'); }); + + test('returns 410 when pane no longer exists', async () => { + setupAgentMocks(); + vi.mocked(tmux.capturePane).mockRejectedValue(new Error('pane not found')); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/agents/ag-test1234/logs' }); + + expect(res.statusCode).toBe(410); + expect(res.json().code).toBe('PANE_NOT_FOUND'); + }); }); // ---------- POST /api/agents/:id/send ---------- describe('POST /api/agents/:id/send', () => { test('sends text with Enter by default', async () => { - const manifest = makeManifest(); - vi.mocked(requireManifest).mockResolvedValue(manifest); - vi.mocked(findAgent).mockReturnValue({ - worktree: manifest.worktrees['wt-abc123'], - agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], - }); + setupAgentMocks(); const app = await buildApp(); const res = await app.inject({ @@ -172,12 +174,7 @@ describe('POST /api/agents/:id/send', () => { }); test('sends literal text without Enter', async () => { - const manifest = makeManifest(); - vi.mocked(requireManifest).mockResolvedValue(manifest); - vi.mocked(findAgent).mockReturnValue({ - worktree: manifest.worktrees['wt-abc123'], - agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], - }); + setupAgentMocks(); const app = await buildApp(); const res = await app.inject({ @@ -191,12 +188,7 @@ describe('POST /api/agents/:id/send', () => { }); test('sends raw tmux keys', async () => { - const manifest = makeManifest(); - vi.mocked(requireManifest).mockResolvedValue(manifest); - vi.mocked(findAgent).mockReturnValue({ - worktree: manifest.worktrees['wt-abc123'], - agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], - }); + setupAgentMocks(); const app = await buildApp(); const res = await app.inject({ @@ -261,6 +253,7 @@ describe('POST /api/agents/:id/kill', () => { worktree: manifest.worktrees['wt-abc123'], agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], }); + vi.mocked(checkAgentStatus).mockResolvedValue({ status: 'running' }); vi.mocked(killAgent).mockResolvedValue(undefined); vi.mocked(updateManifest).mockImplementation(async (_root, updater) => { const m = makeManifest(); @@ -276,6 +269,7 @@ describe('POST /api/agents/:id/kill', () => { expect(res.statusCode).toBe(200); expect(res.json().success).toBe(true); expect(res.json().killed).toBe(true); + expect(checkAgentStatus).toHaveBeenCalled(); expect(killAgent).toHaveBeenCalled(); expect(updateManifest).toHaveBeenCalled(); }); @@ -290,6 +284,7 @@ describe('POST /api/agents/:id/kill', () => { worktree: manifest.worktrees['wt-abc123'], agent: stoppedAgent, }); + vi.mocked(checkAgentStatus).mockResolvedValue({ status: 'gone' }); const app = await buildApp(); const res = await app.inject({ @@ -302,6 +297,30 @@ describe('POST /api/agents/:id/kill', () => { expect(killAgent).not.toHaveBeenCalled(); }); + test('uses live tmux status instead of stale manifest status', async () => { + // Agent shows "running" in manifest but tmux says "idle" + const agent = makeAgent({ status: 'running' }); + const manifest = makeManifest({ + worktrees: { 'wt-abc123': makeWorktree({ agents: { 'ag-test1234': agent } }) }, + }); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent, + }); + vi.mocked(checkAgentStatus).mockResolvedValue({ status: 'idle' }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/kill', + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toMatch(/already idle/); + expect(killAgent).not.toHaveBeenCalled(); + }); + test('returns 404 for unknown agent', async () => { vi.mocked(requireManifest).mockResolvedValue(makeManifest()); vi.mocked(findAgent).mockReturnValue(undefined); @@ -319,15 +338,13 @@ describe('POST /api/agents/:id/kill', () => { // ---------- POST /api/agents/:id/restart ---------- describe('POST /api/agents/:id/restart', () => { - test('restarts a running agent with original prompt', async () => { + function setupRestartMocks() { const manifest = makeManifest(); vi.mocked(requireManifest).mockResolvedValue(manifest); vi.mocked(findAgent).mockReturnValue({ worktree: manifest.worktrees['wt-abc123'], agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], }); - vi.mocked(killAgent).mockResolvedValue(undefined); - vi.mocked(fs.readFile).mockResolvedValue('original prompt'); vi.mocked(loadConfig).mockResolvedValue({ sessionName: 'ppg', defaultAgent: 'claude', @@ -340,16 +357,22 @@ describe('POST /api/agents/:id/restart', () => { command: 'claude', interactive: true, }); - vi.mocked(tmux.ensureSession).mockResolvedValue(undefined); - vi.mocked(tmux.createWindow).mockResolvedValue('ppg:2'); - vi.mocked(spawnAgent).mockResolvedValue(makeAgent({ - id: 'ag-new12345', + vi.mocked(restartAgent).mockResolvedValue({ + oldAgentId: 'ag-test1234', + newAgentId: 'ag-new12345', tmuxTarget: 'ppg:2', - })); - vi.mocked(updateManifest).mockImplementation(async (_root, updater) => { - const m = makeManifest(); - return updater(m); + sessionId: 'session-uuid-123', + worktreeId: 'wt-abc123', + worktreeName: 'feature-auth', + branch: 'ppg/feature-auth', + path: '/tmp/project/.worktrees/wt-abc123', }); + return manifest; + } + + test('restarts a running agent with original prompt', async () => { + setupRestartMocks(); + vi.mocked(fs.readFile).mockResolvedValue('original prompt'); const app = await buildApp(); const res = await app.inject({ @@ -363,18 +386,36 @@ describe('POST /api/agents/:id/restart', () => { expect(body.success).toBe(true); expect(body.oldAgentId).toBe('ag-test1234'); expect(body.newAgent.id).toBe('ag-new12345'); - expect(killAgent).toHaveBeenCalled(); - expect(spawnAgent).toHaveBeenCalled(); + expect(restartAgent).toHaveBeenCalled(); }); test('uses prompt override when provided', async () => { - const manifest = makeManifest(); + setupRestartMocks(); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/restart', + payload: { prompt: 'new task' }, + }); + + expect(res.statusCode).toBe(200); + expect(fs.readFile).not.toHaveBeenCalled(); + expect(restartAgent).toHaveBeenCalledWith( + expect.objectContaining({ promptText: 'new task' }), + ); + }); + + test('skips kill for non-running agent', async () => { + const idleAgent = makeAgent({ status: 'idle' }); + const manifest = makeManifest({ + worktrees: { 'wt-abc123': makeWorktree({ agents: { 'ag-test1234': idleAgent } }) }, + }); vi.mocked(requireManifest).mockResolvedValue(manifest); vi.mocked(findAgent).mockReturnValue({ worktree: manifest.worktrees['wt-abc123'], - agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + agent: idleAgent, }); - vi.mocked(killAgent).mockResolvedValue(undefined); vi.mocked(loadConfig).mockResolvedValue({ sessionName: 'ppg', defaultAgent: 'claude', @@ -387,30 +428,59 @@ describe('POST /api/agents/:id/restart', () => { command: 'claude', interactive: true, }); - vi.mocked(tmux.ensureSession).mockResolvedValue(undefined); - vi.mocked(tmux.createWindow).mockResolvedValue('ppg:2'); - vi.mocked(spawnAgent).mockResolvedValue(makeAgent({ id: 'ag-new12345', tmuxTarget: 'ppg:2' })); - vi.mocked(updateManifest).mockImplementation(async (_root, updater) => { - const m = makeManifest(); - return updater(m); + vi.mocked(fs.readFile).mockResolvedValue('original prompt'); + vi.mocked(restartAgent).mockResolvedValue({ + oldAgentId: 'ag-test1234', + newAgentId: 'ag-new12345', + tmuxTarget: 'ppg:2', + sessionId: 'session-uuid-123', + worktreeId: 'wt-abc123', + worktreeName: 'feature-auth', + branch: 'ppg/feature-auth', + path: '/tmp/project/.worktrees/wt-abc123', }); const app = await buildApp(); const res = await app.inject({ method: 'POST', url: '/api/agents/ag-test1234/restart', - payload: { prompt: 'new task' }, + payload: {}, }); expect(res.statusCode).toBe(200); - // Should NOT read the old prompt file - expect(fs.readFile).not.toHaveBeenCalled(); - // spawnAgent should receive the override prompt - expect(spawnAgent).toHaveBeenCalledWith( - expect.objectContaining({ prompt: 'new task' }), + // restartAgent handles the kill-or-skip internally + expect(restartAgent).toHaveBeenCalledWith( + expect.objectContaining({ oldAgent: expect.objectContaining({ status: 'idle' }) }), ); }); + test('returns 400 when prompt file missing and no override', async () => { + const manifest = makeManifest(); + vi.mocked(requireManifest).mockResolvedValue(manifest); + vi.mocked(findAgent).mockReturnValue({ + worktree: manifest.worktrees['wt-abc123'], + agent: manifest.worktrees['wt-abc123'].agents['ag-test1234'], + }); + vi.mocked(loadConfig).mockResolvedValue({ + sessionName: 'ppg', + defaultAgent: 'claude', + agents: { claude: { name: 'claude', command: 'claude', interactive: true } }, + envFiles: [], + symlinkNodeModules: true, + }); + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/agents/ag-test1234/restart', + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().code).toBe('PROMPT_NOT_FOUND'); + }); + test('returns 404 for unknown agent', async () => { vi.mocked(requireManifest).mockResolvedValue(makeManifest()); vi.mocked(findAgent).mockReturnValue(undefined); diff --git a/src/server/routes/agents.ts b/src/server/routes/agents.ts index cae8767..8ef30de 100644 --- a/src/server/routes/agents.ts +++ b/src/server/routes/agents.ts @@ -1,27 +1,28 @@ import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; import { requireManifest, findAgent, updateManifest } from '../../core/manifest.js'; -import { killAgent } from '../../core/agent.js'; +import { killAgent, checkAgentStatus, restartAgent } from '../../core/agent.js'; import { loadConfig, resolveAgentConfig } from '../../core/config.js'; -import { spawnAgent } from '../../core/agent.js'; import * as tmux from '../../core/tmux.js'; import { PpgError, AgentNotFoundError } from '../../lib/errors.js'; -import { agentId as genAgentId, sessionId as genSessionId } from '../../lib/id.js'; import { agentPromptFile } from '../../lib/paths.js'; -import { renderTemplate, type TemplateContext } from '../../core/template.js'; import fs from 'node:fs/promises'; export interface AgentRoutesOptions extends FastifyPluginOptions { projectRoot: string; } +const MAX_LINES = 10_000; + function mapErrorToStatus(err: unknown): number { if (err instanceof PpgError) { switch (err.code) { case 'AGENT_NOT_FOUND': return 404; + case 'PANE_NOT_FOUND': return 410; case 'NOT_INITIALIZED': return 503; case 'MANIFEST_LOCK': return 409; case 'TMUX_NOT_FOUND': return 503; case 'INVALID_ARGS': return 400; + case 'PROMPT_NOT_FOUND': return 400; default: return 500; } } @@ -60,7 +61,9 @@ export async function agentRoutes( }, async (request, reply) => { try { const { id } = request.params; - const lines = request.query.lines ? parseInt(request.query.lines, 10) : 200; + const lines = request.query.lines + ? Math.min(parseInt(request.query.lines, 10), MAX_LINES) + : 200; if (isNaN(lines) || lines < 1) { return reply.code(400).send({ error: 'lines must be a positive integer', code: 'INVALID_ARGS' }); @@ -71,7 +74,16 @@ export async function agentRoutes( if (!found) throw new AgentNotFoundError(id); const { agent } = found; - const content = await tmux.capturePane(agent.tmuxTarget, lines); + + let content: string; + try { + content = await tmux.capturePane(agent.tmuxTarget, lines); + } catch { + throw new PpgError( + `Could not capture pane for agent ${id}. Pane may no longer exist.`, + 'PANE_NOT_FOUND', + ); + } return { agentId: agent.id, @@ -164,11 +176,14 @@ export async function agentRoutes( const { agent } = found; - if (agent.status !== 'running') { + // Refresh live status from tmux (manifest may be stale in long-lived server) + const { status: liveStatus } = await checkAgentStatus(agent, projectRoot); + + if (liveStatus !== 'running') { return { success: true, agentId: agent.id, - message: `Agent already ${agent.status}`, + message: `Agent already ${liveStatus}`, }; } @@ -225,11 +240,6 @@ export async function agentRoutes( const { worktree: wt, agent: oldAgent } = found; - // Kill old agent if still running - if (oldAgent.status === 'running') { - await killAgent(oldAgent); - } - // Read original prompt or use override let promptText: string; if (promptOverride) { @@ -248,57 +258,27 @@ export async function agentRoutes( const agentConfig = resolveAgentConfig(config, agentType ?? oldAgent.agentType); - await tmux.ensureSession(manifest.sessionName); - const newAgentId = genAgentId(); - const windowTarget = await tmux.createWindow(manifest.sessionName, `${wt.name}-restart`, wt.path); - - // Render template vars - const ctx: TemplateContext = { - WORKTREE_PATH: wt.path, - BRANCH: wt.branch, - AGENT_ID: newAgentId, - PROJECT_ROOT: projectRoot, - TASK_NAME: wt.name, - PROMPT: promptText, - }; - const renderedPrompt = renderTemplate(promptText, ctx); - - const newSessionId = genSessionId(); - const agentEntry = await spawnAgent({ - agentId: newAgentId, - agentConfig, - prompt: renderedPrompt, - worktreePath: wt.path, - tmuxTarget: windowTarget, + const result = await restartAgent({ projectRoot, - branch: wt.branch, - sessionId: newSessionId, - }); - - // Update manifest: mark old agent as gone, add new agent - await updateManifest(projectRoot, (m) => { - const mWt = m.worktrees[wt.id]; - if (mWt) { - const mOldAgent = mWt.agents[oldAgent.id]; - if (mOldAgent && mOldAgent.status === 'running') { - mOldAgent.status = 'gone'; - } - mWt.agents[newAgentId] = agentEntry; - } - return m; + agentId: oldAgent.id, + worktree: wt, + oldAgent, + sessionName: manifest.sessionName, + agentConfig, + promptText, }); return { success: true, - oldAgentId: oldAgent.id, + oldAgentId: result.oldAgentId, newAgent: { - id: newAgentId, - tmuxTarget: windowTarget, - sessionId: newSessionId, - worktreeId: wt.id, - worktreeName: wt.name, - branch: wt.branch, - path: wt.path, + id: result.newAgentId, + tmuxTarget: result.tmuxTarget, + sessionId: result.sessionId, + worktreeId: result.worktreeId, + worktreeName: result.worktreeName, + branch: result.branch, + path: result.path, }, }; } catch (err) { From c3b197d5013e28a39654348c621338c2430bbf5d Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Fri, 27 Feb 2026 08:35:28 -0600 Subject: [PATCH 3/3] Fix spawn test manifest typing for strict typecheck --- src/commands/spawn.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/commands/spawn.test.ts b/src/commands/spawn.test.ts index ee642c7..12ecbc8 100644 --- a/src/commands/spawn.test.ts +++ b/src/commands/spawn.test.ts @@ -7,6 +7,7 @@ import { spawnAgent } from '../core/agent.js'; import { getRepoRoot } from '../core/worktree.js'; import { agentId, sessionId } from '../lib/id.js'; import * as tmux from '../core/tmux.js'; +import type { Manifest } from '../types/manifest.js'; vi.mock('node:fs/promises', async () => { const actual = await vi.importActual('node:fs/promises'); @@ -79,7 +80,7 @@ const mockedEnsureSession = vi.mocked(tmux.ensureSession); const mockedCreateWindow = vi.mocked(tmux.createWindow); const mockedSplitPane = vi.mocked(tmux.splitPane); -function createManifest(tmuxWindow = '') { +function createManifest(tmuxWindow = ''): Manifest { return { version: 1 as const, projectRoot: '/tmp/repo', @@ -103,7 +104,7 @@ function createManifest(tmuxWindow = '') { } describe('spawnCommand', () => { - let manifestState = createManifest(); + let manifestState: Manifest = createManifest(); let nextAgent = 1; let nextSession = 1;