From 56ff8ad447cf794f73e8f3f9ecd3ac6b6138308e Mon Sep 17 00:00:00 2001 From: Shubham Naik Date: Wed, 18 Feb 2026 17:38:48 -0800 Subject: [PATCH] feat: listen mode (#997) Co-authored-by: cpacker --- bun.lock | 8 +- package-lock.json | 88 +++- package.json | 4 +- src/cli/App.tsx | 48 +++ src/cli/commands/listen.ts | 335 ++++++++++++++ src/cli/components/ListenerStatusUI.tsx | 77 ++++ src/cli/subcommands/listen.tsx | 187 ++++++++ src/cli/subcommands/router.ts | 3 + src/websocket/listen-client.ts | 552 ++++++++++++++++++++++++ 9 files changed, 1280 insertions(+), 22 deletions(-) create mode 100644 src/cli/commands/listen.ts create mode 100644 src/cli/components/ListenerStatusUI.tsx create mode 100644 src/cli/subcommands/listen.tsx create mode 100644 src/websocket/listen-client.ts diff --git a/bun.lock b/bun.lock index 46eec91..283a66d 100644 --- a/bun.lock +++ b/bun.lock @@ -10,12 +10,14 @@ "ink-link": "^5.0.0", "open": "^10.2.0", "sharp": "^0.34.5", + "ws": "^8.19.0", }, "devDependencies": { "@types/bun": "^1.3.7", "@types/diff": "^8.0.0", "@types/picomatch": "^4.0.2", "@types/react": "^19.2.9", + "@types/ws": "^8.18.1", "diff": "^8.0.2", "husky": "9.1.7", "ink": "^5.0.0", @@ -103,6 +105,8 @@ "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@vscode/ripgrep": ["@vscode/ripgrep@1.17.0", "", { "dependencies": { "https-proxy-agent": "^7.0.2", "proxy-from-env": "^1.1.0", "yauzl": "^2.9.2" } }, "sha512-mBRKm+ASPkUcw4o9aAgfbusIu6H4Sdhw09bjeP1YOBFTJEZAnrnk6WZwzv8NEjgC82f7ILvhmb1WIElSugea6g=="], "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], @@ -291,7 +295,7 @@ "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=="], - "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "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=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -305,6 +309,8 @@ "glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "ink/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "ink-text-input/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], "listr2/cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], diff --git a/package-lock.json b/package-lock.json index 49b0d90..095e3f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,20 +10,22 @@ "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@letta-ai/letta-client": "^1.7.6", + "@letta-ai/letta-client": "^1.7.8", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", - "sharp": "^0.34.5" + "sharp": "^0.34.5", + "ws": "^8.19.0" }, "bin": { "letta": "letta.js" }, "devDependencies": { - "@types/bun": "*", + "@types/bun": "^1.3.7", "@types/diff": "^8.0.0", "@types/picomatch": "^4.0.2", "@types/react": "^19.2.9", + "@types/ws": "^8.18.1", "diff": "^8.0.2", "husky": "9.1.7", "ink": "^5.0.0", @@ -43,6 +45,7 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -549,19 +552,19 @@ } }, "node_modules/@letta-ai/letta-client": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@letta-ai/letta-client/-/letta-client-1.7.6.tgz", - "integrity": "sha512-C/f03uE3TJdgfHk/8rRBxzWvY0YHCYAlrePHcTd0CRHMo++0TA1OTcgiCF+EFVDVYGzfPSeMpqgAZTNvD9r9GQ==", + "version": "1.7.8", + "resolved": "https://registry.npmjs.org/@letta-ai/letta-client/-/letta-client-1.7.8.tgz", + "integrity": "sha512-l7BGsAZOI8b+H1RO2IMzMv3P0VXj6Of2wXLCg5LZejOk0dHLLBi2me4WspLg+6zWPGIRJwMRsxmfp4kV9Zzh6g==", "license": "Apache-2.0" }, "node_modules/@types/bun": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.6.tgz", - "integrity": "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==", + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.9.tgz", + "integrity": "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw==", "dev": true, "license": "MIT", "dependencies": { - "bun-types": "1.3.6" + "bun-types": "1.3.9" } }, "node_modules/@types/diff": { @@ -576,10 +579,9 @@ } }, "node_modules/@types/node": { - "version": "25.0.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", - "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", - "dev": true, + "version": "25.2.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz", + "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -596,12 +598,22 @@ "version": "19.2.9", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz", "integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "dev": true + }, "node_modules/@vscode/ripgrep": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.17.0.tgz", @@ -644,6 +656,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -656,6 +669,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -668,6 +682,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -700,9 +715,9 @@ } }, "node_modules/bun-types": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.6.tgz", - "integrity": "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ==", + "version": "1.3.9", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.9.tgz", + "integrity": "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg==", "dev": true, "license": "MIT", "dependencies": { @@ -728,6 +743,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -740,6 +756,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -752,6 +769,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^4.0.0" @@ -780,6 +798,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, "license": "MIT", "dependencies": { "slice-ansi": "^5.0.0", @@ -796,6 +815,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.0.0", @@ -812,6 +832,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "dev": true, "license": "MIT", "dependencies": { "convert-to-spaces": "^2.0.1" @@ -841,6 +862,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -850,7 +872,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/debug": { @@ -934,6 +956,7 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, "license": "MIT" }, "node_modules/environment": { @@ -952,6 +975,7 @@ "version": "1.44.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "dev": true, "license": "MIT", "workspaces": [ "docs", @@ -962,6 +986,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1001,6 +1026,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -1072,6 +1098,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1084,6 +1111,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/ink/-/ink-5.2.1.tgz", "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", + "dev": true, "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", @@ -1213,6 +1241,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1225,6 +1254,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-1.0.0.tgz", "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", + "dev": true, "license": "MIT", "bin": { "is-in-ci": "cli.js" @@ -1283,6 +1313,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, "license": "MIT" }, "node_modules/lint-staged": { @@ -1448,6 +1479,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -1483,6 +1515,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1549,6 +1582,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -1582,6 +1616,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -1647,6 +1682,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", "integrity": "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -1659,6 +1695,7 @@ "version": "0.29.2", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.29.2.tgz", "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", @@ -1675,6 +1712,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dev": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -1710,6 +1748,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" @@ -1775,12 +1814,14 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, "license": "ISC" }, "node_modules/slice-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -1797,6 +1838,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.3.1" @@ -1812,6 +1854,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" @@ -1834,6 +1877,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -1851,6 +1895,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -1930,6 +1975,7 @@ "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -1956,13 +2002,13 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/widest-line": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-5.0.0.tgz", "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", + "dev": true, "license": "MIT", "dependencies": { "string-width": "^7.0.0" @@ -1978,6 +2024,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -2058,6 +2105,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "dev": true, "license": "MIT" } } diff --git a/package.json b/package.json index c1f314c..309d6e3 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", - "sharp": "^0.34.5" + "sharp": "^0.34.5", + "ws": "^8.19.0" }, "optionalDependencies": { "@vscode/ripgrep": "^1.17.0" @@ -44,6 +45,7 @@ "@types/diff": "^8.0.0", "@types/picomatch": "^4.0.2", "@types/react": "^19.2.9", + "@types/ws": "^8.18.1", "diff": "^8.0.2", "husky": "9.1.7", "ink": "^5.0.0", diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 33db1bd..9eae01e 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -6223,6 +6223,54 @@ export default function App({ return { submitted: true }; } + // Special handling for /listen command - start listener mode + if (trimmed === "/listen" || trimmed.startsWith("/listen ")) { + // Tokenize with quote support: --name "my laptop" + const parts = Array.from( + trimmed.matchAll( + /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g, + ), + (match) => match[1] ?? match[2] ?? match[3], + ); + + let name: string | undefined; + let listenAgentId: string | undefined; + + for (let i = 1; i < parts.length; i++) { + const part = parts[i]; + const nextPart = parts[i + 1]; + if (part === "--name" && nextPart) { + name = nextPart; + i++; + } else if (part === "--agent" && nextPart) { + listenAgentId = nextPart; + i++; + } + } + + // Default to current agent if not specified + const targetAgentId = listenAgentId || agentId; + + const cmd = commandRunner.start(msg, "Starting listener..."); + const { handleListen, setActiveCommandId: setActiveListenCommandId } = + await import("./commands/listen"); + setActiveListenCommandId(cmd.id); + try { + await handleListen( + { + buffersRef, + refreshDerived, + setCommandRunning, + }, + msg, + { name, agentId: targetAgentId }, + ); + } finally { + setActiveListenCommandId(null); + } + return { submitted: true }; + } + // Special handling for /help command - opens help dialog if (trimmed === "/help") { startOverlayCommand( diff --git a/src/cli/commands/listen.ts b/src/cli/commands/listen.ts new file mode 100644 index 0000000..ed758be --- /dev/null +++ b/src/cli/commands/listen.ts @@ -0,0 +1,335 @@ +/** + * Listen mode - Register letta-code as a listener to receive messages from Letta Cloud + * Usage: letta listen --name "george" + */ + +import { hostname } from "node:os"; +import { getServerUrl } from "../../agent/client"; +import { settingsManager } from "../../settings-manager"; +import { getErrorMessage } from "../../utils/error"; +import type { Buffers, Line } from "../helpers/accumulator"; + +// tiny helper for unique ids +function uid(prefix: string) { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +// Helper type for command result +type CommandLine = Extract; + +let activeCommandId: string | null = null; + +export function setActiveCommandId(id: string | null): void { + activeCommandId = id; +} + +// Context passed to listen handler +export interface ListenCommandContext { + buffersRef: { current: Buffers }; + refreshDerived: () => void; + setCommandRunning: (running: boolean) => void; +} + +// Helper to add a command result to buffers +function addCommandResult( + buffersRef: { current: Buffers }, + refreshDerived: () => void, + input: string, + output: string, + success: boolean, + phase: "running" | "finished" = "finished", +): string { + const cmdId = activeCommandId ?? uid("cmd"); + const existing = buffersRef.current.byId.get(cmdId); + const nextInput = + existing && existing.kind === "command" ? existing.input : input; + const line: CommandLine = { + kind: "command", + id: cmdId, + input: nextInput, + output, + phase, + ...(phase === "finished" && { success }), + }; + buffersRef.current.byId.set(cmdId, line); + if (!buffersRef.current.order.includes(cmdId)) { + buffersRef.current.order.push(cmdId); + } + refreshDerived(); + return cmdId; +} + +// Helper to update an existing command result +function updateCommandResult( + buffersRef: { current: Buffers }, + refreshDerived: () => void, + cmdId: string, + input: string, + output: string, + success: boolean, + phase: "running" | "finished" = "finished", +): void { + const existing = buffersRef.current.byId.get(cmdId); + const nextInput = + existing && existing.kind === "command" ? existing.input : input; + const line: CommandLine = { + kind: "command", + id: cmdId, + input: nextInput, + output, + phase, + ...(phase === "finished" && { success }), + }; + buffersRef.current.byId.set(cmdId, line); + refreshDerived(); +} + +interface ListenOptions { + name?: string; + agentId?: string; +} + +/** + * Handle /listen command + * Usage: /listen --name "george" [--agent agent-xyz] + */ +export async function handleListen( + ctx: ListenCommandContext, + msg: string, + opts: ListenOptions = {}, +): Promise { + // Show usage if needed + if (msg.includes("--help") || msg.includes("-h")) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Usage: /listen --name [--agent ]\n\n" + + "Register this letta-code instance to receive messages from Letta Cloud.\n\n" + + "Options:\n" + + " --name Friendly name for this connection (required)\n" + + " --agent Bind connection to specific agent (defaults to current agent)\n\n" + + "Examples:\n" + + ' /listen --name "george" # Uses current agent\n' + + ' /listen --name "laptop-work" --agent agent-abc123\n\n' + + "Once connected, this instance will listen for incoming messages from cloud agents.\n" + + "Messages will be executed locally using your letta-code environment.", + true, + ); + return; + } + + // Validate required parameters + const connectionName = opts.name; + const agentId = opts.agentId; + + if (!connectionName) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Error: --name is required\n\n" + + 'Usage: /listen --name "george"\n\n' + + "Provide a friendly name to identify this connection (e.g., your name, device name).", + false, + ); + return; + } + + if (!agentId) { + addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Error: No agent specified\n\n" + + "This connection needs a default agent to execute messages.\n" + + "If you're seeing this, it means no agent is active in this conversation.\n\n" + + "Please start a conversation with an agent first, or specify one explicitly:\n" + + ' /listen --name "george" --agent agent-abc123', + false, + ); + return; + } + + // Start listen flow + ctx.setCommandRunning(true); + + const cmdId = addCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + msg, + "Connecting to Letta Cloud...", + true, + "running", + ); + + try { + // Get device ID (stable across sessions) + const deviceId = settingsManager.getOrCreateDeviceId(); + const deviceName = hostname(); + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Registering listener "${connectionName}"...\n` + + `Device: ${deviceName} (${deviceId.slice(0, 8)}...)`, + true, + "running", + ); + + // Register with cloud to get connectionId + const serverUrl = getServerUrl(); + const settings = await settingsManager.getSettingsWithSecureTokens(); + const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY; + + if (!apiKey) { + throw new Error("Missing LETTA_API_KEY"); + } + + // Call register endpoint + const registerUrl = `${serverUrl}/v1/listeners/register`; + const registerResponse = await fetch(registerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Letta-Source": "letta-code", + }, + body: JSON.stringify({ + deviceId, + connectionName, + agentId: opts.agentId, + }), + }); + + if (!registerResponse.ok) { + const error = (await registerResponse.json()) as { message?: string }; + throw new Error(error.message || "Registration failed"); + } + + const { connectionId, wsUrl } = (await registerResponse.json()) as { + connectionId: string; + wsUrl: string; + }; + + // Build agent info message + const adeUrl = `https://app.letta.com/agents/${agentId}`; + const agentInfo = `Agent: ${agentId}\n→ ${adeUrl}\n\n`; + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `✓ Registered successfully!\n\n` + + `Connection ID: ${connectionId}\n` + + `Name: "${connectionName}"\n` + + agentInfo + + `WebSocket: ${wsUrl}\n\n` + + `Starting WebSocket connection...`, + true, + "running", + ); + + // Import and start WebSocket client + const { startListenerClient } = await import( + "../../websocket/listen-client" + ); + + await startListenerClient({ + connectionId, + wsUrl, + deviceId, + connectionName, + agentId, + onStatusChange: (status, connId) => { + const adeUrl = `https://app.letta.com/agents/${agentId}?deviceId=${connId}`; + const statusText = + status === "receiving" + ? "Receiving message" + : status === "processing" + ? "Processing message" + : "Awaiting instructions"; + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Connected to Letta Cloud\n` + + `${statusText}\n\n` + + `View in ADE → ${adeUrl}`, + true, + "finished", + ); + }, + onRetrying: (attempt, _maxAttempts, nextRetryIn) => { + const adeUrl = `https://app.letta.com/agents/${agentId}?deviceId=${connectionId}`; + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Reconnecting to Letta Cloud...\n` + + `Attempt ${attempt}, retrying in ${Math.round(nextRetryIn / 1000)}s\n\n` + + `View in ADE → ${adeUrl}`, + true, + "running", + ); + }, + onConnected: () => { + const adeUrl = `https://app.letta.com/agents/${agentId}?deviceId=${connectionId}`; + + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `Connected to Letta Cloud\n` + + `Awaiting instructions\n\n` + + `View in ADE → ${adeUrl}`, + true, + "finished", + ); + ctx.setCommandRunning(false); + }, + onDisconnected: () => { + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `✗ Listener disconnected\n\n` + `Connection to Letta Cloud was lost.`, + false, + "finished", + ); + ctx.setCommandRunning(false); + }, + onError: (error: Error) => { + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `✗ Listener error: ${getErrorMessage(error)}`, + false, + "finished", + ); + ctx.setCommandRunning(false); + }, + }); + } catch (error) { + updateCommandResult( + ctx.buffersRef, + ctx.refreshDerived, + cmdId, + msg, + `✗ Failed to start listener: ${getErrorMessage(error)}`, + false, + "finished", + ); + ctx.setCommandRunning(false); + } +} diff --git a/src/cli/components/ListenerStatusUI.tsx b/src/cli/components/ListenerStatusUI.tsx new file mode 100644 index 0000000..e87c191 --- /dev/null +++ b/src/cli/components/ListenerStatusUI.tsx @@ -0,0 +1,77 @@ +import { Box, Text } from "ink"; +import Spinner from "ink-spinner"; +import { useEffect, useState } from "react"; + +interface ListenerStatusUIProps { + agentId: string; + connectionId: string; + onReady: (callbacks: { + updateStatus: (status: "idle" | "receiving" | "processing") => void; + updateRetryStatus: (attempt: number, nextRetryIn: number) => void; + clearRetryStatus: () => void; + }) => void; +} + +export function ListenerStatusUI(props: ListenerStatusUIProps) { + const { agentId, connectionId, onReady } = props; + const [status, setStatus] = useState<"idle" | "receiving" | "processing">( + "idle", + ); + const [retryInfo, setRetryInfo] = useState<{ + attempt: number; + nextRetryIn: number; + } | null>(null); + + useEffect(() => { + onReady({ + updateStatus: setStatus, + updateRetryStatus: (attempt, nextRetryIn) => { + setRetryInfo({ attempt, nextRetryIn }); + }, + clearRetryStatus: () => { + setRetryInfo(null); + }, + }); + }, [onReady]); + + const adeUrl = `https://app.letta.com/agents/${agentId}?deviceId=${connectionId}`; + + const statusText = retryInfo + ? `Reconnecting (attempt ${retryInfo.attempt}, retry in ${Math.round(retryInfo.nextRetryIn / 1000)}s)` + : status === "receiving" + ? "Receiving message" + : status === "processing" + ? "Processing message" + : "Awaiting instructions"; + + const showSpinner = status !== "idle" || retryInfo !== null; + + return ( + + + + Connected to Letta Cloud + + + + + {showSpinner && ( + + + + {" "} + {statusText} + + )} + {!showSpinner && {statusText}} + + + + View in ADE → + + {adeUrl} + + + + ); +} diff --git a/src/cli/subcommands/listen.tsx b/src/cli/subcommands/listen.tsx new file mode 100644 index 0000000..cb3989c --- /dev/null +++ b/src/cli/subcommands/listen.tsx @@ -0,0 +1,187 @@ +/** + * CLI subcommand: letta listen --name "george" + * Register letta-code as a listener to receive messages from Letta Cloud + */ + +import { parseArgs } from "node:util"; +import { render } from "ink"; +import { getServerUrl } from "../../agent/client"; +import { settingsManager } from "../../settings-manager"; +import { ListenerStatusUI } from "../components/ListenerStatusUI"; + +export async function runListenSubcommand(argv: string[]): Promise { + // Parse arguments + const { values } = parseArgs({ + args: argv, + options: { + name: { type: "string" }, + agent: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + }); + + // Show help + if (values.help) { + console.log( + "Usage: letta listen --name [--agent ]\n", + ); + console.log( + "Register this letta-code instance to receive messages from Letta Cloud.\n", + ); + console.log("Options:"); + console.log( + " --name Friendly name for this connection (required)", + ); + console.log( + " --agent Bind connection to specific agent (required for CLI usage)", + ); + console.log(" -h, --help Show this help message\n"); + console.log("Examples:"); + console.log(' letta listen --name "george" --agent agent-abc123'); + console.log(' letta listen --name "laptop-work" --agent agent-xyz789\n'); + console.log( + "Once connected, this instance will listen for incoming messages from cloud agents.", + ); + console.log( + "Messages will be executed locally using your letta-code environment.", + ); + return 0; + } + + const connectionName = values.name; + const agentId = values.agent; + + if (!connectionName) { + console.error("Error: --name is required\n"); + console.error('Usage: letta listen --name "george" --agent agent-abc123\n'); + console.error( + "Provide a friendly name to identify this connection (e.g., your name, device name).", + ); + return 1; + } + + if (!agentId) { + console.error("Error: --agent is required\n"); + console.error('Usage: letta listen --name "george" --agent agent-abc123\n'); + console.error( + "A listener connection needs a default agent to execute messages.", + ); + console.error( + "Specify which agent should receive messages from this connection.", + ); + return 1; + } + + try { + // Get device ID + const deviceId = settingsManager.getOrCreateDeviceId(); + + // Get API key (include secure token storage fallback) + const settings = await settingsManager.getSettingsWithSecureTokens(); + const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY; + + if (!apiKey) { + console.error("Error: LETTA_API_KEY not found"); + console.error("Set your API key with: export LETTA_API_KEY="); + return 1; + } + + // Register with cloud + const serverUrl = getServerUrl(); + const registerUrl = `${serverUrl}/v1/listeners/register`; + + const registerResponse = await fetch(registerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "X-Letta-Source": "letta-code", + }, + body: JSON.stringify({ + deviceId, + connectionName, + agentId, + }), + }); + + if (!registerResponse.ok) { + const error = (await registerResponse.json()) as { message?: string }; + console.error(`Registration failed: ${error.message || "Unknown error"}`); + return 1; + } + + const { connectionId, wsUrl } = (await registerResponse.json()) as { + connectionId: string; + wsUrl: string; + }; + + // Clear screen and render Ink UI + console.clear(); + + let updateStatusCallback: + | ((status: "idle" | "receiving" | "processing") => void) + | null = null; + let updateRetryStatusCallback: + | ((attempt: number, nextRetryIn: number) => void) + | null = null; + let clearRetryStatusCallback: (() => void) | null = null; + + const { unmount } = render( + { + updateStatusCallback = callbacks.updateStatus; + updateRetryStatusCallback = callbacks.updateRetryStatus; + clearRetryStatusCallback = callbacks.clearRetryStatus; + }} + />, + ); + + // Import and start WebSocket client + const { startListenerClient } = await import( + "../../websocket/listen-client" + ); + + await startListenerClient({ + connectionId, + wsUrl, + deviceId, + connectionName, + agentId, + onStatusChange: (status) => { + clearRetryStatusCallback?.(); + updateStatusCallback?.(status); + }, + onConnected: () => { + clearRetryStatusCallback?.(); + updateStatusCallback?.("idle"); + }, + onRetrying: (attempt, _maxAttempts, nextRetryIn) => { + updateRetryStatusCallback?.(attempt, nextRetryIn); + }, + onDisconnected: () => { + unmount(); + console.log("\n✗ Listener disconnected"); + console.log("Connection to Letta Cloud was lost.\n"); + process.exit(1); + }, + onError: (error: Error) => { + unmount(); + console.error(`\n✗ Listener error: ${error.message}\n`); + process.exit(1); + }, + }); + + // Keep process alive + return new Promise(() => { + // Never resolves - runs until Ctrl+C + }); + } catch (error) { + console.error( + `Failed to start listener: ${error instanceof Error ? error.message : String(error)}`, + ); + return 1; + } +} diff --git a/src/cli/subcommands/router.ts b/src/cli/subcommands/router.ts index 0edfdd2..63e7efb 100644 --- a/src/cli/subcommands/router.ts +++ b/src/cli/subcommands/router.ts @@ -1,5 +1,6 @@ import { runAgentsSubcommand } from "./agents"; import { runBlocksSubcommand } from "./blocks"; +import { runListenSubcommand } from "./listen.tsx"; import { runMemfsSubcommand } from "./memfs"; import { runMessagesSubcommand } from "./messages"; @@ -19,6 +20,8 @@ export async function runSubcommand(argv: string[]): Promise { return runMessagesSubcommand(rest); case "blocks": return runBlocksSubcommand(rest); + case "listen": + return runListenSubcommand(rest); default: return null; } diff --git a/src/websocket/listen-client.ts b/src/websocket/listen-client.ts new file mode 100644 index 0000000..3700fc0 --- /dev/null +++ b/src/websocket/listen-client.ts @@ -0,0 +1,552 @@ +/** + * WebSocket client for listen mode + * Connects to Letta Cloud and receives messages to execute locally + */ + +import type { Stream } from "@letta-ai/letta-client/core/streaming"; +import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; +import type { + ApprovalCreate, + LettaStreamingResponse, + ToolReturn, +} from "@letta-ai/letta-client/resources/agents/messages"; +import WebSocket from "ws"; +import { + type ApprovalDecision, + type ApprovalResult, + executeApprovalBatch, +} from "../agent/approval-execution"; +import { getResumeData } from "../agent/check-approval"; +import { getClient } from "../agent/client"; +import { sendMessageStream } from "../agent/message"; +import { createBuffers } from "../cli/helpers/accumulator"; +import { drainStreamWithResume } from "../cli/helpers/stream"; +import { settingsManager } from "../settings-manager"; +import { loadTools } from "../tools/manager"; + +interface StartListenerOptions { + connectionId: string; + wsUrl: string; + deviceId: string; + connectionName: string; + agentId?: string; + onConnected: () => void; + onDisconnected: () => void; + onError: (error: Error) => void; + onStatusChange?: ( + status: "idle" | "receiving" | "processing", + connectionId: string, + ) => void; + onRetrying?: ( + attempt: number, + maxAttempts: number, + nextRetryIn: number, + ) => void; +} + +interface PingMessage { + type: "ping"; +} + +interface PongMessage { + type: "pong"; +} + +interface IncomingMessage { + type: "message"; + agentId?: string; + conversationId?: string; + messages: Array; +} + +interface ResultMessage { + type: "result"; + success: boolean; + stopReason?: string; +} + +interface RunStartedMessage { + type: "run_started"; + runId: string; +} + +type ServerMessage = PongMessage | IncomingMessage; +type ClientMessage = PingMessage | ResultMessage | RunStartedMessage; + +type ListenerRuntime = { + socket: WebSocket | null; + heartbeatInterval: NodeJS.Timeout | null; + reconnectTimeout: NodeJS.Timeout | null; + intentionallyClosed: boolean; + hasSuccessfulConnection: boolean; + messageQueue: Promise; +}; + +type ApprovalSlot = + | { type: "result"; value: ApprovalResult } + | { type: "decision" }; + +// Listen mode supports one active connection per process. +let activeRuntime: ListenerRuntime | null = null; + +const MAX_RETRY_DURATION_MS = 5 * 60 * 1000; // 5 minutes +const INITIAL_RETRY_DELAY_MS = 1000; // 1 second +const MAX_RETRY_DELAY_MS = 30000; // 30 seconds + +function createRuntime(): ListenerRuntime { + return { + socket: null, + heartbeatInterval: null, + reconnectTimeout: null, + intentionallyClosed: false, + hasSuccessfulConnection: false, + messageQueue: Promise.resolve(), + }; +} + +function clearRuntimeTimers(runtime: ListenerRuntime): void { + if (runtime.reconnectTimeout) { + clearTimeout(runtime.reconnectTimeout); + runtime.reconnectTimeout = null; + } + if (runtime.heartbeatInterval) { + clearInterval(runtime.heartbeatInterval); + runtime.heartbeatInterval = null; + } +} + +function stopRuntime( + runtime: ListenerRuntime, + suppressCallbacks: boolean, +): void { + runtime.intentionallyClosed = true; + clearRuntimeTimers(runtime); + + if (!runtime.socket) { + return; + } + + const socket = runtime.socket; + runtime.socket = null; + + // Stale runtimes being replaced should not emit callbacks/retries. + if (suppressCallbacks) { + socket.removeAllListeners(); + } + + if ( + socket.readyState === WebSocket.OPEN || + socket.readyState === WebSocket.CONNECTING + ) { + socket.close(); + } +} + +function parseServerMessage(data: WebSocket.RawData): ServerMessage | null { + try { + const raw = typeof data === "string" ? data : data.toString(); + const parsed = JSON.parse(raw) as { type?: string }; + if (parsed.type === "pong" || parsed.type === "message") { + return parsed as ServerMessage; + } + return null; + } catch { + return null; + } +} + +function sendClientMessage(socket: WebSocket, payload: ClientMessage): void { + if (socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(payload)); + } +} + +function buildApprovalExecutionPlan( + approvalMessage: ApprovalCreate, + pendingApprovals: Array<{ + toolCallId: string; + toolName: string; + toolArgs: string; + }>, +): { + slots: ApprovalSlot[]; + decisions: ApprovalDecision[]; +} { + const pendingByToolCallId = new Map( + pendingApprovals.map((approval) => [approval.toolCallId, approval]), + ); + + const slots: ApprovalSlot[] = []; + const decisions: ApprovalDecision[] = []; + + for (const approval of approvalMessage.approvals ?? []) { + if (approval.type === "tool") { + slots.push({ type: "result", value: approval as ToolReturn }); + continue; + } + + if (approval.type !== "approval") { + slots.push({ + type: "result", + value: { + type: "tool", + tool_call_id: "unknown", + tool_return: "Error: Unsupported approval payload", + status: "error", + }, + }); + continue; + } + + const pending = pendingByToolCallId.get(approval.tool_call_id); + + if (approval.approve) { + if (!pending) { + slots.push({ + type: "result", + value: { + type: "tool", + tool_call_id: approval.tool_call_id, + tool_return: "Error: Pending approval not found", + status: "error", + }, + }); + continue; + } + + decisions.push({ + type: "approve", + approval: { + toolCallId: pending.toolCallId, + toolName: pending.toolName, + toolArgs: pending.toolArgs || "{}", + }, + }); + slots.push({ type: "decision" }); + continue; + } + + decisions.push({ + type: "deny", + approval: { + toolCallId: approval.tool_call_id, + toolName: pending?.toolName ?? "", + toolArgs: pending?.toolArgs ?? "{}", + }, + reason: + typeof approval.reason === "string" && approval.reason.length > 0 + ? approval.reason + : "Tool execution denied", + }); + slots.push({ type: "decision" }); + } + + return { slots, decisions }; +} + +/** + * Start the listener WebSocket client with automatic retry. + */ +export async function startListenerClient( + opts: StartListenerOptions, +): Promise { + // Replace any existing runtime without stale callback leakage. + if (activeRuntime) { + stopRuntime(activeRuntime, true); + } + + const runtime = createRuntime(); + activeRuntime = runtime; + + await connectWithRetry(runtime, opts); +} + +/** + * Connect to WebSocket with exponential backoff retry. + */ +async function connectWithRetry( + runtime: ListenerRuntime, + opts: StartListenerOptions, + attempt: number = 0, + startTime: number = Date.now(), +): Promise { + if (runtime !== activeRuntime || runtime.intentionallyClosed) { + return; + } + + const elapsedTime = Date.now() - startTime; + + if (attempt > 0) { + if (elapsedTime >= MAX_RETRY_DURATION_MS) { + opts.onError(new Error("Failed to connect after 5 minutes of retrying")); + return; + } + + const delay = Math.min( + INITIAL_RETRY_DELAY_MS * 2 ** (attempt - 1), + MAX_RETRY_DELAY_MS, + ); + const maxAttempts = Math.ceil( + Math.log2(MAX_RETRY_DURATION_MS / INITIAL_RETRY_DELAY_MS), + ); + + opts.onRetrying?.(attempt, maxAttempts, delay); + + await new Promise((resolve) => { + runtime.reconnectTimeout = setTimeout(resolve, delay); + }); + + runtime.reconnectTimeout = null; + if (runtime !== activeRuntime || runtime.intentionallyClosed) { + return; + } + } + + clearRuntimeTimers(runtime); + + if (attempt === 0) { + await loadTools(); + } + + const settings = await settingsManager.getSettingsWithSecureTokens(); + const apiKey = process.env.LETTA_API_KEY || settings.env?.LETTA_API_KEY; + + if (!apiKey) { + throw new Error("Missing LETTA_API_KEY"); + } + + const url = new URL(opts.wsUrl); + url.searchParams.set("deviceId", opts.deviceId); + url.searchParams.set("connectionName", opts.connectionName); + if (opts.agentId) { + url.searchParams.set("agentId", opts.agentId); + } + + const socket = new WebSocket(url.toString(), { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + runtime.socket = socket; + + socket.on("open", () => { + if (runtime !== activeRuntime || runtime.intentionallyClosed) { + return; + } + + runtime.hasSuccessfulConnection = true; + opts.onConnected(); + + runtime.heartbeatInterval = setInterval(() => { + sendClientMessage(socket, { type: "ping" }); + }, 30000); + }); + + socket.on("message", (data: WebSocket.RawData) => { + const parsed = parseServerMessage(data); + if (!parsed || parsed.type !== "message") { + return; + } + + runtime.messageQueue = runtime.messageQueue + .then(async () => { + if (runtime !== activeRuntime || runtime.intentionallyClosed) { + return; + } + + opts.onStatusChange?.("receiving", opts.connectionId); + await handleIncomingMessage( + parsed, + socket, + opts.onStatusChange, + opts.connectionId, + ); + opts.onStatusChange?.("idle", opts.connectionId); + }) + .catch((error: unknown) => { + if (process.env.DEBUG) { + console.error("[Listen] Error handling queued message:", error); + } + opts.onStatusChange?.("idle", opts.connectionId); + }); + }); + + socket.on("close", (code: number, reason: Buffer) => { + if (runtime !== activeRuntime) { + return; + } + + if (process.env.DEBUG) { + console.log( + `[Listen] WebSocket disconnected (code: ${code}, reason: ${reason.toString()})`, + ); + } + + clearRuntimeTimers(runtime); + runtime.socket = null; + + if (runtime.intentionallyClosed) { + opts.onDisconnected(); + return; + } + + // If we had connected before, restart backoff from zero for this outage window. + const nextAttempt = runtime.hasSuccessfulConnection ? 0 : attempt + 1; + const nextStartTime = runtime.hasSuccessfulConnection + ? Date.now() + : startTime; + runtime.hasSuccessfulConnection = false; + + connectWithRetry(runtime, opts, nextAttempt, nextStartTime).catch( + (error) => { + opts.onError(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); + + socket.on("error", (error: Error) => { + if (process.env.DEBUG) { + console.error("[Listen] WebSocket error:", error); + } + // Error triggers close(), which handles retry logic. + }); +} + +/** + * Handle an incoming message from the cloud. + */ +async function handleIncomingMessage( + msg: IncomingMessage, + socket: WebSocket, + onStatusChange?: ( + status: "idle" | "receiving" | "processing", + connectionId: string, + ) => void, + connectionId?: string, +): Promise { + try { + const agentId = msg.agentId; + const requestedConversationId = msg.conversationId; + const conversationId = requestedConversationId ?? "default"; + + if (!agentId) { + return; + } + + if (connectionId) { + onStatusChange?.("processing", connectionId); + } + + let messagesToSend: Array = msg.messages; + + const firstMessage = msg.messages[0]; + const isApprovalMessage = + firstMessage && + "type" in firstMessage && + firstMessage.type === "approval" && + "approvals" in firstMessage; + + if (isApprovalMessage) { + const approvalMessage = firstMessage as ApprovalCreate; + const client = await getClient(); + const agent = await client.agents.retrieve(agentId); + const resumeData = await getResumeData( + client, + agent, + requestedConversationId, + ); + + const { slots, decisions } = buildApprovalExecutionPlan( + approvalMessage, + resumeData.pendingApprovals, + ); + const decisionResults = + decisions.length > 0 ? await executeApprovalBatch(decisions) : []; + + const rebuiltApprovals: ApprovalResult[] = []; + let decisionResultIndex = 0; + + for (const slot of slots) { + if (slot.type === "result") { + rebuiltApprovals.push(slot.value); + continue; + } + + const next = decisionResults[decisionResultIndex]; + if (next) { + rebuiltApprovals.push(next); + decisionResultIndex++; + continue; + } + + rebuiltApprovals.push({ + type: "tool", + tool_call_id: "unknown", + tool_return: "Error: Missing approval execution result", + status: "error", + }); + } + + messagesToSend = [ + { + type: "approval", + approvals: rebuiltApprovals, + }, + ]; + } + + const stream = await sendMessageStream(conversationId, messagesToSend, { + agentId, + streamTokens: true, + background: true, + }); + + let runIdSent = false; + + const buffers = createBuffers(agentId); + const result = await drainStreamWithResume( + stream as Stream, + buffers, + () => {}, + undefined, + undefined, + ({ chunk }) => { + const maybeRunId = (chunk as { run_id?: unknown }).run_id; + if (!runIdSent && typeof maybeRunId === "string") { + runIdSent = true; + sendClientMessage(socket, { + type: "run_started", + runId: maybeRunId, + }); + } + return undefined; + }, + ); + + sendClientMessage(socket, { + type: "result", + success: result.stopReason === "end_turn", + stopReason: result.stopReason, + }); + } catch { + sendClientMessage(socket, { + type: "result", + success: false, + stopReason: "error", + }); + } +} + +/** + * Stop the active listener connection. + */ +export function stopListenerClient(): void { + if (!activeRuntime) { + return; + } + + const runtime = activeRuntime; + activeRuntime = null; + stopRuntime(runtime, true); +}