From fe8b477628a041fcd144e60e3d8ebc5d6573088c Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Fri, 21 Aug 2026 21:49:45 -0500 Subject: [PATCH 1/2] add project sync, opencode auth, background process, bump up dependencies --- .opencode/package-lock.json | 210 ++--- .opencode/plugin/tensorlake/core/client.ts | 218 ++++- .../plugin/tensorlake/core/credentials.ts | 66 ++ .../plugin/tensorlake/core/project-sync.ts | 777 ++++++++++++++++++ .../plugin/tensorlake/core/session-manager.ts | 264 +++++- .opencode/plugin/tensorlake/core/shell.ts | 8 + .opencode/plugin/tensorlake/index.ts | 15 +- .opencode/plugin/tensorlake/plugins/auth.ts | 34 + .../plugin/tensorlake/plugins/custom-tools.ts | 10 +- .../tensorlake/plugins/session-events.ts | 20 +- .../tensorlake/plugins/system-transform.ts | 39 +- .opencode/plugin/tensorlake/tools.ts | 10 +- .opencode/plugin/tensorlake/tools/bash.ts | 91 +- .opencode/plugin/tensorlake/tools/edit.ts | 6 +- .opencode/plugin/tensorlake/tools/glob.ts | 11 +- .opencode/plugin/tensorlake/tools/grep.ts | 13 +- .opencode/plugin/tensorlake/tools/ls.ts | 8 +- .opencode/plugin/tensorlake/tools/read.ts | 6 +- .opencode/plugin/tensorlake/tools/write.ts | 9 +- README.md | 123 ++- package-lock.json | 598 ++++++++++++-- package.json | 8 +- 22 files changed, 2192 insertions(+), 352 deletions(-) create mode 100644 .opencode/plugin/tensorlake/core/credentials.ts create mode 100644 .opencode/plugin/tensorlake/core/project-sync.ts create mode 100644 .opencode/plugin/tensorlake/core/shell.ts create mode 100644 .opencode/plugin/tensorlake/plugins/auth.ts diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json index 88c770f..fde3924 100644 --- a/.opencode/package-lock.json +++ b/.opencode/package-lock.json @@ -5,14 +5,25 @@ "packages": { "": { "dependencies": { - "@opencode-ai/plugin": "1.15.5", - "tensorlake": "^0.5.14" + "@opencode-ai/plugin": "^1.18.21" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -23,9 +34,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -36,9 +47,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -49,9 +60,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -62,9 +73,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -75,9 +86,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -88,19 +99,20 @@ ] }, "node_modules/@opencode-ai/plugin": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.5.tgz", - "integrity": "sha512-QvhrDLlQuLeFGET1zB2crMWPvou6PpAzYtKrbun9akWvaskyAcXIAVn3lNYX/InMcut5VzL6ERbPgvM7ucHfLA==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.21.tgz", + "integrity": "sha512-wQXMbSvg3pG75F8fYAiJxYuyr6Cl9Hd74lSCY2yznZnejpwa+ksd2kMphmgSo+/UgLhb2AId9KAI6dsRhprzTg==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.15.5", - "effect": "4.0.0-beta.65", + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.21", + "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.2.14", - "@opentui/keymap": ">=0.2.14", - "@opentui/solid": ">=0.2.14" + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" }, "peerDependenciesMeta": { "@opentui/core": { @@ -115,9 +127,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.5.tgz", - "integrity": "sha512-ozJuEmXzrOvia5n0L1KAuvpyf9ESGmTk1FiPhn0RK5X1whbzjlTXL0NAxqNCEkqETxL35jS1KHArEiTpvtJ6FQ==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.21.tgz", + "integrity": "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -154,27 +166,27 @@ } }, "node_modules/effect": { - "version": "4.0.0-beta.65", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.65.tgz", - "integrity": "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw==", + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", + "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", + "ini": "^7.0.0", "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", + "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" + "uuid": "^14.0.0", + "yaml": "^2.9.0" } }, "node_modules/fast-check": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", - "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", "funding": [ { "type": "individual", @@ -200,12 +212,12 @@ "license": "MIT" }, "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/isexe": { @@ -214,6 +226,12 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/kubernetes-types": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", @@ -221,18 +239,18 @@ "license": "Apache-2.0" }, "node_modules/msgpackr": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", - "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", "license": "MIT", "optionalDependencies": { - "msgpackr-extract": "^3.0.2" + "msgpackr-extract": "^3.0.4" } }, "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -243,18 +261,18 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "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" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", "license": "MIT" }, "node_modules/node-gyp-build-optional-packages": { @@ -282,9 +300,9 @@ } }, "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", "funding": [ { "type": "individual", @@ -318,48 +336,19 @@ "node": ">=8" } }, - "node_modules/tensorlake": { - "version": "0.5.14", - "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.14.tgz", - "integrity": "sha512-qeipeiNxoAv/7jks2XgI9q8AyFv36hv2YVOEUqdWo+ZjQmKpUZWFzDxZ+v0KBlkBsRpL/9RDvSZQgUjJfn9dFw==", - "license": "Apache-2.0", - "dependencies": { - "undici": "^8.1.0", - "ws": "^8.20.0" - }, - "bin": { - "function-executor": "bin/function-executor.cjs", - "tensorlake": "bin/tensorlake.cjs", - "tensorlake-create-sandbox-image": "bin/tensorlake-create-sandbox-image.cjs", - "tensorlake-deploy": "bin/tensorlake-deploy.cjs", - "tl": "bin/tl.cjs" - }, - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", "license": "MIT", "engines": { "node": ">=20" } }, - "node_modules/undici": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", - "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", - "license": "MIT", - "engines": { - "node": ">=22.19.0" - } - }, "node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz", + "integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -384,27 +373,6 @@ "node": ">= 8" } }, - "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", diff --git a/.opencode/plugin/tensorlake/core/client.ts b/.opencode/plugin/tensorlake/core/client.ts index 2c21bf9..78a3dc0 100644 --- a/.opencode/plugin/tensorlake/core/client.ts +++ b/.opencode/plugin/tensorlake/core/client.ts @@ -1,16 +1,10 @@ -import { SandboxClient } from 'tensorlake' -import type { Sandbox } from 'tensorlake' +import { RemoteAPIError, Sandbox, SandboxConnectionError, SandboxNotFoundError } from 'tensorlake' +import type { FileSystemMount } from 'tensorlake' import { execFileSync } from 'child_process' import { logger } from './logger.js' const MANAGEMENT_API = process.env.TENSORLAKE_API_URL ?? 'https://api.tensorlake.ai' -function getSandboxProxyUrl(sandboxId: string): string { - const custom = process.env.TENSORLAKE_SANDBOX_PROXY_URL - if (custom) return custom - return `https://${sandboxId}.sandbox.tensorlake.ai` -} - export type SandboxInfo = { sandbox_id: string status: string @@ -33,61 +27,163 @@ export type DirectoryEntry = { size?: number } -export class TensorLakeClient { - private readonly sdk: SandboxClient +export type ProcessStatusInfo = { + pid: number + status: string + exitCode?: number + signal?: number + command: string +} + +export class TensorlakeClient { + // Connected handles keyed by sandboxId, so repeated operations reuse the + // resolved proxy routing instead of re-resolving on every call. + private readonly handles = new Map() + + // Credentials are resolved lazily on every use so a key added via + // `opencode auth login` after startup is picked up without a restart. + constructor(private readonly resolveKey: () => string | undefined) {} + + hasApiKey(): boolean { + return (this.resolveKey() ?? '').length > 0 + } + + getApiKey(): string { + return this.resolveKey() ?? '' + } - constructor(private readonly apiKey: string) { - this.sdk = SandboxClient.forCloud({ - apiKey, + private clientOptions() { + return { + apiKey: this.getApiKey(), apiUrl: MANAGEMENT_API, - organizationId: process.env.TENSORLAKE_ORGANIZATION_ID, - projectId: process.env.TENSORLAKE_PROJECT_ID, + ...(process.env.TENSORLAKE_ORGANIZATION_ID + ? { organizationId: process.env.TENSORLAKE_ORGANIZATION_ID } + : {}), + ...(process.env.TENSORLAKE_PROJECT_ID ? { projectId: process.env.TENSORLAKE_PROJECT_ID } : {}), + } + } + + private async connectSandbox(sandboxId: string): Promise { + const cached = this.handles.get(sandboxId) + if (cached) return cached + const proxyUrl = process.env.TENSORLAKE_SANDBOX_PROXY_URL + const sandbox = await Sandbox.connect({ + sandboxId, + ...(proxyUrl ? { proxyUrl } : {}), + ...this.clientOptions(), }) + this.handles.set(sandboxId, sandbox) + return sandbox } - hasApiKey(): boolean { - return this.apiKey.length > 0 + // Errors suggesting the cached handle is unusable — stale proxy routing + // (e.g. the sandbox was suspended and resumed outside this process, moving + // it to a different host) or stale credentials (401/403 after a key + // rotation; handles capture the key at connect time, so only a reconnect + // picks up the new one). Plain 404 is excluded on purpose: sandbox-level + // not-found already arrives as SandboxNotFoundError, so a bare 404 is a + // daemon endpoint's normal answer (missing file, unknown pid) and must not + // tear down a healthy handle. + private isStaleHandleError(err: unknown): boolean { + return ( + err instanceof SandboxConnectionError || + err instanceof SandboxNotFoundError || + (err instanceof RemoteAPIError && [401, 403, 502, 503].includes(err.statusCode)) + ) + } + + // Runs a proxy-routed operation. On a stale-handle error the handle is + // dropped so the next call reconnects with fresh routing; idempotent ops + // (retry: true) additionally reconnect and retry once themselves. Command + // execution uses retry: false to avoid any chance of running a command twice. + private async withSandbox( + sandboxId: string, + op: (sandbox: Sandbox) => Promise, + opts: { retry: boolean } = { retry: true }, + ): Promise { + const sandbox = await this.connectSandbox(sandboxId) + try { + return await op(sandbox) + } catch (err: unknown) { + if (!this.isStaleHandleError(err)) throw err + this.dropHandle(sandboxId) + if (!opts.retry) throw err + logger.warn(`Sandbox ${sandboxId} call failed (${(err as Error)?.message ?? err}); reconnecting and retrying once`) + return op(await this.connectSandbox(sandboxId)) + } } - async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number } = {}): Promise { + private dropHandle(sandboxId: string): void { + const handle = this.handles.get(sandboxId) + if (!handle) return + this.handles.delete(sandboxId) + try { + handle.close() + } catch { + // closing a stale handle is best-effort + } + } + + async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number; fileSystems?: FileSystemMount[] } = {}): Promise { const cpus = parseFloat(process.env.TENSORLAKE_CPUS ?? '2') const memoryMb = parseInt(process.env.TENSORLAKE_MEMORY_MB ?? '4096', 10) const ephemeralDiskMb = parseInt(process.env.TENSORLAKE_DISK_MB ?? '10240', 10) logger.info(`Creating sandbox name=${opts.name ?? '(ephemeral)'} image=${opts.image ?? '(default)'} cpus=${cpus} memoryMb=${memoryMb} diskMb=${ephemeralDiskMb}`) - const sandbox = await this.sdk.createAndConnect({ + const proxyUrl = process.env.TENSORLAKE_SANDBOX_PROXY_URL + const sandbox = await Sandbox.create({ + ...(proxyUrl ? { proxyUrl } : {}), ...(opts.image ? { image: opts.image } : {}), cpus, memoryMb, diskMb: ephemeralDiskMb, ...(opts.name ? { name: opts.name } : {}), ...(opts.timeoutSecs ? { timeoutSecs: opts.timeoutSecs } : {}), + ...(opts.fileSystems?.length ? { fileSystems: opts.fileSystems } : {}), + ...this.clientOptions(), }) + this.handles.set(sandbox.sandboxId, sandbox) return { sandbox_id: sandbox.sandboxId, status: 'running' } } + async listSandboxFileSystems(sandboxId: string): Promise { + const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.info()) + return info.fileSystems ?? [] + } + + async attachFileSystem(sandboxId: string, fileSystemId: string, mountPath: string): Promise { + // retry: false — a retried attach after a mid-flight failure could double-attach + await this.withSandbox(sandboxId, (sandbox) => sandbox.attachFileSystem(fileSystemId, mountPath), { retry: false }) + } + async getSandbox(sandboxId: string): Promise { - const info = await this.sdk.get(sandboxId) + const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.info()) return { sandbox_id: info.sandboxId, status: info.status as unknown as string } } async deleteSandbox(sandboxId: string): Promise { try { - await this.sdk.delete(sandboxId) + const sandbox = await this.connectSandbox(sandboxId) + await sandbox.terminate() } catch (err: unknown) { - if (String((err as Error)?.message ?? err).includes('404')) return + // Already deleted — treat as success. + if (err instanceof SandboxNotFoundError) return + if (err instanceof RemoteAPIError && err.statusCode === 404) return throw err + } finally { + this.dropHandle(sandboxId) } } async suspendSandbox(sandboxId: string): Promise { - await this.sdk.suspend(sandboxId) + await this.withSandbox(sandboxId, (sandbox) => sandbox.suspend()) } suspendSandboxSync(sandboxId: string): void { + const apiKey = this.getApiKey() execFileSync('curl', [ '-s', '-X', 'POST', `${MANAGEMENT_API}/sandboxes/${sandboxId}/suspend`, - '-H', `Authorization: Bearer ${this.apiKey}`, + '-H', `Authorization: Bearer ${apiKey}`, ], { timeout: 10_000 }) const deadline = Date.now() + 30_000 while (Date.now() < deadline) { @@ -95,7 +191,7 @@ export class TensorLakeClient { const out = execFileSync('curl', [ '-s', `${MANAGEMENT_API}/sandboxes/${sandboxId}`, - '-H', `Authorization: Bearer ${this.apiKey}`, + '-H', `Authorization: Bearer ${apiKey}`, ], { timeout: 10_000 }).toString() const status = JSON.parse(out)?.status if (status === 'suspended' || status === 'terminated') return @@ -107,7 +203,8 @@ export class TensorLakeClient { } async resumeSandbox(sandboxId: string): Promise { - await this.sdk.resume(sandboxId) + // resume() waits for running and refreshes the handle's proxy routing. + await this.withSandbox(sandboxId, (sandbox) => sandbox.resume()) } async waitForSuspended(sandboxId: string, timeoutMs = 30_000): Promise { @@ -124,28 +221,31 @@ export class TensorLakeClient { while (Date.now() < deadline) { const info = await this.getSandbox(sandboxId) if (info.status === 'running') return - if (info.status === 'terminated') throw new Error(`Sandbox ${sandboxId} was terminated`) + // 'timeout' is terminal like 'terminated' — fail fast instead of polling to the deadline + if (info.status === 'terminated' || info.status === 'timeout') { + throw new Error(`Sandbox ${sandboxId} is ${info.status}`) + } await new Promise((r) => setTimeout(r, 500)) } throw new Error(`Sandbox ${sandboxId} did not become running within ${timeoutMs}ms`) } - private connectSandbox(sandboxId: string): Sandbox { - return this.sdk.connect(sandboxId, getSandboxProxyUrl(sandboxId)) - } - async executeCommand( sandboxId: string, command: string, workingDir = '/tmp/workspace', timeoutMs = 120_000, ): Promise { - const sandbox = this.connectSandbox(sandboxId) - const result = await sandbox.run('sh', { - args: ['-c', command], - workingDir, - timeout: timeoutMs / 1000, - }) + const result = await this.withSandbox( + sandboxId, + (sandbox) => + sandbox.run('sh', { + args: ['-c', command], + workingDir, + timeout: timeoutMs / 1000, + }), + { retry: false }, + ) return { exitCode: result.exitCode ?? -1, stdout: result.stdout ?? '', @@ -153,20 +253,52 @@ export class TensorLakeClient { } } + // Processes are started unnamed (non-managed) on purpose: the daemon keeps + // tracking them after exit or kill, so status and output stay queryable by PID. + async startBackgroundProcess(sandboxId: string, command: string, workingDir: string): Promise { + const info = await this.withSandbox( + sandboxId, + (sandbox) => + sandbox.startProcess('sh', { + args: ['-c', command], + workingDir, + }), + { retry: false }, + ) + return info.pid + } + + async getProcessStatus(sandboxId: string, pid: number): Promise { + const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.getProcess(pid)) + return { + pid: info.pid, + status: info.status as unknown as string, + exitCode: info.exitCode, + signal: info.signal, + command: [info.command, ...(info.args ?? [])].join(' '), + } + } + + async getProcessOutput(sandboxId: string, pid: number): Promise { + const output = await this.withSandbox(sandboxId, (sandbox) => sandbox.getOutput(pid)) + return output.lines + } + + async killProcess(sandboxId: string, pid: number): Promise { + await this.withSandbox(sandboxId, (sandbox) => sandbox.killProcess(pid)) + } + async readFile(sandboxId: string, path: string): Promise { - const sandbox = this.connectSandbox(sandboxId) - const data = await sandbox.readFile(path) + const data = await this.withSandbox(sandboxId, (sandbox) => sandbox.readFile(path)) return Buffer.from(data) } async writeFile(sandboxId: string, path: string, content: Buffer): Promise { - const sandbox = this.connectSandbox(sandboxId) - await sandbox.writeFile(path, content) + await this.withSandbox(sandboxId, (sandbox) => sandbox.writeFile(path, content)) } async listDirectory(sandboxId: string, path: string): Promise { - const sandbox = this.connectSandbox(sandboxId) - const response = await sandbox.listDirectory(path) + const response = await this.withSandbox(sandboxId, (sandbox) => sandbox.listDirectory(path)) return response.entries.map((e) => ({ name: e.name, is_dir: e.isDir, diff --git a/.opencode/plugin/tensorlake/core/credentials.ts b/.opencode/plugin/tensorlake/core/credentials.ts new file mode 100644 index 0000000..b56b7c0 --- /dev/null +++ b/.opencode/plugin/tensorlake/core/credentials.ts @@ -0,0 +1,66 @@ +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' +import { xdgData } from 'xdg-basedir' +import { logger } from './logger.js' + +/** Provider id shown in `opencode auth login` and used as the auth.json key. */ +export const PROVIDER_ID = 'tensorlake' + +/** Project-scoped API keys carry their own org/project scope — no extra IDs needed. */ +export const PROJECT_KEY_PREFIX = 'tl_apiKey_' + +export const LOGIN_HINT = + 'Run `opencode auth login`, select Tensorlake, and paste a project API key from https://cloud.tensorlake.ai — or set TENSORLAKE_API_KEY.' + +// OpenCode's credential store, written by `opencode auth login`. +const AUTH_FILE = join(xdgData ?? '/tmp', 'opencode', 'auth.json') + +// auth.json is re-read at most every STORE_TTL_MS so a login/logout done in +// another terminal is picked up without restarting OpenCode. +const STORE_TTL_MS = 15_000 +let storeCache: { key: string | undefined; readAt: number } | null = null + +function readStoredApiKey(): string | undefined { + const now = Date.now() + if (storeCache && now - storeCache.readAt < STORE_TTL_MS) return storeCache.key + let key: string | undefined + try { + if (existsSync(AUTH_FILE)) { + const store = JSON.parse(readFileSync(AUTH_FILE, 'utf-8')) as Record + const entry = store[PROVIDER_ID] as { type?: string; key?: string } | undefined + if (entry?.type === 'api' && typeof entry.key === 'string' && entry.key.length > 0) { + key = entry.key + } + } + } catch (err) { + logger.warn(`Failed to read OpenCode auth store ${AUTH_FILE}: ${err}`) + } + storeCache = { key, readAt: now } + return key +} + +/** + * Credential resolution order: TENSORLAKE_API_KEY env var (CI/automation + * override) first, then the key stored by `opencode auth login`. + */ +export function resolveApiKey(): string | undefined { + const envKey = process.env.TENSORLAKE_API_KEY + if (envKey && envKey.length > 0) return envKey + return readStoredApiKey() +} + +/** + * OpenCode's built-in API-key prompt cannot validate the key at login (the + * plugin never sees the masked value), so keys are checked here on first use. + * A non-project key still works when its scope is supplied via env vars, so + * this is a warning, not a hard failure. + */ +export function projectKeyWarning(apiKey: string): string | undefined { + if (apiKey.startsWith(PROJECT_KEY_PREFIX)) return undefined + if (process.env.TENSORLAKE_ORGANIZATION_ID && process.env.TENSORLAKE_PROJECT_ID) return undefined + return ( + `The stored key is not a project API key (${PROJECT_KEY_PREFIX}...). ` + + 'Sandbox calls may fail. Re-run `opencode auth login` with a project API key from ' + + 'https://cloud.tensorlake.ai (Project → API Keys), or set TENSORLAKE_ORGANIZATION_ID and TENSORLAKE_PROJECT_ID.' + ) +} diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts new file mode 100644 index 0000000..f158afa --- /dev/null +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -0,0 +1,777 @@ +import { existsSync, readdirSync, lstatSync, unlinkSync, readFileSync, writeFileSync, mkdirSync } from 'fs' +import { join, basename, dirname } from 'path' +import { posix } from 'path' +import { tmpdir } from 'os' +import { execFile } from 'child_process' +import { promisify } from 'util' +import { RepositoryClient, FilesystemClient, CloudClient } from 'tensorlake' +import type { FileSystemMount } from 'tensorlake' +import { logger } from './logger.js' +import type { TensorlakeClient } from './client.js' + +export type SyncMode = 'git' | 'volume' | 'off' + +// Directories that are always regenerable and expensive to upload. +const SKIP_DIRS = new Set([ + '.git', + 'node_modules', + '.venv', + 'venv', + '__pycache__', + '.next', + '.nuxt', + '.turbo', + '.cache', + 'dist', + 'build', + 'target', + '.DS_Store', +]) + +// Files larger than this are skipped in volume mode. +const MAX_FILE_BYTES = 100 * 1024 * 1024 + +// Uncommitted-changes patches larger than this are not synced into the sandbox. +const MAX_PATCH_BYTES = 50 * 1024 * 1024 + +// Older versions wrote the sync manifest into the volume root, i.e. into the +// tree mounted as the agent's project dir. That let sandbox code rewrite the +// file that drives laptop-side deletions, so the manifest now lives on the +// laptop (see syncManifestPath) and this legacy on-volume copy is scrubbed. +const LEGACY_VOLUME_MANIFEST_PATH = '.tensorlake-sync-manifest.json' + +const execFileAsync = promisify(execFile) + +function apiUrl(): string | undefined { + return process.env.TENSORLAKE_API_URL +} + +type Scope = { organizationId: string; projectId: string } + +// Cached per key: the stored key can change without a restart (auth login in +// another terminal), and a new key may belong to a different project. +let cachedScope: { apiKey: string; scope: Scope } | null = null + +function pickId(value: unknown, keys: string[]): string | undefined { + if (value == null || typeof value !== 'object') return undefined + const obj = value as Record + for (const key of keys) { + const candidate = obj[key] + if (typeof candidate === 'string' && candidate.length > 0) return candidate + } + return undefined +} + +/** + * Resolve the organization/project scope required by the git and filesystem + * APIs. Env vars win (PAT keys need them); org/project-scoped API keys are + * introspected once and the result cached for the process lifetime. + */ +async function resolveScope(apiKey: string): Promise { + const envOrg = process.env.TENSORLAKE_ORGANIZATION_ID + const envProj = process.env.TENSORLAKE_PROJECT_ID + if (envOrg && envProj) return { organizationId: envOrg, projectId: envProj } + if (cachedScope && cachedScope.apiKey === apiKey) return cachedScope.scope + + const client = CloudClient.forCloud({ apiKey, ...(apiUrl() ? { apiUrl: apiUrl() } : {}) }) + try { + const intro = (await client.introspectApiKey()) as Record + const organizationId = + envOrg ?? + pickId(intro, ['organizationId', 'organization_id']) ?? + pickId(intro.organization, ['id', 'organizationId', 'organization_id']) + const projectId = + envProj ?? + pickId(intro, ['projectId', 'project_id']) ?? + pickId(intro.project, ['id', 'projectId', 'project_id']) + if (!organizationId || !projectId) { + throw new Error( + 'Could not resolve organization/project from the API key. Set TENSORLAKE_ORGANIZATION_ID and TENSORLAKE_PROJECT_ID.', + ) + } + cachedScope = { apiKey, scope: { organizationId, projectId } } + return cachedScope.scope + } finally { + client.close() + } +} + +async function cloudOptions(apiKey: string) { + const scope = await resolveScope(apiKey) + return { + apiKey, + ...(apiUrl() ? { apiUrl: apiUrl() } : {}), + ...scope, + } +} + +export function sanitizeName(input: string): string { + return input.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) +} + +/** Directory name the project is synced to inside the sandbox workspace. */ +export function projectDirName(worktree: string): string { + const name = basename(worktree ?? '').replace(/[^a-zA-Z0-9._-]/g, '-') + return name || 'project' +} + +/** + * Resolve how the local project is synced into the sandbox. + * TENSORLAKE_SYNC_MODE=git|volume|off overrides; default 'auto' picks + * git for git repositories and a mounted cloud volume otherwise. + */ +export function resolveSyncMode(worktree: string): SyncMode { + const env = (process.env.TENSORLAKE_SYNC_MODE ?? 'auto').toLowerCase() + if (env === 'git' || env === 'volume' || env === 'off') return env + if (!worktree || worktree === '/' || !existsSync(worktree)) return 'off' + return existsSync(join(worktree, '.git')) ? 'git' : 'volume' +} + +/** Name shared by the hosted git repo (git mode) and cloud volume (volume mode). */ +function syncResourceName(projectId: string): string { + return sanitizeName(`opencode-${projectId}`) +} + +// Branch names that are safe to embed in the single-quoted sandbox sync +// script and in refspecs. Git allows more than this (e.g. quotes), but such +// names cannot be forwarded verbatim, so they sync as 'main' instead. +const SAFE_BRANCH_RE = /^[A-Za-z0-9._/-]+$/ + +/** + * The branch the project syncs under: the local checkout's current branch, + * kept under the same name on the mirror and in the sandbox clone, so agent + * pushes land exactly where the user expects. Falls back to 'main' for a + * detached HEAD or a name that cannot be embedded safely. + */ +export async function resolveSyncBranch(worktree: string): Promise { + let name: string + try { + // --show-current also answers on an unborn branch (fresh `git init`), + // where rev-parse HEAD has nothing to resolve. + name = (await localGit(worktree, ['branch', '--show-current'])).trim() + } catch { + return 'main' + } + if (!name) { + logger.warn('Local checkout is a detached HEAD; syncing as branch main') + return 'main' + } + if (name.startsWith('-') || !SAFE_BRANCH_RE.test(name)) { + logger.warn(`Local branch name ${JSON.stringify(name)} cannot be synced verbatim; syncing as branch main`) + return 'main' + } + return name +} + +/** Run git in the local worktree; rejects with stderr attached on failure. */ +async function localGit(worktree: string, args: string[], env?: Record): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd: worktree, + maxBuffer: MAX_PATCH_BYTES + 1024 * 1024, + timeout: 300_000, + // LC_ALL=C keeps git's messages untranslated: pushRealHistory classifies + // push failures by matching git's English error strings. + env: { ...process.env, LC_ALL: 'C', ...env }, + }) + return stdout +} + +// Credentials go in an HTTP header instead of the remote URL so the token +// never appears in git's error output or the process list. +function gitAuthConfig(cred: { gitUsername: string; token: string }): string { + const basic = Buffer.from(`${cred.gitUsername}:${cred.token}`).toString('base64') + return `http.extraHeader=Authorization: Basic ${basic}` +} + +function redactAuth(text: string): string { + return text.replace(/Basic [A-Za-z0-9+/=]+/g, 'Basic ***') +} + +// Throwaway ref the mirror's sync branch is fetched into for the ancestry +// check, so the user's FETCH_HEAD (which their own fetch/merge workflows may +// be reading) is never touched. +const MIRROR_CHECK_REF = 'refs/tensorlake/mirror-check' + +/** True when `ancestor` is reachable from `descendant`. */ +async function isAncestor(worktree: string, ancestor: string, descendant: string): Promise { + try { + await localGit(worktree, ['merge-base', '--is-ancestor', ancestor, descendant]) + return true + } catch (err: any) { + // Exit code 1 is merge-base's defined "not an ancestor" answer. Anything + // else (128, a timeout) means the check itself failed, and must propagate + // so callers treat the state as indeterminate instead of acting on it. + if (err?.code === 1) return false + throw err + } +} + +/** + * True when the mirror's sync branch already contains local HEAD — i.e. the + * mirror is simply ahead (an agent pushed commits the laptop hasn't fetched + * yet), as opposed to local history having been rewritten. Fetches the + * mirror's branch into a temporary ref to make the ancestry check possible. + */ +async function mirrorContainsLocalHead( + repos: RepositoryClient, + repo: string, + worktree: string, + branch: string, +): Promise { + const cred = await repos.credential(repo) + await localGit(worktree, [ + '-c', + gitAuthConfig(cred), + 'fetch', + '--quiet', + '--no-write-fetch-head', + repos.url(repo), + `+refs/heads/${branch}:${MIRROR_CHECK_REF}`, + ]) + try { + return await isAncestor(worktree, 'HEAD', MIRROR_CHECK_REF) + } finally { + await localGit(worktree, ['update-ref', '-d', MIRROR_CHECK_REF]).catch(() => {}) + } +} + +/** + * Push the local repository's real history (HEAD) to the mirror's sync + * branch, preserving commits, authors, and dates. A non-fast-forward rejection + * has two causes that must be told apart: the mirror being ahead of the laptop + * (agents commit and push their work to it), in which case the push is simply + * skipped, or local history having been rewritten by a rebase or amend, in + * which case the mirror is deleted, recreated, and pushed fresh. + */ +async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: string, branch: string): Promise { + const push = async () => { + const cred = await repos.credential(repo) + await localGit(worktree, ['-c', gitAuthConfig(cred), 'push', '--quiet', repos.url(repo), `HEAD:refs/heads/${branch}`]) + } + try { + await push() + } catch (err: any) { + const detail = redactAuth(`${err?.stderr ?? ''} ${err?.message ?? err}`) + if (!/non-fast-forward|\[rejected\]|failed to push some refs|fetch first/i.test(detail)) { + throw new Error(`git push to hosted mirror ${repo} failed: ${detail}`) + } + let remoteAhead: boolean + try { + remoteAhead = await mirrorContainsLocalHead(repos, repo, worktree, branch) + } catch (checkErr: any) { + // Indeterminate state: never recreate the mirror without proof of a + // rewrite, or agent commits that exist only on the mirror would be lost. + throw new Error( + `Hosted mirror ${repo} rejected a non-fast-forward push and its state could not be inspected; not recreating it to avoid discarding remote-only commits: ${redactAuth(`${checkErr?.stderr ?? ''} ${checkErr?.message ?? checkErr}`)}`, + ) + } + if (remoteAhead) { + logger.info( + `Hosted mirror ${repo} is ahead of local HEAD (agent commits not yet fetched locally); skipping push — local history is already on the mirror.`, + ) + return + } + logger.warn( + `Hosted mirror ${repo} rejected a non-fast-forward push and its ${branch} branch does not contain local HEAD (local history was rewritten); recreating the mirror. Commits that existed only on the mirror are discarded.`, + ) + await repos.delete(repo) + await repos.create(repo, { defaultBranch: branch }) + try { + await push() + } catch (err2: any) { + throw new Error( + `git push after recreating mirror ${repo} failed: ${redactAuth(`${err2?.stderr ?? ''} ${err2?.message ?? err2}`)}`, + ) + } + } +} + +/** + * Diff of everything not yet committed locally (modified, staged, untracked, + * deleted), built against a temporary index so the real index is untouched. + * Returns null when the working tree is clean or the patch is oversized. + */ +async function buildUncommittedPatch(worktree: string): Promise { + const tmpIndex = join(tmpdir(), `tensorlake-sync-index-${process.pid}-${Date.now()}`) + const env = { GIT_INDEX_FILE: tmpIndex } + try { + await localGit(worktree, ['read-tree', 'HEAD'], env) + await localGit(worktree, ['add', '-A'], env) + const patch = await localGit(worktree, ['diff', '--cached', '--binary', 'HEAD'], env) + if (!patch.trim()) return null + const bytes = Buffer.byteLength(patch) + if (bytes > MAX_PATCH_BYTES) { + logger.warn(`Uncommitted changes are ${bytes} bytes (> ${MAX_PATCH_BYTES}); not syncing them into the sandbox`) + return null + } + return patch + } finally { + try { + unlinkSync(tmpIndex) + } catch { + // temp index may not exist if an early git call failed + } + } +} + +/** + * Replicate uncommitted local changes into the sandbox working tree without + * committing them, so the sandbox mirrors the laptop's exact state. Applied + * only when the sandbox tree is clean and at the same HEAD as the laptop — + * agent work in the sandbox is never overwritten. + */ +async function applyUncommittedChanges( + client: TensorlakeClient, + sandboxId: string, + worktree: string, + destDir: string, + localHead: string, +): Promise { + const patch = await buildUncommittedPatch(worktree) + if (!patch) return + const state = await client.executeCommand(sandboxId, `cd '${destDir}' && git rev-parse HEAD && git status --porcelain`, '/') + if (state.exitCode !== 0) { + logger.warn(`Could not inspect sandbox clone before applying uncommitted changes: ${state.stderr || state.stdout}`) + return + } + const [sandboxHead, ...statusLines] = state.stdout.trim().split('\n') + const dirty = statusLines.some((line) => line.trim() !== '') + if (sandboxHead !== localHead || dirty) { + logger.info( + `Skipping uncommitted-changes sync: sandbox tree ${dirty ? 'has its own modifications' : `is at ${sandboxHead?.slice(0, 8)}, not ${localHead.slice(0, 8)}`}`, + ) + return + } + const patchPath = '/tmp/.tensorlake-sync.patch' + await client.writeFile(sandboxId, patchPath, Buffer.from(patch)) + const apply = await client.executeCommand( + sandboxId, + `cd '${destDir}' && git apply --whitespace=nowarn '${patchPath}'; code=$?; rm -f '${patchPath}'; exit $code`, + '/', + ) + if (apply.exitCode !== 0) { + logger.warn(`Failed to apply uncommitted local changes in sandbox: ${apply.stderr || apply.stdout}`) + } else { + logger.info(`Applied uncommitted local changes (${Buffer.byteLength(patch)} bytes) to ${destDir}`) + } +} + +/** + * Sync the local git repository into the sandbox with full commit history: + * push real refs to a Tensorlake-hosted mirror, then clone (or fast-forward) + * the mirror inside the sandbox at `destDir`, and finally replay uncommitted + * local changes onto the sandbox working tree. A repo with no commits yet + * falls back to a single snapshot commit via pushWorktree. + */ +export async function syncGitProject( + client: TensorlakeClient, + apiKey: string, + sandboxId: string, + worktree: string, + projectId: string, + destDir: string, +): Promise { + const repos = RepositoryClient.forCloud(await cloudOptions(apiKey)) + try { + const repo = syncResourceName(projectId) + const branch = await resolveSyncBranch(worktree) + try { + await repos.info(repo) + } catch { + logger.info(`Creating hosted git repository ${repo}`) + await repos.create(repo, { defaultBranch: branch }) + } + + let localHead: string | null = null + try { + localHead = (await localGit(worktree, ['rev-parse', 'HEAD'])).trim() + } catch { + // no commits yet, or no usable local git — use the snapshot path + } + + if (localHead) { + logger.info( + `Pushing real history (HEAD ${localHead.slice(0, 8)}, branch ${branch}) from ${worktree} to hosted mirror ${repo}`, + ) + await pushRealHistory(repos, repo, worktree, branch) + } else { + logger.info(`Local repository has no commits; pushing worktree snapshot to ${repo} (branch ${branch})`) + await repos.pushWorktree(repo, { + path: worktree, + branch, + message: 'Sync from OpenCode', + }) + } + + const url = repos.url(repo) + const credLine = await credentialLine(repos, repo) + + // Re-syncs only fast-forward: a sandbox clone with its own commits or + // uncommitted changes must never be clobbered by a hard reset. A clean + // clone left on another branch (the user switched branches locally) + // switches to the sync branch; a dirty one is left alone. The git + // identity lets agents commit their work inside the sandbox. + const script = [ + 'set -e', + 'git config --global credential.helper store', + `printf '%s\\n' '${credLine}' > ~/.git-credentials`, + `git config --global user.name >/dev/null 2>&1 || git config --global user.name 'OpenCode Agent'`, + `git config --global user.email >/dev/null 2>&1 || git config --global user.email 'opencode-agent@tensorlake.ai'`, + `if [ -d '${destDir}/.git' ]; then`, + ` cd '${destDir}' && git fetch origin '${branch}'`, + ` current="$(git branch --show-current)"`, + ` if [ "$current" != '${branch}' ]; then`, + ' if [ -n "$(git status --porcelain)" ]; then', + ' echo TENSORLAKE_SYNC_DIVERGED', + ' else', + ` git checkout -q '${branch}' 2>/dev/null || git checkout -q -b '${branch}' 'origin/${branch}'`, + ` git merge --ff-only 'origin/${branch}' || echo TENSORLAKE_SYNC_DIVERGED`, + ' fi', + ` elif ! git merge --ff-only 'origin/${branch}'; then`, + ' echo TENSORLAKE_SYNC_DIVERGED', + ' fi', + 'else', + ` rm -rf '${destDir}' && git clone --branch '${branch}' '${url}' '${destDir}'`, + 'fi', + ].join('\n') + + const result = await client.executeCommand(sandboxId, script, '/', 300_000) + if (result.exitCode !== 0) { + throw new Error(`git sync failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`) + } + if (result.stdout.includes('TENSORLAKE_SYNC_DIVERGED')) { + logger.warn( + `Sandbox clone at ${destDir} has commits or changes that diverge from the pushed project; left untouched instead of resetting. Delete the session's sandbox to start from a fresh clone.`, + ) + } else { + logger.info(`Project cloned into sandbox at ${destDir}`) + if (localHead) { + await applyUncommittedChanges(client, sandboxId, worktree, destDir, localHead) + } + } + } finally { + repos.close() + } +} + +// Git credential store line for the mirror, in git's expected +// https://user:token@host format. +async function credentialLine(repos: RepositoryClient, repo: string): Promise { + const cred = await repos.credential(repo) + const parsed = new URL(repos.url(repo)) + return `${parsed.protocol}//${encodeURIComponent(cred.gitUsername)}:${encodeURIComponent(cred.token)}@${parsed.host}` +} + +// Git tokens live about one hour; re-mint the sandbox's stored credential +// well before that so agent pushes keep working in long sessions. +export const GIT_CREDENTIAL_REFRESH_MS = 30 * 60 * 1000 + +/** + * Write a freshly minted git credential into the sandbox's credential store, + * replacing the (possibly expired) one written by the last sync. + */ +export async function refreshSandboxGitCredential( + client: TensorlakeClient, + apiKey: string, + sandboxId: string, + projectId: string, +): Promise { + const repos = RepositoryClient.forCloud(await cloudOptions(apiKey)) + try { + const credLine = await credentialLine(repos, syncResourceName(projectId)) + const result = await client.executeCommand( + sandboxId, + `printf '%s\\n' '${credLine}' > ~/.git-credentials`, + '/', + 30_000, + ) + if (result.exitCode !== 0) { + throw new Error(`writing ~/.git-credentials failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`) + } + } finally { + repos.close() + } +} + +/** What a sync-back pass did, for user-facing reporting. */ +export type SyncBackResult = + | { kind: 'up-to-date' } + | { kind: 'fast-forwarded'; branch: string; commits: number; oid: string } + | { kind: 'staged'; branch: string; ref: string; commits: number; oid: string; reason: string } + +/** + * Pull agent commits from the hosted mirror back into the local repository. + * Fetches every mirror branch into refs/remotes/tensorlake/* (so nothing an + * agent pushed is ever stranded on the mirror), then fast-forwards the local + * sync branch when that is safe. Unsafe cases — a dirty worktree or diverged + * histories — leave the commits on the tensorlake/ tracking ref for + * the user to merge deliberately; the local checkout is never disturbed. + */ +export async function syncBackFromMirror( + apiKey: string, + worktree: string, + projectId: string, +): Promise { + const branch = await resolveSyncBranch(worktree) + const repo = syncResourceName(projectId) + const repos = RepositoryClient.forCloud(await cloudOptions(apiKey)) + try { + const cred = await repos.credential(repo) + // --prune drops tracking refs for branches deleted (or recreated) on the + // mirror, so a stale tensorlake/* ref can't masquerade as agent work. + await localGit(worktree, [ + '-c', + gitAuthConfig(cred), + 'fetch', + '--quiet', + '--no-write-fetch-head', + '--prune', + repos.url(repo), + '+refs/heads/*:refs/remotes/tensorlake/*', + ]) + } finally { + repos.close() + } + + const trackingRef = `refs/remotes/tensorlake/${branch}` + const shortRef = `tensorlake/${branch}` + let mirrorOid: string + try { + mirrorOid = (await localGit(worktree, ['rev-parse', '--verify', '--quiet', trackingRef])).trim() + } catch { + return { kind: 'up-to-date' } // branch not on the mirror yet + } + + let localOid: string + try { + localOid = (await localGit(worktree, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).trim() + } catch { + // No local branch to advance (e.g. a repo that had no commits at sync + // time). The fetch above already saved the agent's work locally. + return { kind: 'staged', branch, ref: shortRef, commits: 0, oid: mirrorOid, reason: `local branch ${branch} not found` } + } + + if (mirrorOid === localOid) return { kind: 'up-to-date' } + // Mirror behind the laptop: the next inbound sync's push handles that. + if (await isAncestor(worktree, mirrorOid, localOid)) return { kind: 'up-to-date' } + + const commits = + parseInt((await localGit(worktree, ['rev-list', '--count', `${localOid}..${mirrorOid}`])).trim(), 10) || 0 + if (!(await isAncestor(worktree, localOid, mirrorOid))) { + return { kind: 'staged', branch, ref: shortRef, commits, oid: mirrorOid, reason: 'local and agent histories diverged' } + } + + const current = (await localGit(worktree, ['branch', '--show-current'])).trim() + if (current !== branch) { + // The branch exists but is not checked out (detached HEAD fallback): + // advance the ref directly — no worktree files are involved. The + // old-value argument makes it a compare-and-swap against races. + await localGit(worktree, ['update-ref', `refs/heads/${branch}`, mirrorOid, localOid]) + return { kind: 'fast-forwarded', branch, commits, oid: mirrorOid } + } + + const dirty = (await localGit(worktree, ['status', '--porcelain'])).trim() !== '' + if (dirty) { + return { kind: 'staged', branch, ref: shortRef, commits, oid: mirrorOid, reason: 'local uncommitted changes' } + } + + await localGit(worktree, ['merge', '--ff-only', '--quiet', mirrorOid]) + return { kind: 'fast-forwarded', branch, commits, oid: mirrorOid } +} + +/** + * Recursively collect files to upload (remote path -> absolute local path) + * plus every path that still exists locally, uploaded or not. Deletion + * propagation must key off `present`, not `files`: a path can be skipped from + * the upload (oversized, symlink, unreadable) while very much still existing. + */ +function collectFiles(worktree: string): { files: Record; present: Set } { + const files: Record = {} + const present = new Set() + const walk = (dir: string, prefix: string) => { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue + const localPath = join(dir, entry) + const remotePath = prefix ? posix.join(prefix, entry) : entry + let st + try { + st = lstatSync(localPath) + } catch { + // Unreadable is not deleted — keep it out of the upload but never let + // its absence from `files` be read as a local deletion. + present.add(remotePath) + continue + } + if (st.isSymbolicLink()) { + present.add(remotePath) + continue + } + if (st.isDirectory()) { + walk(localPath, remotePath) + } else if (st.isFile()) { + present.add(remotePath) + if (st.size > MAX_FILE_BYTES) { + logger.warn(`Skipping ${localPath} (${st.size} bytes > ${MAX_FILE_BYTES})`) + continue + } + files[remotePath] = localPath + } + } + } + walk(worktree, '') + return { files, present } +} + +/** + * Ensure the project's cloud volume exists and holds the current worktree + * content. Returns the mount spec for the sandbox. Sandbox mounts address + * volumes by their filesystem name. `manifestDir` is a laptop-local directory + * where the record of uploaded paths is kept between syncs. + */ +export async function ensureVolumeWithProject( + apiKey: string, + worktree: string, + projectId: string, + mountPath: string, + manifestDir: string, +): Promise { + const name = syncResourceName(projectId) + const options = await cloudOptions(apiKey) + const fsClient = new FilesystemClient(options) + let fs + try { + fs = await fsClient.get(name) + } catch { + logger.info(`Creating cloud volume ${name}`) + fs = await fsClient.create(name) + } + + const { files, present } = collectFiles(worktree) + const count = Object.keys(files).length + const manifestPath = syncManifestPath(manifestDir, name) + const stale = staleRemotePaths(readSyncManifest(manifestPath), present) + logger.info(`Uploading ${count} files from ${worktree} to volume ${name}`) + if (count > 0) { + await fs.writeFilesFromPaths(files, 'Sync from OpenCode') + } + + // Drop previously uploaded paths that no longer exist locally, so local + // deletions propagate to later sandboxes. Non-fatal: the uploads above + // already landed, and paths that fail to delete stay in the manifest so the + // deletion is retried on the next sync. + let undeleted: string[] = [] + if (stale.length > 0) { + try { + logger.info(`Removing ${stale.length} locally deleted files from volume ${name}`) + await fs.writeFiles({}, 'Sync from OpenCode (remove deleted files)', stale) + } catch (err) { + logger.warn(`Failed to remove deleted files from volume ${name}: ${err}`) + undeleted = stale + } + } + writeSyncManifest(manifestPath, [...Object.keys(files), ...undeleted]) + + // Scrub the manifest older versions wrote into the volume root, where every + // sandbox agent could see and commit it. + try { + await fs.deleteFile(LEGACY_VOLUME_MANIFEST_PATH, 'Remove legacy sync manifest') + } catch { + // already gone — the common case + } + + return { fileSystemId: name, mountPath } +} + +/** Laptop-local record of the paths the last sync uploaded to a volume. */ +function syncManifestPath(manifestDir: string, volumeName: string): string { + return join(manifestDir, `${volumeName}.sync-manifest.json`) +} + +function readSyncManifest(manifestPath: string): string[] { + try { + const parsed = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { paths?: unknown } + if (!Array.isArray(parsed.paths)) return [] + return parsed.paths.filter((p): p is string => typeof p === 'string') + } catch { + // first sync, or a missing/corrupt manifest — never guess at deletions + return [] + } +} + +function writeSyncManifest(manifestPath: string, paths: string[]): void { + try { + mkdirSync(dirname(manifestPath), { recursive: true }) + writeFileSync(manifestPath, JSON.stringify({ paths: [...new Set(paths)].sort() })) + } catch (err) { + logger.warn(`Failed to write sync manifest ${manifestPath}: ${err}`) + } +} + +// Deletion paths are sent to the volume API verbatim, so accept only plain +// relative paths — no absolute paths, no `.`/`..` segments, no backslashes. +function isSafeRelativePath(path: string): boolean { + if (!path || path.startsWith('/') || path.includes('\\')) return false + return path.split('/').every((segment) => segment !== '' && segment !== '.' && segment !== '..') +} + +/** + * Previously uploaded paths that have since been deleted locally. Computed + * against everything still present in the worktree (not just the uploaded + * set), so files skipped from an upload are never treated as deleted, and + * only from the manifest of past uploads, so files created by agents inside + * the mounted volume are never touched. Paths now under SKIP_DIRS are also + * left alone — the sandbox may own them (e.g. an agent-built dist/). + */ +function staleRemotePaths(previous: string[], present: Set): string[] { + return previous.filter( + (path) => + isSafeRelativePath(path) && + !present.has(path) && + !path.split('/').some((segment) => SKIP_DIRS.has(segment)), + ) +} + +/** Attach the project volume to an already-running sandbox if not mounted. */ +export async function ensureVolumeMounted( + client: TensorlakeClient, + mount: FileSystemMount, + sandboxId: string, +): Promise { + const mounts = await client.listSandboxFileSystems(sandboxId) + if (!mounts.some((m) => m.mountPath === mount.mountPath)) { + logger.info(`Attaching volume ${mount.fileSystemId} to sandbox ${sandboxId} at ${mount.mountPath}`) + await client.attachFileSystem(sandboxId, mount.fileSystemId, mount.mountPath) + } + // Both attachFileSystem and the control plane's mount listing reflect + // control-plane state; the guest materializes the mount asynchronously on + // the dataplane. Callers mark the sandbox synced and run tools with + // mountPath as cwd immediately after, so always wait for the guest to see a + // real mount — including when the control plane already listed it. + await waitForGuestMount(client, sandboxId, mount.mountPath) +} + +async function waitForGuestMount( + client: TensorlakeClient, + sandboxId: string, + path: string, + timeoutMs = 30_000, + probeTimeoutMs = 5_000, +): Promise { + // `test -d` is not enough here: the sync-failure fallback mkdirs a plain + // directory at this exact path, which would satisfy it while the volume is + // not mounted at all. Require the path to be an actual mountpoint. + const probe = `mountpoint -q '${path}' 2>/dev/null || awk -v p='${path}' '$2 == p { found = 1 } END { exit !found }' /proc/mounts` + const deadline = Date.now() + timeoutMs + for (;;) { + try { + const check = await client.executeCommand(sandboxId, probe, '/', probeTimeoutMs) + if (check.exitCode === 0) return + } catch (err) { + logger.warn(`Mount readiness check failed: ${err}`) + } + if (Date.now() >= deadline) { + throw new Error(`Volume mount at ${path} did not become visible in the sandbox within ${timeoutMs}ms`) + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } +} diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index a708ca4..63b211a 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -1,30 +1,230 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' -import { join } from 'path' -import { TensorLakeClient } from './client.js' +import { join, posix } from 'path' +import { TensorlakeClient } from './client.js' +import { LOGIN_HINT, projectKeyWarning } from './credentials.js' import { logger } from './logger.js' import { toast } from './toast.js' import type { ProjectSessionData } from './types.js' import type { PluginInput } from '@opencode-ai/plugin' +import { + resolveSyncMode, + projectDirName, + syncGitProject, + syncBackFromMirror, + ensureVolumeWithProject, + ensureVolumeMounted, + refreshSandboxGitCredential, + GIT_CREDENTIAL_REFRESH_MS, +} from './project-sync.js' -export class TensorLakeSessionManager { - private readonly client: TensorLakeClient +export class TensorlakeSessionManager { + private readonly client: TensorlakeClient // In-memory cache: sessionId -> { sandboxId } private readonly cache = new Map() // In-flight getSandbox promises keyed by sessionId — prevents concurrent double-resume private readonly inflight = new Map>() + // Sandboxes whose project sync already ran in this process + private readonly synced = new Set() + // In-flight sync promises keyed by sandboxId — prevents concurrent double-sync + private readonly syncInflight = new Map>() + // Sandboxes confirmed to already contain the project dir (check runs once per process) + private readonly hasProjectDir = new Set() + // Last failed sync attempt per sandbox — retried after a cooldown instead of on every tool call + private readonly syncFailedAt = new Map() + private static readonly SYNC_RETRY_COOLDOWN_MS = 60_000 + // In-flight sync-back per worktree — session.idle can fire faster than a fetch completes + private readonly syncBackInflight = new Map>() + // Last failed sync-back per worktree — retried after a cooldown instead of on every idle + private readonly syncBackFailedAt = new Map() + // Mirror commit already reported as "staged, not merged" per worktree — suppresses repeat toasts + private readonly lastStagedOid = new Map() + // Keys already checked for project scope — the warning fires once per key + private readonly warnedKeys = new Set() + // When each sandbox's git credential was last written — tokens expire in ~1h + private readonly credRefreshedAt = new Map() public readonly workDir: string private readonly storageDir: string - constructor(apiKey: string, storageDir: string, workDir: string) { - this.client = new TensorLakeClient(apiKey) + constructor(resolveKey: () => string | undefined, storageDir: string, workDir: string) { + this.client = new TensorlakeClient(resolveKey) this.storageDir = storageDir this.workDir = workDir } - getClient(): TensorLakeClient { + getClient(): TensorlakeClient { return this.client } + /** Directory inside the sandbox where the local project is synced. */ + projectDir(worktree: string): string { + if (resolveSyncMode(worktree) === 'off') return this.workDir + // Guest (Linux sandbox) path — must stay POSIX even on a Windows host + return posix.join(this.workDir, projectDirName(worktree)) + } + + /** + * Sync the local project into the sandbox. Git repos are pushed to a + * Tensorlake-hosted repo and cloned inside the sandbox; plain folders are + * pushed to a cloud volume mounted into the sandbox. Failures are logged + * and surfaced but never block the sandbox. + */ + private async syncProject(sandboxId: string, projectId: string, worktree: string): Promise { + if (this.synced.has(sandboxId)) return + const mode = resolveSyncMode(worktree) + if (mode === 'off') { + this.synced.add(sandboxId) + return + } + const failedAt = this.syncFailedAt.get(sandboxId) + if (failedAt !== undefined && Date.now() - failedAt < TensorlakeSessionManager.SYNC_RETRY_COOLDOWN_MS) return + const destDir = this.projectDir(worktree) + try { + if (mode === 'git') { + toast.show({ title: 'Syncing project', message: `Pushing ${worktree} and cloning into the sandbox...`, variant: 'info' }) + await syncGitProject(this.client, this.client.getApiKey(), sandboxId, worktree, projectId, destDir) + // The sync script wrote a fresh credential into the sandbox. + this.credRefreshedAt.set(sandboxId, Date.now()) + } else { + toast.show({ title: 'Syncing project', message: `Uploading ${worktree} to a cloud volume...`, variant: 'info' }) + const mount = await ensureVolumeWithProject(this.client.getApiKey(), worktree, projectId, destDir, this.storageDir) + await ensureVolumeMounted(this.client, mount, sandboxId) + } + this.synced.add(sandboxId) + this.syncFailedAt.delete(sandboxId) + toast.show({ title: 'Project synced', message: `Project available at ${destDir}`, variant: 'success' }) + } catch (err: any) { + this.syncFailedAt.set(sandboxId, Date.now()) + logger.error(`Project sync (${mode}) failed: ${err?.stack ?? err}`) + // Tools default their working directory to destDir; create it so the + // sandbox really is usable (but empty) while sync is failing. + try { + await this.client.executeCommand(sandboxId, `mkdir -p ${destDir}`, '/') + } catch (mkdirErr) { + logger.warn(`Failed to create project dir after sync failure: ${mkdirErr}`) + } + toast.show({ title: 'Project sync failed', message: `${err?.message ?? err}. Sandbox is usable but empty.`, variant: 'error' }) + } + } + + /** Run syncProject at most once concurrently per sandbox. Never rejects (syncProject handles its own errors). */ + private syncProjectOnce(sandboxId: string, projectId: string, worktree: string): Promise { + const existing = this.syncInflight.get(sandboxId) + if (existing) return existing + const promise = this.syncProject(sandboxId, projectId, worktree) + .finally(() => this.syncInflight.delete(sandboxId)) + this.syncInflight.set(sandboxId, promise) + return promise + } + + /** + * Make sure tools can run against the project dir without always paying for + * a full sync up front. If the sandbox already contains the project dir + * (e.g. a resumed sandbox synced by an earlier process), the refresh sync + * runs in the background; only a sandbox with no project copy at all blocks + * on the initial sync. + */ + private async ensureProjectAvailable(sandboxId: string, projectId: string, worktree: string): Promise { + if (this.synced.has(sandboxId)) { + this.refreshGitCredentialIfStale(sandboxId, projectId, worktree) + return + } + if (resolveSyncMode(worktree) === 'off') { + this.synced.add(sandboxId) + return + } + if (this.hasProjectDir.has(sandboxId)) { + void this.syncProjectOnce(sandboxId, projectId, worktree) + return + } + const destDir = this.projectDir(worktree) + try { + // The check runs BEFORE the sync starts, so it can never observe a + // half-populated clone. In git mode it requires a .git dir: only then + // does the sync script take its safe fast-forward path — without .git + // it runs `rm -rf && git clone`, which must never execute behind the + // agent's back (e.g. on the decoy dir the sync-failure fallback leaves, + // after the agent has written files into it). In volume mode the dir + // must be non-empty so that same empty decoy dir does not count. + const mode = resolveSyncMode(worktree) + const checkCmd = mode === 'git' + ? `[ -d '${destDir}/.git' ]` + : `[ -d '${destDir}' ] && [ -n "$(ls -A '${destDir}' 2>/dev/null)" ]` + const check = await this.client.executeCommand(sandboxId, checkCmd, '/', 15_000) + if (check.exitCode === 0) { + this.hasProjectDir.add(sandboxId) + void this.syncProjectOnce(sandboxId, projectId, worktree) + return + } + } catch (err) { + logger.warn(`Failed to check for existing project dir: ${err}`) + } + await this.syncProjectOnce(sandboxId, projectId, worktree) + } + + /** + * Re-mint the sandbox's stored git credential when the last one is near + * expiry (tokens last about an hour). Fire-and-forget: a turn never blocks + * on it, and a failed refresh is retried on the next turn. + */ + private refreshGitCredentialIfStale(sandboxId: string, projectId: string, worktree: string): void { + if (resolveSyncMode(worktree) !== 'git') return + const last = this.credRefreshedAt.get(sandboxId) ?? 0 + if (Date.now() - last < GIT_CREDENTIAL_REFRESH_MS) return + // Claim the slot up front so concurrent tool calls don't stack refreshes. + this.credRefreshedAt.set(sandboxId, Date.now()) + refreshSandboxGitCredential(this.client, this.client.getApiKey(), sandboxId, projectId) + .then(() => logger.info(`Refreshed git credential in sandbox ${sandboxId}`)) + .catch((err: any) => { + this.credRefreshedAt.delete(sandboxId) + logger.warn(`Failed to refresh git credential in sandbox ${sandboxId}: ${err?.message ?? err}`) + }) + } + + /** + * Pull agent commits from the hosted mirror back into the local checkout. + * Runs after each agent turn (session.idle) so a "commit and push" by the + * agent lands on the user's local branch within seconds; a turn where the + * agent pushed nothing costs one cheap fetch. Only sessions that actually + * have a sandbox in this process trigger it, and only in git mode. + */ + async syncBack(sessionId: string, projectId: string, worktree: string): Promise { + if (!this.cache.has(sessionId)) return + if (resolveSyncMode(worktree) !== 'git') return + const failedAt = this.syncBackFailedAt.get(worktree) + if (failedAt !== undefined && Date.now() - failedAt < TensorlakeSessionManager.SYNC_RETRY_COOLDOWN_MS) return + const existing = this.syncBackInflight.get(worktree) + if (existing) return existing + const run = this._syncBack(worktree, projectId).finally(() => this.syncBackInflight.delete(worktree)) + this.syncBackInflight.set(worktree, run) + return run + } + + private async _syncBack(worktree: string, projectId: string): Promise { + try { + const result = await syncBackFromMirror(this.client.getApiKey(), worktree, projectId) + if (result.kind === 'fast-forwarded') { + this.lastStagedOid.delete(worktree) + logger.info(`Sync-back: fast-forwarded local ${result.branch} by ${result.commits} agent commit(s)`) + toast.show({ + title: 'Agent work pulled', + message: `${result.commits} commit(s) from the sandbox are now on your local '${result.branch}' branch.`, + variant: 'success', + }) + } else if (result.kind === 'staged' && result.oid !== this.lastStagedOid.get(worktree)) { + this.lastStagedOid.set(worktree, result.oid) + logger.info(`Sync-back: staged ${result.commits} agent commit(s) on ${result.ref} (${result.reason})`) + toast.show({ + title: 'Agent work fetched', + message: `${result.commits} commit(s) are on '${result.ref}' but not merged (${result.reason}). Merge when ready: git merge ${result.ref}`, + variant: 'warning', + }) + } + } catch (err: any) { + this.syncBackFailedAt.set(worktree, Date.now()) + logger.warn(`Sync-back from mirror failed: ${err?.message ?? err}`) + } + } + private storagePath(projectId: string): string { return join(this.storageDir, `${projectId}.json`) } @@ -93,28 +293,50 @@ export class TensorLakeSessionManager { if (pluginCtx?.client?.tui) toast.initialize(pluginCtx.client.tui) if (!this.client.hasApiKey()) { - const msg = 'TENSORLAKE_API_KEY is not set. Please set the environment variable.' - toast.show({ title: 'Sandbox error', message: msg, variant: 'error' }) + const msg = `No Tensorlake credentials found. ${LOGIN_HINT}` + logger.error(`Tool call blocked for session ${sessionId}: ${msg}`) + toast.show({ title: 'Tensorlake login required', message: msg, variant: 'error' }) throw new Error(msg) } + // Login can no longer validate the key (OpenCode masks it away from the + // plugin), so warn once per key when it doesn't look project-scoped. + const apiKey = this.client.getApiKey() + if (!this.warnedKeys.has(apiKey)) { + this.warnedKeys.add(apiKey) + const warning = projectKeyWarning(apiKey) + if (warning) { + logger.warn(warning) + toast.show({ title: 'Check your Tensorlake API key', message: warning, variant: 'warning' }) + } + } + // Check in-memory cache const cached = this.cache.get(sessionId) if (cached) { try { const info = await this.client.getSandbox(cached.sandboxId) - if (info.status === 'suspended') { - logger.info(`Resuming sandbox ${cached.sandboxId}`) - await this.client.resumeSandbox(cached.sandboxId) - await this.client.waitForRunning(cached.sandboxId) - toast.show({ title: 'Sandbox resumed', message: 'Sandbox resumed from suspension.', variant: 'info' }) - } else if (info.status === 'terminated') { - logger.warn(`Sandbox ${cached.sandboxId} was terminated, creating new one`) + // 'timeout' is terminal like 'terminated' — waiting on it never ends in 'running' + if (info.status === 'terminated' || info.status === 'timeout') { + logger.warn(`Sandbox ${cached.sandboxId} is ${info.status}, creating new one`) this.cache.delete(sessionId) this.removeSession(projectId, sessionId) - return this.getSandbox(sessionId, projectId, worktree, pluginCtx) + // Call _getSandbox directly: getSandbox would return the still-pending + // in-flight promise for this session, resolving the promise to itself. + return this._getSandbox(sessionId, projectId, worktree, pluginCtx) + } + if (info.status === 'suspended' || info.status === 'suspending') { + logger.info(`Resuming sandbox ${cached.sandboxId} (was ${info.status})`) + if (info.status === 'suspending') await this.client.waitForSuspended(cached.sandboxId) + // resumeSandbox blocks until the sandbox is running again + await this.client.resumeSandbox(cached.sandboxId) + toast.show({ title: 'Sandbox resumed', message: 'Sandbox resumed from suspension.', variant: 'info' }) + } else if (info.status !== 'running') { + await this.client.waitForRunning(cached.sandboxId) } this.updateSession(projectId, worktree, sessionId, cached.sandboxId) + // No-op when already synced; retries a previously failed sync (after cooldown) + await this.ensureProjectAvailable(cached.sandboxId, projectId, worktree) return cached } catch (err) { logger.warn(`Failed to check cached sandbox: ${err}`) @@ -137,15 +359,16 @@ export class TensorLakeSessionManager { logger.info(`Trying sandbox ${stored.sandboxId} from session ${storedSessionId}`) try { const info = await this.client.getSandbox(stored.sandboxId) - if (info.status === 'terminated') { + // 'timeout' is terminal like 'terminated' — waiting on it never ends in 'running' + if (info.status === 'terminated' || info.status === 'timeout') { this.removeSession(projectId, storedSessionId) continue } if (info.status === 'suspended' || info.status === 'suspending') { logger.info(`Resuming sandbox ${stored.sandboxId} (was ${info.status})`) if (info.status === 'suspending') await this.client.waitForSuspended(stored.sandboxId) + // resumeSandbox blocks until the sandbox is running again await this.client.resumeSandbox(stored.sandboxId) - await this.client.waitForRunning(stored.sandboxId) } else if (info.status !== 'running') { await this.client.waitForRunning(stored.sandboxId) } @@ -154,6 +377,7 @@ export class TensorLakeSessionManager { this.updateSession(projectId, worktree, sessionId, stored.sandboxId) const reused = storedSessionId !== sessionId toast.show({ title: 'Sandbox connected', message: reused ? 'Reusing sandbox from previous session.' : 'Connected to existing sandbox.', variant: 'info' }) + await this.ensureProjectAvailable(stored.sandboxId, projectId, worktree) return entry } catch (err) { logger.warn(`Failed to connect to sandbox ${stored.sandboxId}: ${err}`) @@ -181,6 +405,8 @@ export class TensorLakeSessionManager { this.updateSession(projectId, worktree, sessionId, created.sandbox_id) toast.show({ title: 'Sandbox created', message: 'New sandbox is ready.', variant: 'success' }) + // A fresh sandbox has no project copy — the sync must finish before tools run + await this.syncProjectOnce(created.sandbox_id, projectId, worktree) return entry } diff --git a/.opencode/plugin/tensorlake/core/shell.ts b/.opencode/plugin/tensorlake/core/shell.ts new file mode 100644 index 0000000..06f378d --- /dev/null +++ b/.opencode/plugin/tensorlake/core/shell.ts @@ -0,0 +1,8 @@ +/** + * Quote a string for safe use as a single word in a POSIX shell command. + * Wraps the value in single quotes; embedded single quotes become '\''. + * Nothing inside single quotes is expanded by the shell. + */ +export function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} diff --git a/.opencode/plugin/tensorlake/index.ts b/.opencode/plugin/tensorlake/index.ts index ac49e4a..06f752a 100644 --- a/.opencode/plugin/tensorlake/index.ts +++ b/.opencode/plugin/tensorlake/index.ts @@ -2,8 +2,10 @@ import { join } from 'path' import { xdgData } from 'xdg-basedir' import type { PluginInput } from '@opencode-ai/plugin' import { setLogFilePath, logger } from './core/logger.js' -import { TensorLakeSessionManager } from './core/session-manager.js' +import { resolveApiKey } from './core/credentials.js' +import { TensorlakeSessionManager } from './core/session-manager.js' import { toast } from './core/toast.js' +import { authHook } from './plugins/auth.js' import { customTools } from './plugins/custom-tools.js' import { eventHandlers } from './plugins/session-events.js' import { systemPromptTransform } from './plugins/system-transform.js' @@ -13,11 +15,7 @@ const STORAGE_DIR = join(xdgData ?? '/tmp', 'opencode', 'storage', 'tensorlake') const WORK_DIR = '/tmp/workspace' setLogFilePath(LOG_FILE) -const sessionManager = new TensorLakeSessionManager( - process.env.TENSORLAKE_API_KEY ?? '', - STORAGE_DIR, - WORK_DIR, -) +const sessionManager = new TensorlakeSessionManager(resolveApiKey, STORAGE_DIR, WORK_DIR) function suspendAndExit(signal: string) { logger.info(`Received ${signal}, suspending sandboxes before exit`) @@ -34,10 +32,13 @@ process.on('SIGINT', () => suspendAndExit('SIGINT')) async function tensorlakePlugin(ctx: PluginInput) { toast.initialize(ctx.client?.tui) + const worktree = ctx.project?.worktree ?? ctx.worktree ?? '' + const projectDir = sessionManager.projectDir(worktree) return { + auth: authHook, tool: await customTools(ctx, sessionManager), event: await eventHandlers(ctx, sessionManager), - 'experimental.chat.system.transform': await systemPromptTransform(ctx, WORK_DIR), + 'experimental.chat.system.transform': await systemPromptTransform(ctx, WORK_DIR, projectDir), } } diff --git a/.opencode/plugin/tensorlake/plugins/auth.ts b/.opencode/plugin/tensorlake/plugins/auth.ts new file mode 100644 index 0000000..6d26a8e --- /dev/null +++ b/.opencode/plugin/tensorlake/plugins/auth.ts @@ -0,0 +1,34 @@ +import type { AuthHook } from '@opencode-ai/plugin' +import { PROVIDER_ID } from '../core/credentials.js' + +/** + * Registers Tensorlake as a provider in `opencode auth login`. OpenCode shows + * its own masked "Enter your API key" prompt for `api` methods and stores the + * key in its credential store (auth.json). The method must not define a text + * prompt or `authorize`: OpenCode renders text prompts unmasked (echoing the + * key on screen) and never passes the masked key to `authorize`, so a text + * prompt forces the user to enter the key twice. The key is checked lazily on + * first use instead (see session-manager.ts). + * + * The single-option select below is the only way to show where to get the key: + * OpenCode's key prompt message is hardcoded and `api` methods have no + * instructions field. Pasting a key into the select is inert, so it cannot + * leak the key. Its value lands in auth.json metadata, which nothing reads. + */ +export const authHook: AuthHook = { + provider: PROVIDER_ID, + methods: [ + { + type: 'api', + label: 'Project API key', + prompts: [ + { + type: 'select', + key: 'hint', + message: 'Get your API key from https://cloud.tensorlake.ai (open your project → API Keys)', + options: [{ label: 'I have my key — continue', value: 'ok' }], + }, + ], + }, + ], +} diff --git a/.opencode/plugin/tensorlake/plugins/custom-tools.ts b/.opencode/plugin/tensorlake/plugins/custom-tools.ts index 891d611..c5e44ee 100644 --- a/.opencode/plugin/tensorlake/plugins/custom-tools.ts +++ b/.opencode/plugin/tensorlake/plugins/custom-tools.ts @@ -1,9 +1,9 @@ import type { PluginInput } from '@opencode-ai/plugin' -import { createTensorLakeTools } from '../tools.js' +import { createTensorlakeTools } from '../tools.js' import { logger } from '../core/logger.js' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' -export async function customTools(ctx: PluginInput, sessionManager: TensorLakeSessionManager) { - logger.info('OpenCode started with TensorLake plugin') - return createTensorLakeTools(sessionManager, ctx.project.id, ctx.project.worktree, ctx) +export async function customTools(ctx: PluginInput, sessionManager: TensorlakeSessionManager) { + logger.info('OpenCode started with Tensorlake plugin') + return createTensorlakeTools(sessionManager, ctx.project.id, ctx.project.worktree, ctx) } diff --git a/.opencode/plugin/tensorlake/plugins/session-events.ts b/.opencode/plugin/tensorlake/plugins/session-events.ts index fdaf6d5..0e7e6d4 100644 --- a/.opencode/plugin/tensorlake/plugins/session-events.ts +++ b/.opencode/plugin/tensorlake/plugins/session-events.ts @@ -1,14 +1,26 @@ import type { PluginInput } from '@opencode-ai/plugin' -import { EVENT_TYPE_SESSION_DELETED, EVENT_TYPE_SERVER_INSTANCE_DISPOSED, type EventSessionDeleted } from '../core/types.js' +import { + EVENT_TYPE_SESSION_DELETED, + EVENT_TYPE_SESSION_IDLE, + EVENT_TYPE_SERVER_INSTANCE_DISPOSED, + type EventSessionDeleted, + type EventSessionIdle, +} from '../core/types.js' import { toast } from '../core/toast.js' import { logger } from '../core/logger.js' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' -export async function eventHandlers(ctx: PluginInput, sessionManager: TensorLakeSessionManager) { +export async function eventHandlers(ctx: PluginInput, sessionManager: TensorlakeSessionManager) { const projectId = ctx.project.id + const worktree = ctx.project?.worktree ?? (ctx as any).worktree ?? '' return async (args: any) => { const event = args.event - if (event.type === EVENT_TYPE_SESSION_DELETED) { + if (event.type === EVENT_TYPE_SESSION_IDLE) { + // The agent just finished a turn: pull whatever it pushed to the mirror + // back into the local checkout. Failures are logged inside syncBack. + const sessionId = (event as EventSessionIdle).properties.sessionID + await sessionManager.syncBack(sessionId, projectId, worktree) + } else if (event.type === EVENT_TYPE_SESSION_DELETED) { const sessionId = (event as EventSessionDeleted).properties.info.id try { await sessionManager.deleteSandbox(sessionId, projectId) diff --git a/.opencode/plugin/tensorlake/plugins/system-transform.ts b/.opencode/plugin/tensorlake/plugins/system-transform.ts index 1d5bb7c..b30eb44 100644 --- a/.opencode/plugin/tensorlake/plugins/system-transform.ts +++ b/.opencode/plugin/tensorlake/plugins/system-transform.ts @@ -1,17 +1,34 @@ import type { PluginInput } from '@opencode-ai/plugin' import type { ExperimentalChatSystemTransformInput, ExperimentalChatSystemTransformOutput } from '../core/types.js' +import { resolveSyncMode, resolveSyncBranch } from '../core/project-sync.js' -export async function systemPromptTransform(ctx: PluginInput, workDir: string) { +export async function systemPromptTransform(ctx: PluginInput, workDir: string, projectDir: string) { + const worktree = ctx.project?.worktree ?? ctx.worktree ?? '' + const mode = resolveSyncMode(worktree) return async (_input: ExperimentalChatSystemTransformInput, output: ExperimentalChatSystemTransformOutput) => { - output.system.push( - [ - '## TensorLake Sandbox Integration', - 'This session is running inside a TensorLake sandbox.', - `The working directory is: ${workDir}`, - 'All bash commands, file reads/writes, and searches run inside the sandbox.', - `Put all project files in ${workDir}. Do NOT use paths from the host system.`, - "For long-running commands (servers, watchers), use the 'background' option.", - ].join('\n'), - ) + const lines = [ + '## Tensorlake Sandbox Integration', + 'This session is running inside a Tensorlake sandbox.', + 'All bash commands, file reads/writes, and searches run inside the sandbox.', + 'Do NOT use paths from the host system.', + "For long-running commands (servers, watchers), use bash with background=true; check on them with bash_output and stop them with bash_kill.", + ] + if (mode === 'git') { + // Resolved per message, not at plugin startup: the user can switch + // local branches between turns and later syncs follow the new branch. + const branch = await resolveSyncBranch(worktree) + lines.push( + `The local project is synced into the sandbox as a git clone at: ${projectDir} (branch: ${branch})`, + `Work in ${projectDir} on branch '${branch}'. Commit and push to 'origin ${branch}' to persist changes; pushed commits are pulled back into the user's local checkout automatically.`, + ) + } else if (mode === 'volume') { + lines.push( + `The local project is mounted into the sandbox on a cloud volume at: ${projectDir}`, + `Work in ${projectDir}. Writes there are persisted automatically.`, + ) + } else { + lines.push(`The working directory is: ${workDir}`, `Put all project files in ${workDir}.`) + } + output.system.push(lines.join('\n')) } } diff --git a/.opencode/plugin/tensorlake/tools.ts b/.opencode/plugin/tensorlake/tools.ts index be6c8f0..0530af1 100644 --- a/.opencode/plugin/tensorlake/tools.ts +++ b/.opencode/plugin/tensorlake/tools.ts @@ -1,6 +1,6 @@ -import type { TensorLakeSessionManager } from './core/session-manager.js' +import type { TensorlakeSessionManager } from './core/session-manager.js' import type { PluginInput } from '@opencode-ai/plugin' -import { bashTool } from './tools/bash.js' +import { bashTool, bashOutputTool, bashKillTool } from './tools/bash.js' import { readTool } from './tools/read.js' import { writeTool } from './tools/write.js' import { editTool } from './tools/edit.js' @@ -8,14 +8,16 @@ import { lsTool } from './tools/ls.js' import { globTool } from './tools/glob.js' import { grepTool } from './tools/grep.js' -export function createTensorLakeTools( - sessionManager: TensorLakeSessionManager, +export function createTensorlakeTools( + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) { return { bash: bashTool(sessionManager, projectId, worktree, pluginCtx), + bash_output: bashOutputTool(sessionManager, projectId, worktree, pluginCtx), + bash_kill: bashKillTool(sessionManager, projectId, worktree, pluginCtx), read: readTool(sessionManager, projectId, worktree, pluginCtx), write: writeTool(sessionManager, projectId, worktree, pluginCtx), edit: editTool(sessionManager, projectId, worktree, pluginCtx), diff --git a/.opencode/plugin/tensorlake/tools/bash.ts b/.opencode/plugin/tensorlake/tools/bash.ts index a789650..6ed1d57 100644 --- a/.opencode/plugin/tensorlake/tools/bash.ts +++ b/.opencode/plugin/tensorlake/tools/bash.ts @@ -1,15 +1,35 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' + +// Lines already returned per background process (keyed by sandboxId:pid), +// so bash_output only shows output produced since the previous call. +const outputOffsets = new Map() + +// With no stored offset (e.g. after a plugin restart) the whole buffer would +// count as "new"; cap the replay so a long-running server's log can't flood. +const MAX_REPLAY_LINES = 200 + +function offsetKey(sandboxId: string, pid: number): string { + return `${sandboxId}:${pid}` +} + +function describeStatus(pid: number, status: string, exitCode?: number, signal?: number): string { + if (status === 'running') return `Background process ${pid} is running.` + const detail = + exitCode !== undefined ? ` with exit code ${exitCode}` : signal !== undefined ? ` by signal ${signal}` : '' + return `Background process ${pid} has ${status}${detail}.` +} export const bashTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Executes shell commands in a TensorLake sandbox', + description: + 'Executes shell commands in a Tensorlake sandbox. Set background=true for long-running commands (servers, watchers); it returns a pid to use with bash_output and bash_kill.', args: { command: z.string(), background: z.boolean().optional(), @@ -17,11 +37,12 @@ export const bashTool = ( async execute(args: { command: string; background?: boolean }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) const client = sessionManager.getClient() - const workDir = sessionManager.workDir + const workDir = sessionManager.projectDir(worktree) if (args.background) { - client.executeCommand(sandboxId, args.command, workDir, 300_000).catch(() => {}) - return `Command started in background: ${args.command}` + const pid = await client.startBackgroundProcess(sandboxId, args.command, workDir) + outputOffsets.set(offsetKey(sandboxId, pid), 0) + return `Started background process with pid ${pid}. Use bash_output with pid=${pid} to read its output, bash_kill to stop it.` } const result = await client.executeCommand(sandboxId, args.command, workDir) @@ -29,3 +50,61 @@ export const bashTool = ( return `Exit code: ${result.exitCode}\n${output}` }, }) + +export const bashOutputTool = ( + sessionManager: TensorlakeSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: + 'Returns new output and status of a background process started with bash background=true. Only lines produced since the previous bash_output call for that pid are returned.', + args: { + pid: z.number().describe('Pid returned by bash when background=true'), + }, + async execute(args: { pid: number }, ctx: ToolContext) { + const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) + const client = sessionManager.getClient() + + const key = offsetKey(sandboxId, args.pid) + let status + try { + status = await client.getProcessStatus(sandboxId, args.pid) + } catch (err: any) { + outputOffsets.delete(key) + return `No background process with pid ${args.pid} found in the sandbox (${err?.message ?? err}).` + } + const lines = await client.getProcessOutput(sandboxId, args.pid) + const offset = outputOffsets.get(key) + let fresh = lines.slice(offset ?? 0) + let truncationNote = '' + if (offset === undefined && fresh.length > MAX_REPLAY_LINES) { + truncationNote = `(showing last ${MAX_REPLAY_LINES} of ${fresh.length} buffered lines)\n` + fresh = fresh.slice(-MAX_REPLAY_LINES) + } + outputOffsets.set(key, lines.length) + + const header = describeStatus(status.pid, status.status, status.exitCode, status.signal) + const body = fresh.length > 0 ? fresh.join('\n') : '(no new output)' + return `${header}\n${truncationNote}${body}` + }, +}) + +export const bashKillTool = ( + sessionManager: TensorlakeSessionManager, + projectId: string, + worktree: string, + pluginCtx: PluginInput, +) => ({ + description: 'Kills a background process started with bash background=true.', + args: { + pid: z.number().describe('Pid returned by bash when background=true'), + }, + async execute(args: { pid: number }, ctx: ToolContext) { + const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) + const client = sessionManager.getClient() + await client.killProcess(sandboxId, args.pid) + outputOffsets.delete(offsetKey(sandboxId, args.pid)) + return `Background process ${args.pid} killed.` + }, +}) diff --git a/.opencode/plugin/tensorlake/tools/edit.ts b/.opencode/plugin/tensorlake/tools/edit.ts index 27b5b6e..b961e9e 100644 --- a/.opencode/plugin/tensorlake/tools/edit.ts +++ b/.opencode/plugin/tensorlake/tools/edit.ts @@ -1,15 +1,15 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const editTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Replaces a string in a file in the TensorLake sandbox', + description: 'Replaces a string in a file in the Tensorlake sandbox', args: { filePath: z.string(), oldString: z.string(), diff --git a/.opencode/plugin/tensorlake/tools/glob.ts b/.opencode/plugin/tensorlake/tools/glob.ts index 97c2fa6..fc45bab 100644 --- a/.opencode/plugin/tensorlake/tools/glob.ts +++ b/.opencode/plugin/tensorlake/tools/glob.ts @@ -1,25 +1,26 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' +import { shellQuote } from '../core/shell.js' export const globTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Finds files matching a glob pattern in the TensorLake sandbox', + description: 'Finds files matching a glob pattern in the Tensorlake sandbox', args: { pattern: z.string(), path: z.string().optional(), }, async execute(args: { pattern: string; path?: string }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) - const searchPath = args.path ?? sessionManager.workDir + const searchPath = args.path ?? sessionManager.projectDir(worktree) const result = await sessionManager .getClient() - .executeCommand(sandboxId, `find ${searchPath} -name "${args.pattern}" 2>/dev/null`, '/') + .executeCommand(sandboxId, `find ${shellQuote(searchPath)} -name ${shellQuote(args.pattern)} 2>/dev/null`, '/') return result.stdout.trim() || '(no matches)' }, }) diff --git a/.opencode/plugin/tensorlake/tools/grep.ts b/.opencode/plugin/tensorlake/tools/grep.ts index 098f544..6b9161d 100644 --- a/.opencode/plugin/tensorlake/tools/grep.ts +++ b/.opencode/plugin/tensorlake/tools/grep.ts @@ -1,15 +1,16 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' +import { shellQuote } from '../core/shell.js' export const grepTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Searches for a text pattern in files in the TensorLake sandbox', + description: 'Searches for a text pattern in files in the Tensorlake sandbox', args: { pattern: z.string(), path: z.string().optional(), @@ -17,9 +18,9 @@ export const grepTool = ( }, async execute(args: { pattern: string; path?: string; filePattern?: string }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) - const searchPath = args.path ?? sessionManager.workDir - const include = args.filePattern ? `--include="${args.filePattern}"` : '' - const cmd = `grep -rn ${include} "${args.pattern}" ${searchPath} 2>/dev/null` + const searchPath = args.path ?? sessionManager.projectDir(worktree) + const include = args.filePattern ? `--include=${shellQuote(args.filePattern)}` : '' + const cmd = `grep -rn ${include} -e ${shellQuote(args.pattern)} -- ${shellQuote(searchPath)} 2>/dev/null` const result = await sessionManager.getClient().executeCommand(sandboxId, cmd, '/') return result.stdout.trim() || '(no matches)' }, diff --git a/.opencode/plugin/tensorlake/tools/ls.ts b/.opencode/plugin/tensorlake/tools/ls.ts index 38b49e7..986307d 100644 --- a/.opencode/plugin/tensorlake/tools/ls.ts +++ b/.opencode/plugin/tensorlake/tools/ls.ts @@ -1,21 +1,21 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const lsTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Lists files in a directory in the TensorLake sandbox', + description: 'Lists files in a directory in the Tensorlake sandbox', args: { dirPath: z.string().optional(), }, async execute(args: { dirPath?: string }, ctx: ToolContext) { const { sandboxId } = await sessionManager.getSandbox(ctx.sessionID, projectId, worktree, pluginCtx) - const path = args.dirPath ?? sessionManager.workDir + const path = args.dirPath ?? sessionManager.projectDir(worktree) const entries = await sessionManager.getClient().listDirectory(sandboxId, path) return entries.map((e) => (e.is_dir ? `${e.name}/` : e.name)).join('\n') }, diff --git a/.opencode/plugin/tensorlake/tools/read.ts b/.opencode/plugin/tensorlake/tools/read.ts index b01a57c..b1d4d63 100644 --- a/.opencode/plugin/tensorlake/tools/read.ts +++ b/.opencode/plugin/tensorlake/tools/read.ts @@ -1,15 +1,15 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' export const readTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Reads a file from the TensorLake sandbox', + description: 'Reads a file from the Tensorlake sandbox', args: { filePath: z.string(), }, diff --git a/.opencode/plugin/tensorlake/tools/write.ts b/.opencode/plugin/tensorlake/tools/write.ts index 9342874..1629d4e 100644 --- a/.opencode/plugin/tensorlake/tools/write.ts +++ b/.opencode/plugin/tensorlake/tools/write.ts @@ -1,15 +1,16 @@ import { z } from 'zod' import type { PluginInput } from '@opencode-ai/plugin' import type { ToolContext } from '@opencode-ai/plugin/tool' -import type { TensorLakeSessionManager } from '../core/session-manager.js' +import type { TensorlakeSessionManager } from '../core/session-manager.js' +import { shellQuote } from '../core/shell.js' export const writeTool = ( - sessionManager: TensorLakeSessionManager, + sessionManager: TensorlakeSessionManager, projectId: string, worktree: string, pluginCtx: PluginInput, ) => ({ - description: 'Writes content to a file in the TensorLake sandbox', + description: 'Writes content to a file in the Tensorlake sandbox', args: { filePath: z.string(), content: z.string(), @@ -19,7 +20,7 @@ export const writeTool = ( const buf = Buffer.from(args.content) const dir = args.filePath.split('/').slice(0, -1).join('/') if (dir) { - await sessionManager.getClient().executeCommand(sandboxId, `mkdir -p ${dir}`, '/').catch(() => {}) + await sessionManager.getClient().executeCommand(sandboxId, `mkdir -p ${shellQuote(dir)}`, '/').catch(() => {}) } await sessionManager.getClient().writeFile(sandboxId, args.filePath, buf) return `Written ${buf.length} bytes to ${args.filePath}` diff --git a/README.md b/README.md index e467ac7..665caed 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,20 @@ # tensorlake-opencode -An OpenCode plugin that runs all AI sessions inside isolated [TensorLake](https://tensorlake.ai) sandboxes. Every bash command, file read/write, and search is executed in the sandbox rather than on your local machine. +An OpenCode plugin that runs all AI sessions inside isolated [Tensorlake](https://tensorlake.ai) sandboxes. Every bash command, file read/write, and search is executed in the sandbox rather than on your local machine. --- ## Overview -When the plugin is active, OpenCode intercepts the standard tool calls (bash, read, write, edit, ls, glob, grep) and routes them to a TensorLake sandbox. +When the plugin is active, OpenCode intercepts the standard tool calls (bash, read, write, edit, ls, glob, grep) and routes them to a Tensorlake sandbox. > **No sandbox is created when you start OpenCode.** The sandbox is provisioned lazily on the model's first tool call in a session. If you launch OpenCode and nothing seems to happen, that's expected — ask the model to run a command to spin one up. - **Sandbox lifecycle** - A sandbox is created lazily on the **first intercepted tool call** in a session (not at launch) and deleted when the session is deleted. Sandbox state is persisted to disk so that reconnection is possible across OpenCode restarts. +- **Project sync** - The project you opened in OpenCode is automatically synced into the sandbox at `/tmp/workspace/`. Git repositories are pushed — with full commit history — to a Tensorlake-hosted git mirror and cloned inside the sandbox; non-git folders are uploaded to a Tensorlake cloud volume that is mounted into the sandbox. See [Project sync](#project-sync). +- **Background processes** - `bash` with `background=true` starts a real long-running process in the sandbox (dev server, watcher, etc.) and returns its pid. Two extra tools, `bash_output` and `bash_kill`, let the model read new output and stop the process. - **Suspension/resume** - If a sandbox is found in a suspended state it is automatically resumed before use. -- **System prompt injection** - A block is appended to the system prompt on every request informing the model that it is operating inside a sandbox at `/tmp/workspace`. +- **System prompt injection** - A block is appended to the system prompt on every request informing the model that it is operating inside a sandbox and where the project lives. - **Toast notifications** - Sandbox status events (created, connected, resumed, deleted) surface as TUI toasts. - **Logging** - All plugin activity is written to `~/.local/share/opencode/log/tensorlake.log`. @@ -20,7 +22,9 @@ When the plugin is active, OpenCode intercepts the standard tool calls (bash, re | OpenCode tool | Sandbox implementation | |---|---| -| `bash` | `sandbox.run('sh', { args: ['-c', cmd] })` via SDK | +| `bash` | `sandbox.run('sh', { args: ['-c', cmd] })` via SDK; `background=true` uses `sandbox.startProcess(...)` and returns a pid | +| `bash_output` *(added)* | `sandbox.getProcess(pid)` + `sandbox.getOutput(pid)` — returns status and output lines new since the last call | +| `bash_kill` *(added)* | `sandbox.killProcess(pid)` | | `read` | `sandbox.readFile(path)` via SDK | | `write` | `sandbox.writeFile(path, content)` via SDK | | `edit` | read + string replace + write | @@ -30,10 +34,66 @@ When the plugin is active, OpenCode intercepts the standard tool calls (bash, re --- +## Project sync + +The first time a sandbox is used (per OpenCode process), the plugin syncs your local project into it so the sandbox is not empty. The mode is chosen automatically: + +| Local project | Sync mode | How it works | +|---|---|---| +| Git repository (has `.git`) | `git` | Your repo's **real commit history** is pushed to a Tensorlake-hosted mirror (`opencode-`) **under your current branch name**, and the mirror is cloned inside the sandbox on that same branch — so `git log`, `git blame`, and `git diff` in the sandbox show your actual commits, authors, and dates. Uncommitted local changes (modified + untracked files) are replayed onto the sandbox working tree, uncommitted, so the sandbox mirrors your laptop exactly. Git credentials and a fallback identity are configured in the sandbox so the model can commit and `git push` to persist changes back to the mirror; **pushed commits are pulled back into your local branch automatically** (see below). A repo with no commits yet is synced as a single snapshot commit instead. | +| Plain folder | `volume` | The folder is uploaded to a Tensorlake cloud volume (`opencode-`) and the volume is mounted into the sandbox. Writes inside the mount are persisted to durable storage automatically and survive sandbox termination. Common build artifacts (`node_modules`, `.venv`, `dist`, `target`, …) and files over 100 MB are skipped. | + +The project lands at `/tmp/workspace/`, which is also the default working directory for `bash`, `ls`, `glob`, and `grep`. + +Override the automatic choice with `TENSORLAKE_SYNC_MODE`: + +```bash +export TENSORLAKE_SYNC_MODE=git # always use git push/clone +export TENSORLAKE_SYNC_MODE=volume # always use a mounted cloud volume +export TENSORLAKE_SYNC_MODE=off # disable project sync (pre-0.2.0 behavior) +``` + +Sync failures are surfaced as a toast and logged, but never block the sandbox — you just get an empty workspace. + +> Sync runs once per sandbox per OpenCode process. Restarting OpenCode re-syncs, picking up local changes (git mode fast-forwards the clone; volume mode uploads only changed content). A sandbox clone that has its own commits or edits is never reset — re-sync only fast-forwards, and uncommitted local changes are only replayed onto a clean sandbox tree. + +### Where agent commits go (git mode) + +**Agent commits do not go to GitHub directly.** Inside the sandbox, `origin` points to the Tensorlake-hosted mirror (`opencode-`) — not to your GitHub/GitLab remote. The plugin never copies your GitHub credentials or remotes into the sandbox. When the agent commits and runs `git push`, the work lands on the mirror, on the same branch you have checked out locally. + +**The plugin pulls agent commits back to your machine automatically.** After each agent turn, it fetches the mirror into `refs/remotes/tensorlake/*` and fast-forwards your local branch when that is safe (your worktree is clean and the histories have not diverged). You see a toast when commits land. So the everyday flow is: + +```bash +# you are on feature-x; the agent works in the sandbox on feature-x +# agent commits and pushes -> your local feature-x advances automatically + +# review, then send it to GitHub from your machine as usual: +git push origin feature-x +gh pr create +``` + +When a fast-forward is not safe, nothing in your checkout is touched. The commits wait on the `tensorlake/` tracking ref, a toast tells you why (uncommitted local changes, or diverged histories), and you merge on your own terms: + +```bash +git stash # if the blocker was uncommitted changes +git merge tensorlake/feature-x +git stash pop +``` + +The mirror is a normal [Tensorlake git repository](https://docs.tensorlake.ai/git/introduction), so the standard `tl git` commands (`tl git list`, `tl git token`, …) work with it if you ever want to inspect it directly. + +To push agent work to GitHub, let the sync-back land it (or merge the tracking ref), then push from your machine. Alternatively, add your GitHub remote and credentials inside the sandbox yourself — the plugin does not do this for you. + +> **Branch naming.** The sync branch is whatever `git branch --show-current` reports on your machine when the sync runs — one name everywhere: local, mirror, and sandbox. A detached HEAD (or a branch name that cannot be embedded safely) syncs as `main`. If you switch local branches, the next sandbox sync follows: a clean sandbox clone switches to the new branch; one with its own edits is left untouched. + +> If you rewrite local history (rebase, amend), the next sync recreates the mirror from your rewritten history — any commits that existed only on the mirror are discarded. The automatic sync-back makes this window small (agent commits normally reach your machine within a turn), but rebasing mid-turn while the agent still has unpushed or unfetched work can lose it. An existing sandbox clone can't fast-forward to rewritten history; delete the session's sandbox to get a fresh clone. + +--- + ## Prerequisites - An OpenCode installation (see [opencode.ai](https://opencode.ai)) -- A TensorLake account and API key (sign up at [tensorlake.ai](https://tensorlake.ai)) +- A Tensorlake account and a **project API key** (sign up at [tensorlake.ai](https://tensorlake.ai), then create a key at [cloud.tensorlake.ai](https://cloud.tensorlake.ai) under your project → API Keys) --- @@ -70,12 +130,26 @@ You can list multiple plugins in the same array: } ``` -### 2. Set your API key +### 2. Log in + +The plugin registers Tensorlake as a provider in OpenCode's standard auth flow. Run: ```bash -export TENSORLAKE_API_KEY=your_api_key_here +opencode auth login ``` +Select **Tensorlake** from the provider list and paste a **project API key** (starts with `tl_apiKey_`). Create one at [cloud.tensorlake.ai](https://cloud.tensorlake.ai) — open your project → **API Keys**. Project keys carry their own organization/project scope, so nothing else is needed. + +The key is stored in OpenCode's credential store (`~/.local/share/opencode/auth.json`) alongside your other provider credentials. It is checked on first use, not at login: if the key is wrong, the first tool call shows an error toast that tells you to log in again. No environment variables, no shell profile edits. + +**CI / automation alternative:** set the `TENSORLAKE_API_KEY` environment variable instead. When both are present, the environment variable wins: + +```bash +export TENSORLAKE_API_KEY=tl_apiKey_... +``` + +Personal Access Tokens are supported only via the env-var path, and additionally require `TENSORLAKE_ORGANIZATION_ID` and `TENSORLAKE_PROJECT_ID`. Prefer project API keys. + ### Local development install OpenCode can also run plugins as TypeScript source files directly via its embedded Bun runtime. Use this option when you are modifying this repository locally. @@ -105,26 +179,33 @@ For local paths: | Variable | Default | Description | |---|---|---| -| `TENSORLAKE_API_KEY` | (required) | Your TensorLake API key | -| `TENSORLAKE_ORGANIZATION_ID` | (required for PAT keys) | Organization ID (e.g. `org_…`). Required when using a Personal Access Token; not needed for project-scoped keys. | -| `TENSORLAKE_PROJECT_ID` | (required for PAT keys) | Project ID (e.g. `project_…`). Required when using a Personal Access Token; not needed for project-scoped keys. | +| `TENSORLAKE_API_KEY` | (optional) | Overrides the key stored by `opencode auth login`. Use for CI/automation; interactive users should prefer `opencode auth login`. | +| `TENSORLAKE_ORGANIZATION_ID` | (required for PAT keys) | Organization ID (e.g. `org_…`). Only needed when `TENSORLAKE_API_KEY` is a Personal Access Token; never needed for project API keys. | +| `TENSORLAKE_PROJECT_ID` | (required for PAT keys) | Project ID (e.g. `project_…`). Only needed when `TENSORLAKE_API_KEY` is a Personal Access Token; never needed for project API keys. | | `TENSORLAKE_API_URL` | `https://api.tensorlake.ai` | Override the management API base URL | | `TENSORLAKE_IMAGE` | (server default) | Container image to use when creating a new sandbox. Leave unset to use the platform default. | | `TENSORLAKE_CPUS` | `2` | Number of vCPUs allocated to the sandbox | | `TENSORLAKE_MEMORY_MB` | `4096` | RAM allocated to the sandbox in MB | | `TENSORLAKE_DISK_MB` | `10240` | Ephemeral disk size allocated to the sandbox in MB | | `TENSORLAKE_SANDBOX_PROXY_URL` | (auto) | Override the sandbox proxy URL (useful for local development). When set, all sandboxes use this single URL instead of the `https://{id}.sandbox.tensorlake.ai` pattern | +| `TENSORLAKE_SYNC_MODE` | `auto` | How the local project is synced into the sandbox: `auto` (git repos → `git`, plain folders → `volume`), `git`, `volume`, or `off` | --- ## How to Test +### Auth flow test + +1. Make sure `TENSORLAKE_API_KEY` is **not** exported and no Tensorlake entry exists in `~/.local/share/opencode/auth.json`. +2. Start OpenCode and ask the model to run a command. The tool call should fail with a **"Tensorlake login required"** toast telling you to run `opencode auth login`. +3. Run `opencode auth login` and select **Tensorlake**. A hint shows where to get a key (press Enter to continue), then paste a project API key at the masked **Enter your API key** prompt. The prompt does not validate the key; a non-project key triggers a **"Check your Tensorlake API key"** warning toast on the first tool call. +4. Retry the tool call in the same OpenCode session — no restart needed. The sandbox should now be created. + ### Basic smoke test -1. Set the environment variable and start OpenCode in a project directory: +1. Log in once (`opencode auth login` → Tensorlake → paste a project API key), then start OpenCode in a project directory: ```bash - export TENSORLAKE_API_KEY=your_key opencode ``` @@ -137,7 +218,7 @@ For local paths: On startup you should see a single line: ``` - [2024-01-15T10:00:01.000Z] [INFO] OpenCode started with TensorLake plugin + [2024-01-15T10:00:01.000Z] [INFO] OpenCode started with Tensorlake plugin ``` If this file doesn't exist after launch, the plugin never loaded — see [Plugin not loading](#plugin-not-loading). @@ -157,19 +238,19 @@ In the OpenCode chat prompt, type: Run: echo "hello from sandbox" && uname -a ``` -The model will call the `bash` tool. Because the plugin intercepts it, the command executes inside the TensorLake sandbox. You should see Linux kernel information from the sandbox VM rather than your local machine. +The model will call the `bash` tool. Because the plugin intercepts it, the command executes inside the Tensorlake sandbox. You should see Linux kernel information from the sandbox VM rather than your local machine. ### File read/write test ``` -Write the text "Hello TensorLake" to /tmp/workspace/test.txt, then read it back. +Write the text "Hello Tensorlake" to /tmp/workspace/test.txt, then read it back. ``` The model will: 1. Call `write` with `filePath=/tmp/workspace/test.txt` → routed to `sandbox.writeFile()` via SDK 2. Call `read` with `filePath=/tmp/workspace/test.txt` → routed to `sandbox.readFile()` via SDK -The response should echo back `Hello TensorLake`. +The response should echo back `Hello Tensorlake`. ### Directory listing test @@ -181,7 +262,7 @@ This triggers the `ls` tool, which calls `sandbox.listDirectory('/tmp/workspace' ### Verifying sandbox deletion -Delete the OpenCode session from the session list. The plugin handles the `session.deleted` event and calls `sdk.delete(sandboxId)` via the TensorLake SDK. Confirm in the log: +Delete the OpenCode session from the session list. The plugin handles the `session.deleted` event and calls `sdk.delete(sandboxId)` via the Tensorlake SDK. Confirm in the log: ``` […] [INFO] Deleting sandbox sandbox-xyz for session abc123 @@ -199,7 +280,8 @@ This is expected. The plugin does **not** create a sandbox at launch — a sandb To start one, ask the model to run a command (e.g. `Run: uname -a`). If you've made a tool call and still see no sandbox, check `~/.local/share/opencode/log/tensorlake.log`: - **File is missing entirely** → the plugin never loaded. See [Plugin not loading](#plugin-not-loading). -- **File exists but logs an auth error (401/403)** → verify `TENSORLAKE_API_KEY` is exported in the same environment OpenCode runs in (`echo $TENSORLAKE_API_KEY`). For PAT keys, also set `TENSORLAKE_ORGANIZATION_ID` and `TENSORLAKE_PROJECT_ID`. +- **"Tensorlake login required" toast / `No Tensorlake credentials found` in the log** → run `opencode auth login`, select Tensorlake, and paste a project API key. No restart is needed — the plugin picks up the new credential within seconds; just retry the tool call. If you intended to use an env var instead, verify it's exported in the same environment OpenCode runs in (`echo $TENSORLAKE_API_KEY`). +- **File exists but logs an auth error (401/403)** → the stored key was deleted or revoked. Re-run `opencode auth login` with a fresh project API key. For env-var PAT keys, also set `TENSORLAKE_ORGANIZATION_ID` and `TENSORLAKE_PROJECT_ID`. - **File exists and logs `Creating new sandbox` but it hangs or errors** → the API call to create the sandbox failed; check the error message in the log. > Note: OpenCode installs the npm package into its own cache (`~/.cache/opencode/packages/`), not your project or global `node_modules`. You don't need to `npm install` the plugin yourself — listing it in `opencode.json` is enough. A manual `npm install` elsewhere has no effect on what OpenCode loads. @@ -251,8 +333,9 @@ opencode-tensorlake-plugin/ ├── index.ts # plugin factory ├── tools.ts # assembles all tools ├── core/ - │ ├── client.ts # TensorLake SDK client (SandboxClient wrapper) + │ ├── client.ts # Tensorlake SDK client (SandboxClient wrapper) │ ├── logger.ts # file-based logger with rotation + │ ├── project-sync.ts # syncs the local project into the sandbox (git/volume) │ ├── session-manager.ts # sandbox lifecycle management │ ├── toast.ts # TUI toast queue │ └── types.ts # shared type definitions @@ -294,7 +377,7 @@ export TENSORLAKE_MEMORY_MB=8192 export TENSORLAKE_DISK_MB=20480 ``` -Or edit the defaults directly in `TensorLakeClient.createSandbox` inside `.opencode/plugin/tensorlake/core/client.ts`: +Or edit the defaults directly in `TensorlakeClient.createSandbox` inside `.opencode/plugin/tensorlake/core/client.ts`: ```typescript const cpus = parseFloat(process.env.TENSORLAKE_CPUS ?? '2') diff --git a/package-lock.json b/package-lock.json index 3f97020..01fe63d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "tensorlake-opencode", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tensorlake-opencode", - "version": "0.1.1", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@opencode-ai/plugin": "1.15.5", - "tensorlake": "0.5.30", + "@opencode-ai/plugin": "^1.18.21", + "tensorlake": "^0.5.112", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" }, @@ -19,10 +19,63 @@ "typescript": "^5.4.0" } }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", - "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", "cpu": [ "arm64" ], @@ -33,9 +86,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", - "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", "cpu": [ "x64" ], @@ -46,9 +99,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", - "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", "cpu": [ "arm" ], @@ -59,9 +112,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", - "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", "cpu": [ "arm64" ], @@ -72,9 +125,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", - "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", "cpu": [ "x64" ], @@ -85,9 +138,9 @@ ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", - "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", "cpu": [ "x64" ], @@ -98,19 +151,20 @@ ] }, "node_modules/@opencode-ai/plugin": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.5.tgz", - "integrity": "sha512-QvhrDLlQuLeFGET1zB2crMWPvou6PpAzYtKrbun9akWvaskyAcXIAVn3lNYX/InMcut5VzL6ERbPgvM7ucHfLA==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.21.tgz", + "integrity": "sha512-wQXMbSvg3pG75F8fYAiJxYuyr6Cl9Hd74lSCY2yznZnejpwa+ksd2kMphmgSo+/UgLhb2AId9KAI6dsRhprzTg==", "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.15.5", - "effect": "4.0.0-beta.65", + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.21", + "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { - "@opentui/core": ">=0.2.14", - "@opentui/keymap": ">=0.2.14", - "@opentui/solid": ">=0.2.14" + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" }, "peerDependenciesMeta": { "@opentui/core": { @@ -134,14 +188,71 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.5.tgz", - "integrity": "sha512-ozJuEmXzrOvia5n0L1KAuvpyf9ESGmTk1FiPhn0RK5X1whbzjlTXL0NAxqNCEkqETxL35jS1KHArEiTpvtJ6FQ==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.21.tgz", + "integrity": "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -152,12 +263,83 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -183,27 +365,42 @@ } }, "node_modules/effect": { - "version": "4.0.0-beta.65", - "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.65.tgz", - "integrity": "sha512-QYKvQPAj3CmtsvWkHQww15wX4KG2gNsszDWEcOO5sZCMknp66u6Si/Opmt3wwWCwsyvRmDAdIg+JIz5qzbbFIw==", + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "fast-check": "^4.6.0", + "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", - "ini": "^6.0.0", + "ini": "^7.0.0", "kubernetes-types": "^1.30.0", - "msgpackr": "^1.11.9", + "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", - "uuid": "^13.0.0", - "yaml": "^2.8.3" + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" } }, "node_modules/fast-check": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.8.0.tgz", - "integrity": "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", "funding": [ { "type": "individual", @@ -222,19 +419,78 @@ "node": ">=12.17.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/find-my-way-ts": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", "license": "MIT" }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/google-proto-files": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/google-proto-files/-/google-proto-files-5.0.3.tgz", + "integrity": "sha512-HrKG4IIPUHaU1E4+J8yETm41N39+pm9z+ZBERNcq+q8ltHRcFwTcxSviqJSuxPiZJNTWmdBhp03xcElCRlQPxA==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.5.4", + "walkdir": "^0.4.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", "license": "ISC", "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/isexe": { @@ -243,25 +499,49 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/kubernetes-types": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", "license": "Apache-2.0" }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/msgpackr": { - "version": "1.11.12", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.12.tgz", - "integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", "license": "MIT", "optionalDependencies": { - "msgpackr-extract": "^3.0.2" + "msgpackr-extract": "^3.0.4" } }, "node_modules/msgpackr-extract": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", - "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", "hasInstallScript": true, "license": "MIT", "optional": true, @@ -272,20 +552,38 @@ "download-msgpackr-prebuilds": "bin/download-prebuilds.js" }, "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" + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, "node_modules/multipasta": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", - "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", "license": "MIT" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", @@ -310,10 +608,33 @@ "node": ">=8" } }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/pure-rand": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", - "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", "funding": [ { "type": "individual", @@ -326,6 +647,24 @@ ], "license": "MIT" }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -347,30 +686,62 @@ "node": ">=8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/tensorlake": { - "version": "0.5.30", - "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.30.tgz", - "integrity": "sha512-XCNgeaYB2upve/BFs6yYbD7m4X+st7KTamti9V27sWFJmu5v7mXaYzEDoKuy5YfNGJE9WbeOyZqtNBcsjCMdsA==", + "version": "0.5.112", + "resolved": "https://registry.npmjs.org/tensorlake/-/tensorlake-0.5.112.tgz", + "integrity": "sha512-Issapl4HIyah5t3BQeAj3Ym9D6G5K6IWoHevqaW3/Cq8Jdp0Tgnx/bPEq1ku4n8jlx6NQbYioCHfdZWZerh5YQ==", "license": "Apache-2.0", "dependencies": { - "undici": "^8.1.0", + "@grpc/grpc-js": "^1.13.0", + "@grpc/proto-loader": "^0.8.0", + "ajv": "^8.17.1", + "fflate": "^0.8.2", + "google-proto-files": "^5.0.2", + "nanoid": "3.3.11", + "undici": "8.3.0", "ws": "^8.20.0" }, "bin": { - "function-executor": "bin/function-executor.cjs", - "tensorlake": "bin/tensorlake.cjs", - "tensorlake-create-sandbox-image": "bin/tensorlake-create-sandbox-image.cjs", + "function-executor": "bin/function-executor.js", + "tensorlake-create-sandbox-image": "bin/tensorlake-create-sandbox-image.js", "tensorlake-deploy": "bin/tensorlake-deploy.cjs", - "tl": "bin/tl.cjs" + "tensorlake-import-sandbox-image": "bin/tensorlake-import-sandbox-image.js", + "tensorlake-typescript-function-runner": "bin/tensorlake-typescript-function-runner.js" }, "engines": { "node": ">=22.0.0" } }, "node_modules/toml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", - "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", "license": "MIT", "engines": { "node": ">=20" @@ -403,13 +774,12 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, "license": "MIT" }, "node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -419,6 +789,15 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/walkdir": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/walkdir/-/walkdir-0.4.1.tgz", + "integrity": "sha512-3eBwRyEln6E1MSzcxcVpQIhRG8Q1jLvEqRmCZqS3dsfXEDR/AhOF4d+jHg1qvDCpYaVRZjENPQyrVxAkQqxPgQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -434,6 +813,23 @@ "node": ">= 8" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/ws": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", @@ -467,6 +863,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yaml": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", @@ -482,6 +887,33 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 1ec4b4b..9a21215 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "tensorlake-opencode", - "version": "0.1.1", + "version": "0.2.0", "license": "Apache-2.0", - "description": "OpenCode plugin that runs all sessions in TensorLake sandboxes for isolated execution environments", + "description": "OpenCode plugin that runs all sessions in Tensorlake sandboxes for isolated execution environments", "keywords": [ "tensorlake", "opencode", @@ -28,8 +28,8 @@ "type-check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { - "@opencode-ai/plugin": "1.15.5", - "tensorlake": "0.5.30", + "@opencode-ai/plugin": "^1.18.21", + "tensorlake": "^0.5.112", "xdg-basedir": "^5.1.0", "zod": "^4.2.1" }, From 2c349981f9847ccc867db343056d906320ccfb23 Mon Sep 17 00:00:00 2001 From: shanshan wang Date: Fri, 21 Aug 2026 23:36:27 -0500 Subject: [PATCH 2/2] rename the tensorlake hosted git --- .../plugin/tensorlake/core/project-sync.ts | 92 +++++++++---------- .../plugin/tensorlake/core/session-manager.ts | 10 +- .../tensorlake/plugins/session-events.ts | 2 +- README.md | 22 +++-- 4 files changed, 67 insertions(+), 59 deletions(-) diff --git a/.opencode/plugin/tensorlake/core/project-sync.ts b/.opencode/plugin/tensorlake/core/project-sync.ts index f158afa..27d9c15 100644 --- a/.opencode/plugin/tensorlake/core/project-sync.ts +++ b/.opencode/plugin/tensorlake/core/project-sync.ts @@ -139,7 +139,7 @@ const SAFE_BRANCH_RE = /^[A-Za-z0-9._/-]+$/ /** * The branch the project syncs under: the local checkout's current branch, - * kept under the same name on the mirror and in the sandbox clone, so agent + * kept under the same name on the sync repo and in the sandbox clone, so agent * pushes land exactly where the user expects. Falls back to 'main' for a * detached HEAD or a name that cannot be embedded safely. */ @@ -187,10 +187,10 @@ function redactAuth(text: string): string { return text.replace(/Basic [A-Za-z0-9+/=]+/g, 'Basic ***') } -// Throwaway ref the mirror's sync branch is fetched into for the ancestry +// Throwaway ref the sync repo's sync branch is fetched into for the ancestry // check, so the user's FETCH_HEAD (which their own fetch/merge workflows may // be reading) is never touched. -const MIRROR_CHECK_REF = 'refs/tensorlake/mirror-check' +const SYNC_REPO_CHECK_REF = 'refs/tensorlake/sync-repo-check' /** True when `ancestor` is reachable from `descendant`. */ async function isAncestor(worktree: string, ancestor: string, descendant: string): Promise { @@ -207,12 +207,12 @@ async function isAncestor(worktree: string, ancestor: string, descendant: string } /** - * True when the mirror's sync branch already contains local HEAD — i.e. the - * mirror is simply ahead (an agent pushed commits the laptop hasn't fetched + * True when the sync repo's sync branch already contains local HEAD — i.e. the + * sync repo is simply ahead (an agent pushed commits the laptop hasn't fetched * yet), as opposed to local history having been rewritten. Fetches the - * mirror's branch into a temporary ref to make the ancestry check possible. + * sync repo's branch into a temporary ref to make the ancestry check possible. */ -async function mirrorContainsLocalHead( +async function syncRepoContainsLocalHead( repos: RepositoryClient, repo: string, worktree: string, @@ -226,22 +226,22 @@ async function mirrorContainsLocalHead( '--quiet', '--no-write-fetch-head', repos.url(repo), - `+refs/heads/${branch}:${MIRROR_CHECK_REF}`, + `+refs/heads/${branch}:${SYNC_REPO_CHECK_REF}`, ]) try { - return await isAncestor(worktree, 'HEAD', MIRROR_CHECK_REF) + return await isAncestor(worktree, 'HEAD', SYNC_REPO_CHECK_REF) } finally { - await localGit(worktree, ['update-ref', '-d', MIRROR_CHECK_REF]).catch(() => {}) + await localGit(worktree, ['update-ref', '-d', SYNC_REPO_CHECK_REF]).catch(() => {}) } } /** - * Push the local repository's real history (HEAD) to the mirror's sync + * Push the local repository's real history (HEAD) to the sync repo's sync * branch, preserving commits, authors, and dates. A non-fast-forward rejection - * has two causes that must be told apart: the mirror being ahead of the laptop + * has two causes that must be told apart: the sync repo being ahead of the laptop * (agents commit and push their work to it), in which case the push is simply * skipped, or local history having been rewritten by a rebase or amend, in - * which case the mirror is deleted, recreated, and pushed fresh. + * which case the sync repo is deleted, recreated, and pushed fresh. */ async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: string, branch: string): Promise { const push = async () => { @@ -253,26 +253,26 @@ async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: } catch (err: any) { const detail = redactAuth(`${err?.stderr ?? ''} ${err?.message ?? err}`) if (!/non-fast-forward|\[rejected\]|failed to push some refs|fetch first/i.test(detail)) { - throw new Error(`git push to hosted mirror ${repo} failed: ${detail}`) + throw new Error(`git push to sync repo ${repo} failed: ${detail}`) } let remoteAhead: boolean try { - remoteAhead = await mirrorContainsLocalHead(repos, repo, worktree, branch) + remoteAhead = await syncRepoContainsLocalHead(repos, repo, worktree, branch) } catch (checkErr: any) { - // Indeterminate state: never recreate the mirror without proof of a - // rewrite, or agent commits that exist only on the mirror would be lost. + // Indeterminate state: never recreate the sync repo without proof of a + // rewrite, or agent commits that exist only on the sync repo would be lost. throw new Error( - `Hosted mirror ${repo} rejected a non-fast-forward push and its state could not be inspected; not recreating it to avoid discarding remote-only commits: ${redactAuth(`${checkErr?.stderr ?? ''} ${checkErr?.message ?? checkErr}`)}`, + `Sync repo ${repo} rejected a non-fast-forward push and its state could not be inspected; not recreating it to avoid discarding remote-only commits: ${redactAuth(`${checkErr?.stderr ?? ''} ${checkErr?.message ?? checkErr}`)}`, ) } if (remoteAhead) { logger.info( - `Hosted mirror ${repo} is ahead of local HEAD (agent commits not yet fetched locally); skipping push — local history is already on the mirror.`, + `Sync repo ${repo} is ahead of local HEAD (agent commits not yet fetched locally); skipping push — local history is already on the sync repo.`, ) return } logger.warn( - `Hosted mirror ${repo} rejected a non-fast-forward push and its ${branch} branch does not contain local HEAD (local history was rewritten); recreating the mirror. Commits that existed only on the mirror are discarded.`, + `Sync repo ${repo} rejected a non-fast-forward push and its ${branch} branch does not contain local HEAD (local history was rewritten); recreating the sync repo. Commits that existed only on the sync repo are discarded.`, ) await repos.delete(repo) await repos.create(repo, { defaultBranch: branch }) @@ -280,7 +280,7 @@ async function pushRealHistory(repos: RepositoryClient, repo: string, worktree: await push() } catch (err2: any) { throw new Error( - `git push after recreating mirror ${repo} failed: ${redactAuth(`${err2?.stderr ?? ''} ${err2?.message ?? err2}`)}`, + `git push after recreating sync repo ${repo} failed: ${redactAuth(`${err2?.stderr ?? ''} ${err2?.message ?? err2}`)}`, ) } } @@ -316,7 +316,7 @@ async function buildUncommittedPatch(worktree: string): Promise { /** * Replicate uncommitted local changes into the sandbox working tree without - * committing them, so the sandbox mirrors the laptop's exact state. Applied + * committing them, so the sandbox matches the laptop's exact state. Applied * only when the sandbox tree is clean and at the same HEAD as the laptop — * agent work in the sandbox is never overwritten. */ @@ -358,8 +358,8 @@ async function applyUncommittedChanges( /** * Sync the local git repository into the sandbox with full commit history: - * push real refs to a Tensorlake-hosted mirror, then clone (or fast-forward) - * the mirror inside the sandbox at `destDir`, and finally replay uncommitted + * push real refs to a Tensorlake-sync repo, then clone (or fast-forward) + * the sync repo inside the sandbox at `destDir`, and finally replay uncommitted * local changes onto the sandbox working tree. A repo with no commits yet * falls back to a single snapshot commit via pushWorktree. */ @@ -391,7 +391,7 @@ export async function syncGitProject( if (localHead) { logger.info( - `Pushing real history (HEAD ${localHead.slice(0, 8)}, branch ${branch}) from ${worktree} to hosted mirror ${repo}`, + `Pushing real history (HEAD ${localHead.slice(0, 8)}, branch ${branch}) from ${worktree} to sync repo ${repo}`, ) await pushRealHistory(repos, repo, worktree, branch) } else { @@ -454,7 +454,7 @@ export async function syncGitProject( } } -// Git credential store line for the mirror, in git's expected +// Git credential store line for the sync repo, in git's expected // https://user:token@host format. async function credentialLine(repos: RepositoryClient, repo: string): Promise { const cred = await repos.credential(repo) @@ -500,14 +500,14 @@ export type SyncBackResult = | { kind: 'staged'; branch: string; ref: string; commits: number; oid: string; reason: string } /** - * Pull agent commits from the hosted mirror back into the local repository. - * Fetches every mirror branch into refs/remotes/tensorlake/* (so nothing an - * agent pushed is ever stranded on the mirror), then fast-forwards the local + * Pull agent commits from the sync repo back into the local repository. + * Fetches every sync repo branch into refs/remotes/tensorlake/* (so nothing an + * agent pushed is ever stranded on the sync repo), then fast-forwards the local * sync branch when that is safe. Unsafe cases — a dirty worktree or diverged * histories — leave the commits on the tensorlake/ tracking ref for * the user to merge deliberately; the local checkout is never disturbed. */ -export async function syncBackFromMirror( +export async function syncBackFromSyncRepo( apiKey: string, worktree: string, projectId: string, @@ -518,7 +518,7 @@ export async function syncBackFromMirror( try { const cred = await repos.credential(repo) // --prune drops tracking refs for branches deleted (or recreated) on the - // mirror, so a stale tensorlake/* ref can't masquerade as agent work. + // sync repo, so a stale tensorlake/* ref can't masquerade as agent work. await localGit(worktree, [ '-c', gitAuthConfig(cred), @@ -535,11 +535,11 @@ export async function syncBackFromMirror( const trackingRef = `refs/remotes/tensorlake/${branch}` const shortRef = `tensorlake/${branch}` - let mirrorOid: string + let syncRepoOid: string try { - mirrorOid = (await localGit(worktree, ['rev-parse', '--verify', '--quiet', trackingRef])).trim() + syncRepoOid = (await localGit(worktree, ['rev-parse', '--verify', '--quiet', trackingRef])).trim() } catch { - return { kind: 'up-to-date' } // branch not on the mirror yet + return { kind: 'up-to-date' } // branch not on the sync repo yet } let localOid: string @@ -548,17 +548,17 @@ export async function syncBackFromMirror( } catch { // No local branch to advance (e.g. a repo that had no commits at sync // time). The fetch above already saved the agent's work locally. - return { kind: 'staged', branch, ref: shortRef, commits: 0, oid: mirrorOid, reason: `local branch ${branch} not found` } + return { kind: 'staged', branch, ref: shortRef, commits: 0, oid: syncRepoOid, reason: `local branch ${branch} not found` } } - if (mirrorOid === localOid) return { kind: 'up-to-date' } - // Mirror behind the laptop: the next inbound sync's push handles that. - if (await isAncestor(worktree, mirrorOid, localOid)) return { kind: 'up-to-date' } + if (syncRepoOid === localOid) return { kind: 'up-to-date' } + // Sync repo behind the laptop: the next inbound sync's push handles that. + if (await isAncestor(worktree, syncRepoOid, localOid)) return { kind: 'up-to-date' } const commits = - parseInt((await localGit(worktree, ['rev-list', '--count', `${localOid}..${mirrorOid}`])).trim(), 10) || 0 - if (!(await isAncestor(worktree, localOid, mirrorOid))) { - return { kind: 'staged', branch, ref: shortRef, commits, oid: mirrorOid, reason: 'local and agent histories diverged' } + parseInt((await localGit(worktree, ['rev-list', '--count', `${localOid}..${syncRepoOid}`])).trim(), 10) || 0 + if (!(await isAncestor(worktree, localOid, syncRepoOid))) { + return { kind: 'staged', branch, ref: shortRef, commits, oid: syncRepoOid, reason: 'local and agent histories diverged' } } const current = (await localGit(worktree, ['branch', '--show-current'])).trim() @@ -566,17 +566,17 @@ export async function syncBackFromMirror( // The branch exists but is not checked out (detached HEAD fallback): // advance the ref directly — no worktree files are involved. The // old-value argument makes it a compare-and-swap against races. - await localGit(worktree, ['update-ref', `refs/heads/${branch}`, mirrorOid, localOid]) - return { kind: 'fast-forwarded', branch, commits, oid: mirrorOid } + await localGit(worktree, ['update-ref', `refs/heads/${branch}`, syncRepoOid, localOid]) + return { kind: 'fast-forwarded', branch, commits, oid: syncRepoOid } } const dirty = (await localGit(worktree, ['status', '--porcelain'])).trim() !== '' if (dirty) { - return { kind: 'staged', branch, ref: shortRef, commits, oid: mirrorOid, reason: 'local uncommitted changes' } + return { kind: 'staged', branch, ref: shortRef, commits, oid: syncRepoOid, reason: 'local uncommitted changes' } } - await localGit(worktree, ['merge', '--ff-only', '--quiet', mirrorOid]) - return { kind: 'fast-forwarded', branch, commits, oid: mirrorOid } + await localGit(worktree, ['merge', '--ff-only', '--quiet', syncRepoOid]) + return { kind: 'fast-forwarded', branch, commits, oid: syncRepoOid } } /** diff --git a/.opencode/plugin/tensorlake/core/session-manager.ts b/.opencode/plugin/tensorlake/core/session-manager.ts index 63b211a..8e24dc7 100644 --- a/.opencode/plugin/tensorlake/core/session-manager.ts +++ b/.opencode/plugin/tensorlake/core/session-manager.ts @@ -10,7 +10,7 @@ import { resolveSyncMode, projectDirName, syncGitProject, - syncBackFromMirror, + syncBackFromSyncRepo, ensureVolumeWithProject, ensureVolumeMounted, refreshSandboxGitCredential, @@ -36,7 +36,7 @@ export class TensorlakeSessionManager { private readonly syncBackInflight = new Map>() // Last failed sync-back per worktree — retried after a cooldown instead of on every idle private readonly syncBackFailedAt = new Map() - // Mirror commit already reported as "staged, not merged" per worktree — suppresses repeat toasts + // Sync repo commit already reported as "staged, not merged" per worktree — suppresses repeat toasts private readonly lastStagedOid = new Map() // Keys already checked for project scope — the warning fires once per key private readonly warnedKeys = new Set() @@ -181,7 +181,7 @@ export class TensorlakeSessionManager { } /** - * Pull agent commits from the hosted mirror back into the local checkout. + * Pull agent commits from the sync repo back into the local checkout. * Runs after each agent turn (session.idle) so a "commit and push" by the * agent lands on the user's local branch within seconds; a turn where the * agent pushed nothing costs one cheap fetch. Only sessions that actually @@ -201,7 +201,7 @@ export class TensorlakeSessionManager { private async _syncBack(worktree: string, projectId: string): Promise { try { - const result = await syncBackFromMirror(this.client.getApiKey(), worktree, projectId) + const result = await syncBackFromSyncRepo(this.client.getApiKey(), worktree, projectId) if (result.kind === 'fast-forwarded') { this.lastStagedOid.delete(worktree) logger.info(`Sync-back: fast-forwarded local ${result.branch} by ${result.commits} agent commit(s)`) @@ -221,7 +221,7 @@ export class TensorlakeSessionManager { } } catch (err: any) { this.syncBackFailedAt.set(worktree, Date.now()) - logger.warn(`Sync-back from mirror failed: ${err?.message ?? err}`) + logger.warn(`Sync-back from sync repo failed: ${err?.message ?? err}`) } } diff --git a/.opencode/plugin/tensorlake/plugins/session-events.ts b/.opencode/plugin/tensorlake/plugins/session-events.ts index 0e7e6d4..d0a9432 100644 --- a/.opencode/plugin/tensorlake/plugins/session-events.ts +++ b/.opencode/plugin/tensorlake/plugins/session-events.ts @@ -16,7 +16,7 @@ export async function eventHandlers(ctx: PluginInput, sessionManager: Tensorlake return async (args: any) => { const event = args.event if (event.type === EVENT_TYPE_SESSION_IDLE) { - // The agent just finished a turn: pull whatever it pushed to the mirror + // The agent just finished a turn: pull whatever it pushed to the sync repo // back into the local checkout. Failures are logged inside syncBack. const sessionId = (event as EventSessionIdle).properties.sessionID await sessionManager.syncBack(sessionId, projectId, worktree) diff --git a/README.md b/README.md index 665caed..3213bfb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ When the plugin is active, OpenCode intercepts the standard tool calls (bash, re > **No sandbox is created when you start OpenCode.** The sandbox is provisioned lazily on the model's first tool call in a session. If you launch OpenCode and nothing seems to happen, that's expected — ask the model to run a command to spin one up. - **Sandbox lifecycle** - A sandbox is created lazily on the **first intercepted tool call** in a session (not at launch) and deleted when the session is deleted. Sandbox state is persisted to disk so that reconnection is possible across OpenCode restarts. -- **Project sync** - The project you opened in OpenCode is automatically synced into the sandbox at `/tmp/workspace/`. Git repositories are pushed — with full commit history — to a Tensorlake-hosted git mirror and cloned inside the sandbox; non-git folders are uploaded to a Tensorlake cloud volume that is mounted into the sandbox. See [Project sync](#project-sync). +- **Project sync** - The project you opened in OpenCode is automatically synced into the sandbox at `/tmp/workspace/`. Git repositories are pushed — with full commit history — to a Tensorlake-hosted git repository and cloned inside the sandbox; non-git folders are uploaded to a Tensorlake cloud volume that is mounted into the sandbox. See [Project sync](#project-sync). - **Background processes** - `bash` with `background=true` starts a real long-running process in the sandbox (dev server, watcher, etc.) and returns its pid. Two extra tools, `bash_output` and `bash_kill`, let the model read new output and stop the process. - **Suspension/resume** - If a sandbox is found in a suspended state it is automatically resumed before use. - **System prompt injection** - A block is appended to the system prompt on every request informing the model that it is operating inside a sandbox and where the project lives. @@ -40,7 +40,7 @@ The first time a sandbox is used (per OpenCode process), the plugin syncs your l | Local project | Sync mode | How it works | |---|---|---| -| Git repository (has `.git`) | `git` | Your repo's **real commit history** is pushed to a Tensorlake-hosted mirror (`opencode-`) **under your current branch name**, and the mirror is cloned inside the sandbox on that same branch — so `git log`, `git blame`, and `git diff` in the sandbox show your actual commits, authors, and dates. Uncommitted local changes (modified + untracked files) are replayed onto the sandbox working tree, uncommitted, so the sandbox mirrors your laptop exactly. Git credentials and a fallback identity are configured in the sandbox so the model can commit and `git push` to persist changes back to the mirror; **pushed commits are pulled back into your local branch automatically** (see below). A repo with no commits yet is synced as a single snapshot commit instead. | +| Git repository (has `.git`) | `git` | Your repo's **real commit history** is pushed to the **sync repo** — a [Tensorlake git repository](https://docs.tensorlake.ai/git/introduction) the plugin creates for your project, named `opencode-` — **under your current branch name**, and the sync repo is cloned inside the sandbox on that same branch — so `git log`, `git blame`, and `git diff` in the sandbox show your actual commits, authors, and dates. Uncommitted local changes (modified + untracked files) are replayed onto the sandbox working tree, uncommitted, so the sandbox matches your laptop exactly. Git credentials and a fallback identity are configured in the sandbox so the model can commit and `git push` to persist changes back to the sync repo; **pushed commits are pulled back into your local branch automatically** (see below). A repo with no commits yet is synced as a single snapshot commit instead. | | Plain folder | `volume` | The folder is uploaded to a Tensorlake cloud volume (`opencode-`) and the volume is mounted into the sandbox. Writes inside the mount are persisted to durable storage automatically and survive sandbox termination. Common build artifacts (`node_modules`, `.venv`, `dist`, `target`, …) and files over 100 MB are skipped. | The project lands at `/tmp/workspace/`, which is also the default working directory for `bash`, `ls`, `glob`, and `grep`. @@ -59,9 +59,9 @@ Sync failures are surfaced as a toast and logged, but never block the sandbox ### Where agent commits go (git mode) -**Agent commits do not go to GitHub directly.** Inside the sandbox, `origin` points to the Tensorlake-hosted mirror (`opencode-`) — not to your GitHub/GitLab remote. The plugin never copies your GitHub credentials or remotes into the sandbox. When the agent commits and runs `git push`, the work lands on the mirror, on the same branch you have checked out locally. +**Agent commits do not go to GitHub directly.** Inside the sandbox, `origin` points to the sync repo — not to your GitHub/GitLab remote. The plugin never copies your GitHub credentials or remotes into the sandbox. When the agent commits and runs `git push`, the work lands on the sync repo, on the same branch you have checked out locally. -**The plugin pulls agent commits back to your machine automatically.** After each agent turn, it fetches the mirror into `refs/remotes/tensorlake/*` and fast-forwards your local branch when that is safe (your worktree is clean and the histories have not diverged). You see a toast when commits land. So the everyday flow is: +**The plugin pulls agent commits back to your machine automatically.** After each agent turn, it fetches the sync repo into `refs/remotes/tensorlake/*` and fast-forwards your local branch when that is safe (your worktree is clean and the histories have not diverged). You see a toast when commits land. So the everyday flow is: ```bash # you are on feature-x; the agent works in the sandbox on feature-x @@ -80,13 +80,21 @@ git merge tensorlake/feature-x git stash pop ``` -The mirror is a normal [Tensorlake git repository](https://docs.tensorlake.ai/git/introduction), so the standard `tl git` commands (`tl git list`, `tl git token`, …) work with it if you ever want to inspect it directly. +The sync repo is a normal Tensorlake git repository, so the standard `tl git` commands (`tl git list`, `tl git token`, …) work with it if you ever want to inspect it directly. To push agent work to GitHub, let the sync-back land it (or merge the tracking ref), then push from your machine. Alternatively, add your GitHub remote and credentials inside the sandbox yourself — the plugin does not do this for you. -> **Branch naming.** The sync branch is whatever `git branch --show-current` reports on your machine when the sync runs — one name everywhere: local, mirror, and sandbox. A detached HEAD (or a branch name that cannot be embedded safely) syncs as `main`. If you switch local branches, the next sandbox sync follows: a clean sandbox clone switches to the new branch; one with its own edits is left untouched. +### Why a sync repo instead of cloning your remote in the sandbox? -> If you rewrite local history (rebase, amend), the next sync recreates the mirror from your rewritten history — any commits that existed only on the mirror are discarded. The automatic sync-back makes this window small (agent commits normally reach your machine within a turn), but rebasing mid-turn while the agent still has unpushed or unfetched work can lose it. An existing sandbox clone can't fast-forward to rewritten history; delete the session's sandbox to get a fresh clone. +The agent could simply `git clone` your GitHub repo inside the sandbox. The sync repo exists because that has three problems the plugin is designed to avoid: + +- **No GitHub credentials in the sandbox.** The sandbox runs agent-generated code. A GitHub token inside it can leak. The sandbox only receives a Tensorlake credential scoped to the one sync repo — your GitHub account is unreachable by construction. +- **The sandbox matches your laptop, not your remote.** A clone of GitHub misses unpushed commits, uncommitted edits, and untracked files. The sync includes all of them, and works for repos with no remote at all. +- **A review gate.** Agent pushes land on the sync repo and sync back to your local branch. Only you push to GitHub, after review — the agent never can. + +> **Branch naming.** The sync branch is whatever `git branch --show-current` reports on your machine when the sync runs — one name everywhere: local, sync repo, and sandbox. A detached HEAD (or a branch name that cannot be embedded safely) syncs as `main`. If you switch local branches, the next sandbox sync follows: a clean sandbox clone switches to the new branch; one with its own edits is left untouched. + +> If you rewrite local history (rebase, amend), the next sync recreates the sync repo from your rewritten history — any commits that existed only on the sync repo are discarded. The automatic sync-back makes this window small (agent commits normally reach your machine within a turn), but rebasing mid-turn while the agent still has unpushed or unfetched work can lose it. An existing sandbox clone can't fast-forward to rewritten history; delete the session's sandbox to get a fresh clone. ---