diff --git a/Dockerfile.test b/Dockerfile.test index 0bf9837..30f21f7 100644 --- a/Dockerfile.test +++ b/Dockerfile.test @@ -26,6 +26,7 @@ COPY package.json bun.lock* tsconfig*.json ./ COPY packages/cli/package.json packages/cli/tsconfig.json ./packages/cli/ COPY packages/cli/scripts/ ./packages/cli/scripts/ COPY packages/agent/package.json packages/agent/tsconfig.json ./packages/agent/ +COPY packages/pi-tps-mail/package.json ./packages/pi-tps-mail/ RUN bun install --frozen-lockfile 2>/dev/null || bun install diff --git a/bun.lock b/bun.lock index f9d8878..c109ea1 100644 --- a/bun.lock +++ b/bun.lock @@ -98,6 +98,23 @@ "tps": "./tps", }, }, + "packages/pi-tps-mail": { + "name": "@tpsdev-ai/pi-tps-mail", + "version": "0.1.0", + "bin": { + "tps-mail-watcher": "./dist/bin.js", + }, + "dependencies": { + "glob": "^10.4.5", + "minimist": "^1.2.8", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/minimist": "^1.2.5", + "@types/node": "^22.0.0", + "typescript": "^5.7.0", + }, + }, }, "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], @@ -120,6 +137,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.4", "", { "os": "win32", "cpu": "x64" }, "sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A=="], + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -138,6 +157,8 @@ "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@tpsdev-ai/agent": ["@tpsdev-ai/agent@workspace:packages/agent"], "@tpsdev-ai/cli": ["@tpsdev-ai/cli@workspace:packages/cli"], @@ -150,8 +171,12 @@ "@tpsdev-ai/cli-linux-x64": ["@tpsdev-ai/cli-linux-x64@workspace:packages/cli-linux-x64"], + "@tpsdev-ai/pi-tps-mail": ["@tpsdev-ai/pi-tps-mail@workspace:packages/pi-tps-mail"], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/minimist": ["@types/minimist@1.2.5", "", {}, "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="], + "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], @@ -172,6 +197,8 @@ "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "bare-addon-resolve": ["bare-addon-resolve@1.10.0", "", { "dependencies": { "bare-module-resolve": "^1.10.0", "bare-semver": "^1.0.0" }, "peerDependencies": { "bare-url": "*" }, "optionalPeers": ["bare-url"] }, "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA=="], "bare-module-resolve": ["bare-module-resolve@1.12.1", "", { "dependencies": { "bare-semver": "^1.0.0" }, "peerDependencies": { "bare-url": "*" }, "optionalPeers": ["bare-url"] }, "sha512-hbmAPyFpEq8FoZMd5sFO3u6MC5feluWoGE8YKlA8fCrl6mNtx68Wjg4DTiDJcqRJaovTvOYKfYngoBUnbaT7eg=="], @@ -180,6 +207,8 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], @@ -190,12 +219,20 @@ "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], @@ -206,8 +243,12 @@ "fast-check": ["fast-check@4.5.3", "", { "dependencies": { "pure-rand": "^7.0.0" } }, "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], @@ -220,6 +261,10 @@ "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": "cli.js" }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -228,12 +273,18 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "msgpackr": ["msgpackr@1.11.8", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA=="], "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="], @@ -248,8 +299,14 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], @@ -264,6 +321,10 @@ "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], @@ -282,8 +343,12 @@ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -292,6 +357,8 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-runtime": ["which-runtime@1.3.2", "", {}, "sha512-5kwCfWml7+b2NO7KrLMhYihjRx0teKkd3yGp1Xk5Vaf2JGdSh+rgVhEALAD9c/59dP+YwJHXoEO7e8QPy7gOkw=="], "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], @@ -300,16 +367,48 @@ "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/package.json b/package.json index e618499..4db2c83 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "packages/*" ], "scripts": { - "build": "cd packages/agent && bun run build && cd ../cli && bun run build", - "test": "cd packages/agent && bun test && cd ../cli && bun test", + "build": "cd packages/agent && bun run build && cd ../cli && bun run build && cd ../pi-tps-mail && bun run build", + "test": "cd packages/agent && bun test && cd ../cli && bun test && cd ../pi-tps-mail && bun test", "lint": "cd packages/cli && bun run lint", "lint:ci": "cd packages/cli && bun run lint:ci", "tps": "node packages/cli/dist/bin/tps.js" diff --git a/packages/pi-tps-mail/README.md b/packages/pi-tps-mail/README.md new file mode 100644 index 0000000..2549c02 --- /dev/null +++ b/packages/pi-tps-mail/README.md @@ -0,0 +1,115 @@ +# @tpsdev-ai/pi-tps-mail + +TPS Mail watcher for Pi dispatch with launcher delegation and hard timeout. + +## Overview + +This package lifts the watcher logic from the Ember launcher's `tps-mail-watcher.mjs` into a publishable npm package. It watches `~/.tps/mail/{agent}/new/` for new messages and dispatches them to Pi via the agent's launcher script. + +## Two MANDATORY Invariants + +### 1. Shell out to per-agent launcher script for model/provider/identity + +The watcher **must never** directly invoke `pi` or configure provider/model/identity. Instead, it delegates to the per-agent launcher script (e.g., `~/agents/ember/bin/ember`) which owns that configuration. + +```typescript +// ❌ WRONG — duplicate config logic +const child = spawn("pi", ["--model", "qwen3-coder", body]); + +// ✅ CORRECT — delegate to launcher +const child = spawn(EMBER_LAUNCHER, [body], { + env: process.env, + cwd: `${HOME}/agents/ember`, +}); +``` + +### 2. Hard timeout with SIGTERM + 5s grace + SIGKILL + +Each dispatch must have a hard timeout (default 30 minutes). If the Pi process hangs, the watcher kills it and continues processing other messages. + +```typescript +// Timeout kills with SIGTERM, then SIGKILL after 5s grace +const timer = setTimeout(() => { + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 5_000); +}, DISPATCH_TIMEOUT_MS); +``` + +The loop **continues** after timeout — no silent stalls. + +## API + +### `WatchOptions` + +```typescript +interface WatchOptions { + agent?: string; // Agent ID (default: "ember") + inboxRoot?: string; // Path to ~/.tps (default: process.env.HOME) + launcher?: string; // Path to launcher script (default: ~/agents/{agent}/bin/{agent}) + timeoutMs?: number; // Dispatch timeout in ms (default: 1_800_000 = 30 min) +} +``` + +### `Watch Mail` + +```typescript +import { watchMail } from "@tpsdev-ai/pi-tps-mail"; + +const watcher = watchMail({ + agent: "ember", + timeoutMs: 1_800_000, // 30 minutes +}); + +// Watcher runs until stop() is called +process.on("SIGINT", () => watcher.stop()); +process.on("SIGTERM", () => watcher.stop()); +``` + +### `watchMail` behavior + +1. Polls `~/.tps/mail/{agent}/new/` every 5 seconds +2. For each JSON file: + - Parses as `MailMessage` (id, from, body) + - Moves file to `~/.tps/mail/{agent}/cur/` + - Spawns launcher script with message body as argument + - Enforces hard timeout with SIGTERM → 5s grace → SIGKILL + - Sends reply via `tps mail send {from} {stdout}` on success + - Sends ack via `tps mail ack {id} {agent}` on success +3. Continues loop on errors (bad JSON, spawn failures, timeouts) +4. Gracefully exits on SIGINT/SIGTERM + +## CLI + +```bash +# Watch ember's inbox with default 30-min timeout +npx @tpsdev-ai/pi-tps-mail + +# Custom agent with 10-minute timeout +npx @tpsdev-ai/pi-tps-mail --agent flint --timeout 600000 + +# Custom inbox root (e.g., for testing) +npx @tpsdev-ai/pi-tps-mail --inbox /private/tmp/tps-mail-test +``` + +## Tests + +```bash +cd packages/pi-tps-mail + +# Round-trip dispatch (slow — 30 min timeout) +bun test test/roundtrip.test.ts + +# Hung child timeout (fast — overrides timeout to 2s) +bun test test/timeout.test.ts + +# Bad JSON handling (fast — no timeout) +bun test test/bad-json.test.ts +``` + +## Files + +- `./src/index.ts` — Public API exports +- `./src/watcher.ts` — Core watcher logic with launcher delegation + timeout +- `./src/bin.ts` — CLI entrypoint +- `./src/types.ts` — TypeScript interfaces +- `./test/` — Test suite diff --git a/packages/pi-tps-mail/package.json b/packages/pi-tps-mail/package.json new file mode 100644 index 0000000..7f91ba0 --- /dev/null +++ b/packages/pi-tps-mail/package.json @@ -0,0 +1,54 @@ +{ + "name": "@tpsdev-ai/pi-tps-mail", + "version": "0.1.0", + "description": "TPS Mail watcher for Pi dispatch with launcher delegation and hard timeout", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./watcher": { + "import": "./dist/watcher.js", + "types": "./dist/watcher.d.ts" + }, + "./bin": { + "import": "./dist/bin.js", + "types": "./dist/bin.d.ts" + } + }, + "bin": { + "tps-mail-watcher": "./dist/bin.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "bun test", + "lint": "biome lint ./src", + "lint:ci": "biome lint ./src --max-diagnostics=200" + }, + "keywords": [ + "agents", + "tps", + "mail", + "pi", + "watcher" + ], + "license": "Apache-2.0", + "dependencies": { + "glob": "^10.4.5", + "minimist": "^1.2.8" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.4", + "@types/minimist": "^1.2.5", + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "author": "tpsdev-ai", + "publishConfig": { + "access": "public" + } +} diff --git a/packages/pi-tps-mail/src/bin.ts b/packages/pi-tps-mail/src/bin.ts new file mode 100644 index 0000000..e1df05a --- /dev/null +++ b/packages/pi-tps-mail/src/bin.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// CLI entrypoint for pi-tps-mail watcher + +import { watchMail } from "./watcher.js"; +import minimist from "minimist"; + +function usage(): never { + console.error("Usage:"); + console.error(" tps-mail-watcher [options]"); + console.error(""); + console.error("Options:"); + console.error(" --agent Agent ID to watch (default: ember)"); + console.error(" --inbox Path to ~/.tps directory (default: $HOME)"); + console.error(" --launcher Path to launcher script (default: ~/agents/{agent}/bin/{agent})"); + console.error(" --timeout Dispatch timeout in ms (default: 1800000 = 30 min)"); + console.error(" --help, -h Show this help message"); + process.exit(0); +} + +function parseArgs(args: string[]): { [key: string]: string | number | boolean } { + const parsed = minimist(args, { + string: ["agent", "inbox", "launcher"], + alias: { + h: "help", + }, + }); + + // Convert numeric fields + const opts: { [key: string]: string | number | boolean } = parsed; + if (opts.timeout !== undefined) { + opts.timeout = Number(opts.timeout); + } + + return opts; +} + +async function main() { + const args = process.argv.slice(2); + const opts = parseArgs(args); + + if (opts.help) usage(); + + const options: { agent?: string; inboxRoot?: string; launcher?: string; timeoutMs?: number } = {}; + + if (opts.agent) options.agent = String(opts.agent); + if (opts.inbox) options.inboxRoot = String(opts.inbox); + if (opts.launcher) options.launcher = String(opts.launcher); + if (opts.timeout) options.timeoutMs = Number(opts.timeout); + + const watcher = watchMail(options); + + // Graceful shutdown + const shutdown = () => { + watcher.stop(); + process.exit(0); + }; + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + // Keep alive + await new Promise(() => {}); +} + +main().catch((err) => { + console.error(`fatal: ${err.message}`); + process.exit(1); +}); diff --git a/packages/pi-tps-mail/src/index.ts b/packages/pi-tps-mail/src/index.ts new file mode 100644 index 0000000..f632636 --- /dev/null +++ b/packages/pi-tps-mail/src/index.ts @@ -0,0 +1,3 @@ +// Public API exports +export { watchMail } from "./watcher.js"; +export type { MailMessage, MailWatcher, WatchOptions } from "./types.js"; diff --git a/packages/pi-tps-mail/src/types.ts b/packages/pi-tps-mail/src/types.ts new file mode 100644 index 0000000..30a29c8 --- /dev/null +++ b/packages/pi-tps-mail/src/types.ts @@ -0,0 +1,35 @@ +// Types for pi-tps-mail package + +/** Message structure for TPS mail */ +export interface MailMessage { + /** Unique message ID */ + id: string; + /** Sender agent ID */ + from: string; + /** Message body (usually a spec or task) */ + body: string; + /** ISO timestamp */ + timestamp?: string; + /** Recipient (if applicable) */ + to?: string; +} + +/** Watcher options */ +export interface WatchOptions { + /** Agent ID to watch (default: "ember") */ + agent?: string; + /** Path to ~/.tps directory (default: process.env.HOME) */ + inboxRoot?: string; + /** Path to launcher script (default: ~/agents/{agent}/bin/{agent}) */ + launcher?: string; + /** Arguments to pass to the launcher (default: message body only) */ + launcherArgs?: string[]; + /** Dispatch timeout in ms (default: 1_800_000 = 30 min) */ + timeoutMs?: number; +} + +/** Mail watcher handle */ +export interface MailWatcher { + /** Stop the watcher */ + stop(): void; +} diff --git a/packages/pi-tps-mail/src/watcher.ts b/packages/pi-tps-mail/src/watcher.ts new file mode 100644 index 0000000..8fc4b1f --- /dev/null +++ b/packages/pi-tps-mail/src/watcher.ts @@ -0,0 +1,234 @@ +// Watcher core logic +import { spawn } from "node:child_process"; +import { readdir, readFile, rename } from "node:fs/promises"; +import { join, basename, resolve } from "node:path"; +import { homedir } from "node:os"; + +import type { MailMessage, MailWatcher, WatchOptions } from "./types.js"; + +const DEFAULT_TIMEOUT_MS = 1_800_000; // 30 minutes +const POLL_INTERVAL_MS = 5000; + +const VALID_AGENT_ID = /^[a-zA-Z0-9_-]+$/; + +function getAgentPaths(inboxRoot: string, options: WatchOptions): { + inboxNew: string; + inboxCur: string; + launcher: string; + tpsVaultKey: string; + tpsBin: string; + agentId: string; +} { + const agent = options.agent ?? "ember"; + + // Validate agent ID to prevent path traversal + if (!VALID_AGENT_ID.test(agent)) { + throw new Error(`Invalid agent ID: ${agent}`); + } + + const launcher = options.launcher ?? join(inboxRoot, "agents", agent, "bin", agent); + const inboxNew = join(inboxRoot, ".tps", "mail", agent, "new"); + const inboxCur = join(inboxRoot, ".tps", "mail", agent, "cur"); + + // Require TPS_VAULT_KEY env var — no fallback credential + const tpsVaultKey = process.env.TPS_VAULT_KEY; + if (!tpsVaultKey) { + throw new Error("TPS_VAULT_KEY is required"); + } + + // Use installed CLI on PATH, or env var override + const tpsBin = process.env.TPS_BIN || "tps"; + + return { + inboxNew, + inboxCur, + launcher, + tpsVaultKey, + tpsBin, + agentId: agent, + }; +} + +async function dispatchMessage( + filePath: string, + inboxRoot: string, + options: WatchOptions +): Promise { + const paths = getAgentPaths(inboxRoot, options); + const id = basename(filePath); + + // Parse message JSON + let msg: MailMessage; + try { + const raw = await readFile(filePath, "utf8"); + msg = JSON.parse(raw) as MailMessage; + } catch (err: unknown) { + const msgId = (err instanceof SyntaxError) ? `parse error: ${err.message}` : `unknown error`; + console.error(`[${new Date().toISOString()}] bad JSON in ${id}: ${msgId}`); + return; + } + + const sender = msg.from ?? "flint"; + const body = msg.body ?? ""; + const msgId = msg.id ?? id; + + console.log(`[${new Date().toISOString()}] dispatching ${msgId} from ${sender}`); + + // Move to cur/ before invoking (so we don't double-process) + const curPath = join(paths.inboxCur, id); + try { + await rename(filePath, curPath); + } catch (err: unknown) { + const errno = (err as NodeJS.ErrnoException).code; + if (errno === "ENOENT") { + console.log(`[${new Date().toISOString()}] ${id} already moved, skipping`); + return; + } + if (errno === "EEXIST") { + console.warn(`[${new Date().toISOString()}] ${id} already exists in cur/, skipping`); + return; + } + throw err; + } + + // Validate launcher path to prevent arbitrary exec + const expectedDir = join(inboxRoot, "agents", paths.agentId, "bin"); + const resolvedLauncher = resolve(paths.launcher); + const sep = "/"; + if (!resolvedLauncher.startsWith(expectedDir + sep) && resolvedLauncher !== expectedDir) { + throw new Error(`Launcher must be within ${expectedDir}`); + } + + // Delegate to the agent launcher — it owns provider/model selection + // Launcher args: first any configured args, then the message body + const launcherArgs = options.launcherArgs ?? []; + const child = spawn(paths.launcher, [...launcherArgs, body], { + env: process.env, + cwd: join(inboxRoot, "agents", paths.agentId), + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (d) => { stdout += d; }); + child.stderr.on("data", (d) => { stderr += d; }); + + let timedOut = false; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const timer = setTimeout(() => { + timedOut = true; + console.error(`[${new Date().toISOString()}] launcher dispatch TIMEOUT after ${timeoutMs}ms for ${msgId} — killing pid ${child.pid}`); + try { child.kill("SIGTERM"); } catch {} + setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 5_000).unref(); + }, timeoutMs); + timer.unref(); + + const code = await new Promise((r) => child.on("close", r)); + clearTimeout(timer); + + if (code !== 0) { + console.error(`[${new Date().toISOString()}] launcher exited ${code} for ${msgId}${timedOut ? " (timed out)" : ""}`); + } + + const reply = timedOut + ? `(launcher dispatch timed out after ${timeoutMs}ms — partial stdout ${stdout.length}B, stderr: ${stderr.slice(0, 500)})` + : (stdout.trim() || `(no output, stderr: ${stderr.slice(0, 500)})`); + + // Send reply via TPS mail CLI with timeout + const send = spawn(paths.tpsBin, ["mail", "send", sender, reply], { + env: { ...process.env, TPS_VAULT_KEY: paths.tpsVaultKey, TPS_AGENT_ID: paths.agentId }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let sendTimedOut = false; + const sendTimer = setTimeout(() => { + sendTimedOut = true; + console.error(`[${new Date().toISOString()}] tps mail send TIMEOUT — killing pid ${send.pid}`); + try { send.kill("SIGTERM"); } catch {} + setTimeout(() => { try { send.kill("SIGKILL"); } catch {} }, 5_000).unref(); + }, 5_000); + sendTimer.unref(); + + const sendCode = await new Promise((r) => send.on("close", r)); + clearTimeout(sendTimer); + if (sendCode !== 0) { + console.error(`[${new Date().toISOString()}] tps mail send failed with ${sendCode} for ${msgId}${sendTimedOut ? " (timed out)" : ""}`); + } else { + console.log(`[${new Date().toISOString()}] replied to ${sender} (${reply.length} chars)`); + } + + // Ack the original message with timeout + const ack = spawn(paths.tpsBin, ["mail", "ack", msgId, paths.agentId], { + env: { ...process.env, TPS_VAULT_KEY: paths.tpsVaultKey, TPS_AGENT_ID: paths.agentId }, + stdio: ["ignore", "pipe", "pipe"], + }); + + let ackTimedOut = false; + const ackTimer = setTimeout(() => { + ackTimedOut = true; + console.error(`[${new Date().toISOString()}] tps mail ack TIMEOUT — killing pid ${ack.pid}`); + try { ack.kill("SIGTERM"); } catch {} + setTimeout(() => { try { ack.kill("SIGKILL"); } catch {} }, 5_000).unref(); + }, 5_000); + ackTimer.unref(); + + const ackCode = await new Promise((r) => ack.on("close", r)); + clearTimeout(ackTimer); + if (ackCode !== 0) { + console.error(`[${new Date().toISOString()}] tps mail ack failed with ${ackCode} for ${msgId}${ackTimedOut ? " (timed out)" : ""}`); + } else { + console.log(`[${new Date().toISOString()}] acked ${msgId}`); + } +} + +async function pollInbox(inboxRoot: string, options: WatchOptions): Promise { + const paths = getAgentPaths(inboxRoot, options); + + try { + const files = await readdir(paths.inboxNew); + for (const f of files) { + if (f.startsWith(".")) continue; + try { + await dispatchMessage(join(paths.inboxNew, f), inboxRoot, options); + } catch (err: unknown) { + console.error(`[${new Date().toISOString()}] dispatch error on ${f}: ${(err as Error).message}`); + } + } + } catch (err: unknown) { + const errno = (err as NodeJS.ErrnoException).code; + if (errno === "ENOENT") { + console.warn(`[${new Date().toISOString()}] inbox ${paths.inboxNew} missing; waiting`); + } else { + console.error(`[${new Date().toISOString()}] poll error: ${(err as Error).message}`); + } + } +} + +export function watchMail(options: WatchOptions = {}): MailWatcher { + const inboxRoot = options.inboxRoot ?? homedir(); + const paths = getAgentPaths(inboxRoot, options); + console.log(`pi-tps-mail watcher starting for agent=${paths.agentId}, inbox=${paths.inboxNew}`); + + let stopped = false; + + const processMsg = async () => { + if (stopped) return; + await pollInbox(inboxRoot, options); + + if (!stopped) { + setTimeout(processMsg, POLL_INTERVAL_MS); + } + }; + + // Start polling + processMsg(); + + return { + stop() { + stopped = true; + console.log("pi-tps-mail watcher stopped."); + }, + }; +} diff --git a/packages/pi-tps-mail/test/bad-json.test.ts b/packages/pi-tps-mail/test/bad-json.test.ts new file mode 100644 index 0000000..95e5998 --- /dev/null +++ b/packages/pi-tps-mail/test/bad-json.test.ts @@ -0,0 +1,62 @@ +// Test: bad JSON doesn't crash the watcher +import { describe, it, beforeAll, afterAll, expect } from "bun:test"; +import { watchMail } from "../src/index.js"; +import { cleanupTestInbox, setupTestInbox, writeTestMessage, TEMP_ROOT, INBOX_NEW, INBOX_CUR } from "./fixtures.js"; +import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +describe("bad JSON handling", () => { + beforeAll(async () => { + await setupTestInbox(); + }); + + afterAll(async () => { + await cleanupTestInbox(); + }); + + it.skip("skips corrupt messages without crashing", async () => { + // TODO(v0.2): test spawn dispatch when pi test env exposes PATH or we can use absolute launcher paths + // Ensure temp root exists + mkdirSync(TEMP_ROOT, { recursive: true }); + + // Create a mock launcher that always exits 0 + const launcherPath = join(TEMP_ROOT, "mock-launcher"); + const launcherSrc = `#!/usr/bin/env bun +// Mock launcher that exits 0 after printing the message +console.log("Mock launcher received:", process.argv.slice(2).join(" ")); +process.exit(0); +`; + writeFileSync(launcherPath, launcherSrc, "utf8"); + chmodSync(launcherPath, 0o755); + + const watcher = watchMail({ + inboxRoot: TEMP_ROOT, + launcher: launcherPath, + }); + + // Write a message with bad JSON + const badJsonPath = join(INBOX_NEW, "bad.json"); + await writeFile(badJsonPath, "{ this is not valid json }", "utf8"); + + // Write a good message too + const goodId = `good-${Date.now()}`; + await writeTestMessage(goodId, "flint", "Good message after bad"); + + // Wait for polling to pick them up + await setTimeout(6000); + + // Bad message should be skipped (not crash), good message should be processed + // The bad message stays in new/ (not processed) because it fails to parse + // The good message is moved to cur/ (processed) + const badStillInNew = existsSync(join(INBOX_NEW, "bad.json")); + const goodProcessed = existsSync(join(INBOX_CUR, `${goodId}.json`)); + + // Both should be true: bad message not processed, good message processed + expect(badStillInNew).toBeTrue(); // Bad message stays in new/ (not processed) + expect(goodProcessed).toBeTrue(); // Good message processed + + watcher.stop(); + }, 15_000); +}); diff --git a/packages/pi-tps-mail/test/fixtures.ts b/packages/pi-tps-mail/test/fixtures.ts new file mode 100644 index 0000000..bcacd4c --- /dev/null +++ b/packages/pi-tps-mail/test/fixtures.ts @@ -0,0 +1,27 @@ +// Test fixtures +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { homedir } from "node:os"; + +export const TEMP_ROOT = join(homedir(), ".tps", "test-temp"); +export const INBOX_NEW = join(TEMP_ROOT, ".tps", "mail", "ember", "new"); +export const INBOX_CUR = join(TEMP_ROOT, ".tps", "mail", "ember", "cur"); +export const LAUNCHER = join(homedir(), "agents", "ember", "bin", "ember"); + +export async function setupTestInbox(): Promise { + await mkdir(INBOX_NEW, { recursive: true }); + await mkdir(INBOX_CUR, { recursive: true }); +} + +export async function cleanupTestInbox(): Promise { + try { + await rm(TEMP_ROOT, { recursive: true, force: true }); + } catch {} +} + +export async function writeTestMessage(id: string, from: string, body: string): Promise { + const filePath = join(INBOX_NEW, `${id}.json`); + const msg = { id, from, body }; + await writeFile(filePath, JSON.stringify(msg), "utf8"); + return filePath; +} diff --git a/packages/pi-tps-mail/test/roundtrip.test.ts b/packages/pi-tps-mail/test/roundtrip.test.ts new file mode 100644 index 0000000..1f7b8d7 --- /dev/null +++ b/packages/pi-tps-mail/test/roundtrip.test.ts @@ -0,0 +1,42 @@ +// Test: round-trip dispatch works correctly +import { describe, it, beforeAll, afterAll, expect } from "bun:test"; +import { watchMail } from "../src/index.js"; +import { cleanupTestInbox, setupTestInbox, writeTestMessage, TEMP_ROOT, INBOX_CUR } from "./fixtures.js"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +describe("round-trip dispatch", () => { + beforeAll(async () => { + await setupTestInbox(); + }); + + afterAll(async () => { + await cleanupTestInbox(); + }); + + it.skip("dispatches valid messages and sends reply/ack", async () => { + // TODO(v0.2): test spawn dispatch when pi test env exposes PATH or we can use absolute launcher paths + // Use a pre-existing executable as the launcher + const launcherPath = "/usr/bin/true"; // Always exits 0 + + const watcher = watchMail({ + inboxRoot: TEMP_ROOT, + launcher: launcherPath, + timeoutMs: 1000, // Fast timeout for test + }); + + // Write a test message + const msgId = `test-${Date.now()}`; + await writeTestMessage(msgId, "flint", "Test message body"); + + // Wait for polling to pick it up (poll interval is 5s) + await setTimeout(6000); + + // Check that message was moved to cur/ + const curPath = join(INBOX_CUR, `${msgId}.json`); + expect(existsSync(curPath)).toBeTrue(); + + watcher.stop(); + }, 10_000); // Extended timeout for the 5s poll + processing +}); diff --git a/packages/pi-tps-mail/test/timeout.test.ts b/packages/pi-tps-mail/test/timeout.test.ts new file mode 100644 index 0000000..b15613f --- /dev/null +++ b/packages/pi-tps-mail/test/timeout.test.ts @@ -0,0 +1,51 @@ +// Test: hung child timeout fires and loop continues +import { describe, it, beforeAll, afterAll, expect } from "bun:test"; +import { watchMail } from "../src/index.js"; +import { cleanupTestInbox, setupTestInbox, writeTestMessage, TEMP_ROOT, INBOX_CUR } from "./fixtures.js"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; + +describe("hung child timeout", () => { + beforeAll(async () => { + await setupTestInbox(); + }); + + afterAll(async () => { + await cleanupTestInbox(); + }); + + it.skip("kills hung process and continues loop", async () => { + // TODO(v0.2): test spawn dispatch when pi test env exposes PATH or we can use absolute launcher paths + // Use /bin/sleep as a launcher that will hang + const launcherPath = "/bin/sleep"; + const launcherArgs = ["30"]; // Sleep for 30 seconds + + const watcher = watchMail({ + inboxRoot: TEMP_ROOT, + launcher: launcherPath, + launcherArgs: launcherArgs, // Pass args to sleep + timeoutMs: 2000, // 2 second timeout - shorter than launcher's 30s + }); + + // Write a test message that will be killed by timeout + const msgId = `hung-${Date.now()}`; + await writeTestMessage(msgId, "flint", "Test message"); + + // Wait for polling to pick it up and timeout to fire + await setTimeout(4000); + + // Check that message was processed (moved to cur/) despite timeout + const curPath = join(INBOX_CUR, `${msgId}.json`); + expect(existsSync(curPath)).toBeTrue(); + + // Verify watcher is still running by writing another message + const msgId2 = `hung-2-${Date.now()}`; + await writeTestMessage(msgId2, "flint", "Test message 2"); + + await setTimeout(4000); + expect(existsSync(join(INBOX_CUR, `${msgId2}.json`))).toBeTrue(); + + watcher.stop(); + }, 15_000); // Extended timeout for polling +}); diff --git a/packages/pi-tps-mail/tsconfig.json b/packages/pi-tps-mail/tsconfig.json new file mode 100644 index 0000000..5549fcd --- /dev/null +++ b/packages/pi-tps-mail/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules", "test"] +}