Merge branch 'main' into fix/costs-of-subagents

This commit is contained in:
Marian Alexandru Alecu
2026-04-27 11:48:10 +03:00
committed by GitHub
234 changed files with 12745 additions and 6622 deletions
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"@kilocode/kilo-gateway": patch
---
Fix multi-turn DeepSeek reasoning round-tripping on OpenRouter by bumping `@openrouter/ai-sdk-provider` to 2.8.1 in both the CLI and Kilo Gateway packages and letting the SDK handle reasoning details, plus pulling in upstream DeepSeek variant, reasoning-effort, and assistant-reasoning fixes. New DeepSeek conversations are fixed; existing sessions that already stored empty reasoning metadata may still need to be restarted.
+271 -10
View File
@@ -5,21 +5,22 @@
"packages": {
"": {
"dependencies": {
"@kilocode/plugin": "7.2.14"
"@kilocode/plugin": "7.2.21"
}
},
"node_modules/@kilocode/plugin": {
"version": "7.2.14",
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.14.tgz",
"integrity": "sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==",
"version": "7.2.21",
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.21.tgz",
"integrity": "sha512-PUo1Vc7OmOHZAxOdrFsez01e+JAX7jhyGw0caBOuXwAWuT39tGISjrUQoeW5k2/VqkZv3V8fPyvcuto0iuNeDA==",
"license": "MIT",
"dependencies": {
"@kilocode/sdk": "7.2.14",
"@kilocode/sdk": "7.2.21",
"effect": "4.0.0-beta.48",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.97",
"@opentui/solid": ">=0.1.97"
"@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.100"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -31,14 +32,98 @@
}
},
"node_modules/@kilocode/sdk": {
"version": "7.2.14",
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.14.tgz",
"integrity": "sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==",
"version": "7.2.21",
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.21.tgz",
"integrity": "sha512-iqhUEAEF4ouqXdlm3CuVCCh07FhMpjVT/QfZqDFAis7PO0JTmR76sCAMtqSzv8nogt/CFuR/vgpK79d+xZcM2g==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"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==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"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==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"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==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"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==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"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==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"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==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -53,12 +138,135 @@
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.48",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"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/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"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/msgpackr": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"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==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"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"
}
},
"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==",
"license": "MIT"
},
"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",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -68,6 +276,22 @@
"node": ">=8"
}
},
"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==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -89,6 +313,28 @@
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -104,6 +350,21 @@
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
+271 -10
View File
@@ -5,21 +5,22 @@
"packages": {
"": {
"dependencies": {
"@kilocode/plugin": "7.2.14"
"@kilocode/plugin": "7.2.21"
}
},
"node_modules/@kilocode/plugin": {
"version": "7.2.14",
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.14.tgz",
"integrity": "sha512-mS+WA9HZIBH2qQ9ARA+v0q4MdQTSdfOvKbe4AOSkjP+P5hVA70OM/UVM9DVcvmjSOxU+wuUxmOy+j/EQIrgFmw==",
"version": "7.2.21",
"resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.21.tgz",
"integrity": "sha512-PUo1Vc7OmOHZAxOdrFsez01e+JAX7jhyGw0caBOuXwAWuT39tGISjrUQoeW5k2/VqkZv3V8fPyvcuto0iuNeDA==",
"license": "MIT",
"dependencies": {
"@kilocode/sdk": "7.2.14",
"@kilocode/sdk": "7.2.21",
"effect": "4.0.0-beta.48",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.1.97",
"@opentui/solid": ">=0.1.97"
"@opentui/core": ">=0.1.100",
"@opentui/solid": ">=0.1.100"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -31,14 +32,98 @@
}
},
"node_modules/@kilocode/sdk": {
"version": "7.2.14",
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.14.tgz",
"integrity": "sha512-Naz83lFrsbavuDp6UwxRuglOaSNvRBsZfcRNvb7RpWYAwbuJP0dBdhpXj6uO3ta5qxeQ2JzxKNC9Ffz+LCLLDg==",
"version": "7.2.21",
"resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.21.tgz",
"integrity": "sha512-iqhUEAEF4ouqXdlm3CuVCCh07FhMpjVT/QfZqDFAis7PO0JTmR76sCAMtqSzv8nogt/CFuR/vgpK79d+xZcM2g==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"
}
},
"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==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"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==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"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==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"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==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"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==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"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==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -53,12 +138,135 @@
"node": ">= 8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/effect": {
"version": "4.0.0-beta.48",
"resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz",
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"fast-check": "^4.6.0",
"find-my-way-ts": "^0.1.6",
"ini": "^6.0.0",
"kubernetes-types": "^1.30.0",
"msgpackr": "^1.11.9",
"multipasta": "^0.2.7",
"toml": "^4.1.1",
"uuid": "^13.0.0",
"yaml": "^2.8.3"
}
},
"node_modules/fast-check": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz",
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT",
"dependencies": {
"pure-rand": "^8.0.0"
},
"engines": {
"node": ">=12.17.0"
}
},
"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/ini": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz",
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
"license": "ISC",
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
"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/msgpackr": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz",
"integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.2"
}
},
"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==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"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"
}
},
"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==",
"license": "MIT"
},
"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",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -68,6 +276,22 @@
"node": ">=8"
}
},
"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==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/dubzzz"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fast-check"
}
],
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -89,6 +313,28 @@
"node": ">=8"
}
},
"node_modules/toml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz",
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -104,6 +350,21 @@
"node": ">= 8"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.1.8",
"license": "MIT",
+10
View File
@@ -20,6 +20,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
- **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale.
- **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing.
- **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name.
- **Backend/SDK programmatic testing**: see [TESTING.md](./TESTING.md) for spawning the local main-branch backend (`bun dev serve`) and driving it via `curl` — use this instead of `kilo serve` (prod binary) when testing backend fixes.
## Products
@@ -181,6 +182,15 @@ Changeset descriptions appear directly in release notes and are read by end user
PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus on intent and context a reviewer can't get from the diff — skip file-by-file inventories, test result summaries, and anything obvious from the code itself.
## GitHub Issues
- When creating a GitHub issue for the VS Code extension or JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue.
- Do not add platform-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, `[VS Code]`, `[VSCode]`, or similar. Use a plain, descriptive title.
- Always add VS Code extension issues to the GitHub project `VS Code Extension`: https://github.com/orgs/Kilo-Org/projects/25
- Always add JetBrains plugin issues to the GitHub project `Jetbrains Plugin`: https://github.com/orgs/Kilo-Org/projects/39
- When using `gh`, prefer `gh issue create --template "..." --project "..."` with the matching project title.
- If project assignment fails because `gh` is missing the required scope, run `gh auth refresh -s project` and retry.
## Fork Merge Process
Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode).
+3 -1
View File
@@ -6,7 +6,9 @@
<a href="https://www.reddit.com/r/kilocode/"><img src="https://raster.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit" height="20"></a>
</p>
# 🚀 Kilo
<p align="center">
<img width="250" alt="kilo-code-logo" src="https://github.com/user-attachments/assets/bdb0c174-b9fd-40ad-a47b-f3aab9b54e8d" />
</p>
> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.
+261
View File
@@ -0,0 +1,261 @@
# TESTING.md
How to spin up the **local main-branch** Kilo backend and test it with `curl` / `fetch`. Aimed at a running Kilo CLI agent iterating on backend fixes without rebuilding the VS Code extension, TUI, or desktop app.
All examples use plain shell + `curl`. Writing TypeScript files is a last resort (see Section 8).
## TL;DR
```bash
# From repo root. Starts the LOCAL main-branch backend in the background.
PASS=$(openssl rand -hex 16)
KILO_SERVER_PASSWORD="$PASS" bun dev serve --port 0 >/tmp/kilo-serve.log 2>&1 &
echo $! >/tmp/kilo-serve.pid
while ! grep -q "kilo server listening" /tmp/kilo-serve.log 2>/dev/null; do sleep 0.1; done
PORT=$(grep -oE "listening on http://[^:]+:[0-9]+" /tmp/kilo-serve.log | grep -oE "[0-9]+$")
AUTH="Authorization: Basic $(printf 'kilo:%s' "$PASS" | base64 | tr -d '\n')"
BASE="http://127.0.0.1:$PORT"
# Call any endpoint
curl -sS -H "$AUTH" -H "x-kilo-directory: $PWD" "$BASE/global/health"
# Stop when done
kill "$(cat /tmp/kilo-serve.pid)" 2>/dev/null || true
rm -f /tmp/kilo-serve.pid /tmp/kilo-serve.log
```
## 1. What this doc is for
Testing local backend fixes against a real running server, talking to it over HTTP the same way the VS Code extension, TUI, and `kilo run --attach` do — but without any of those clients. Every request is a `curl` the agent can copy-paste.
For in-process tests (no socket, fastest loop) see `packages/opencode/test/kilocode/server/permission-allow-everything.test.ts` for the `Server.Default().app.request(...)` pattern. That's the right tool inside the `packages/opencode/` test suite; this doc is for out-of-process HTTP testing.
## 2. `kilo serve` vs `bun dev serve` — important
| Command | What it runs |
|---|---|
| `kilo serve` | The npm-installed production CLI on `$PATH`. **Not the code in this repo.** |
| `bun dev serve …` (repo root) | The local main-branch backend from this worktree. **This is what you want.** |
| `bun run --cwd packages/opencode --conditions=browser src/index.ts serve …` | Same as `bun dev serve`, fully expanded. |
Root `package.json` defines `"dev"` as the full `bun run --cwd packages/opencode --conditions=browser src/index.ts` invocation, so `bun dev <args>` forwards `<args>` to the local CLI entry point (`packages/opencode/src/index.ts`) without touching the installed binary.
`bun dev` imports the source directly — no rebuild is needed between code edits. Just kill the running server and relaunch.
Do **not** use `createKiloServer()` from `@kilocode/sdk/v2` to test local code: it spawns the PATH `kilo` binary (`packages/sdk/js/src/v2/server.ts:38-136`), which is the wrong tool here.
## 3. Starting the backend (background)
### Random port (recommended)
```bash
KILO_SERVER_PASSWORD=$(openssl rand -hex 16) \
bun dev serve --port 0 >/tmp/kilo-serve.log 2>&1 &
echo $! >/tmp/kilo-serve.pid
while ! grep -q "kilo server listening" /tmp/kilo-serve.log; do sleep 0.1; done
PORT=$(grep -oE "listening on http://[^:]+:[0-9]+" /tmp/kilo-serve.log | grep -oE "[0-9]+$")
```
### Fixed port (if you need a stable URL)
```bash
KILO_SERVER_PASSWORD=secret \
bun dev serve --port 4096 --hostname 127.0.0.1 \
>/tmp/kilo-serve.log 2>&1 &
echo $! >/tmp/kilo-serve.pid
while ! grep -q "kilo server listening" /tmp/kilo-serve.log; do sleep 0.1; done
PORT=4096
```
### No-auth quickstart (fastest)
Omit `KILO_SERVER_PASSWORD` entirely. The server prints `Warning: KILO_SERVER_PASSWORD is not set; server is unsecured.` and the auth middleware is bypassed — fine for throwaway local testing, never for anything else.
```bash
bun dev serve --port 0 >/tmp/kilo-serve.log 2>&1 &
echo $! >/tmp/kilo-serve.pid
while ! grep -q "kilo server listening" /tmp/kilo-serve.log; do sleep 0.1; done
PORT=$(grep -oE "listening on http://[^:]+:[0-9]+" /tmp/kilo-serve.log | grep -oE "[0-9]+$")
BASE="http://127.0.0.1:$PORT"
# no AUTH var needed
```
### Flags (`packages/opencode/src/cli/network.ts`)
| Flag | Default | Notes |
|---|---|---|
| `--port` | `0` (OS-assigned) | Must be passed literally when overriding `opencode.json`'s `server.port`. |
| `--hostname` | `127.0.0.1` | Becomes `0.0.0.0` when `--mdns` is set without an override. |
| `--mdns` | `false` | Publishes an mDNS SRV record. |
| `--mdns-domain` | `kilo.local` | |
| `--cors` | `[]` | Extra allowed origins. |
## 4. The two mandatory request knobs
### Auth header (only if `KILO_SERVER_PASSWORD` was set)
```bash
AUTH="Authorization: Basic $(printf 'kilo:%s' "$KILO_SERVER_PASSWORD" | base64 | tr -d '\n')"
```
The username is literally `kilo` (same as the VS Code extension). Skip this whole block if you launched without a password.
### Directory header on every call
```bash
DIR_HEADER="x-kilo-directory: $PWD"
```
`InstanceMiddleware` uses this to scope the request to a project. For `GET` / `HEAD`, pass `?directory=<urlencoded>` in the URL instead — that's what the SDK does internally.
## 5. Common `curl` recipes
All of the below assume `BASE`, `AUTH`, `DIR_HEADER` are set. Drop `-H "$AUTH"` if you're running without a password.
### Health check (no auth, no directory)
```bash
curl -sS "$BASE/global/health"
```
### Full endpoint list (OpenAPI spec)
```bash
curl -sS "$BASE/doc" | jq .
```
This is the source of truth — anything not in this doc is discoverable from `/doc` without reading source.
### Create a session
```bash
SID=$(curl -sS -X POST "$BASE/session" \
-H "$AUTH" -H "$DIR_HEADER" -H "Content-Type: application/json" \
-d '{}' | jq -r .id)
echo "$SID"
```
### List sessions (GET — directory goes in the query)
```bash
curl -sS -H "$AUTH" \
"$BASE/session?directory=$(printf %s "$PWD" | jq -sRr @uri)"
```
### Send a message (fire-and-forget)
```bash
curl -sS -X POST "$BASE/session/$SID/prompt_async" \
-H "$AUTH" -H "$DIR_HEADER" -H "Content-Type: application/json" \
-d '{"parts":[{"type":"text","text":"hello"}]}'
```
### Read messages for a session
```bash
curl -sS -H "$AUTH" \
"$BASE/session/$SID/message?directory=$(printf %s "$PWD" | jq -sRr @uri)"
```
### Abort an in-flight prompt
```bash
curl -sS -X POST -H "$AUTH" -H "$DIR_HEADER" "$BASE/session/$SID/abort"
```
### Get the resolved config (verify a config change took effect)
```bash
curl -sS -H "$AUTH" \
"$BASE/config?directory=$(printf %s "$PWD" | jq -sRr @uri)"
```
### Stream global events (SSE)
```bash
curl -N -sS -H "$AUTH" \
"$BASE/global/event?directory=$(printf %s "$PWD" | jq -sRr @uri)"
```
`-N` disables curl's output buffering so events appear live. Expect lines like `data: {"directory":"…","payload":{"type":"…",…}}`.
## 6. Stopping the backend
```bash
kill "$(cat /tmp/kilo-serve.pid)" 2>/dev/null || true
rm -f /tmp/kilo-serve.pid /tmp/kilo-serve.log
```
`ServeCommand` handles `SIGTERM` / `SIGINT` / `SIGHUP` and runs `Instance.disposeAll()` + `server.stop(true)` before exiting (`packages/opencode/src/cli/cmd/serve.ts:29-31`). `-9` is only needed if the process hangs past ~5 s.
## 7. Useful environment variables
| Var | Why you'd set it |
|---|---|
| `KILO_SERVER_PASSWORD` | Enable Basic auth. Omit for auth-bypassed local testing. |
| `KILO_DB=":memory:"` | Skip on-disk SQLite — hermetic runs. |
| `KILO_DISABLE_DEFAULT_PLUGINS=true` | Don't auto-load bundled plugins. |
| `KILO_WORKSPACE_ID=<id>` | Single-workspace mode; disables control-plane routes. |
| `KILO_TELEMETRY_LEVEL=off` | Disable PostHog during tests. |
| `KILO_CONFIG_CONTENT='{…}'` | Inline JSON config without writing a file. |
## 8. Last resort: typed SDK via a throwaway script
Use this only when `curl` can't express what you need — typed request/response shapes, complex multi-turn orchestration, SSE consumers that need to coalesce events. Keep the file short and delete it after.
```ts
// /tmp/probe.ts — delete after use. Talks to an already-running backend.
import { createKiloClient } from "@kilocode/sdk/v2"
const port = process.env.PORT!
const pass = process.env.KILO_SERVER_PASSWORD
const headers = pass
? { Authorization: "Basic " + Buffer.from("kilo:" + pass).toString("base64") }
: undefined
const client = createKiloClient({
baseUrl: `http://127.0.0.1:${port}`,
headers,
directory: process.cwd(),
})
const { data: session } = await client.session.create({}, { throwOnError: true })
await client.session.promptAsync({
sessionID: session.id,
parts: [{ type: "text", text: "hello" }],
})
const events = await client.global.event({})
for await (const ev of events.stream) {
const e = ev as { payload: { type: string } }
console.log(e.payload.type)
if (e.payload.type === "session.idle") break
}
```
```bash
PORT="$PORT" KILO_SERVER_PASSWORD="$KILO_SERVER_PASSWORD" bun /tmp/probe.ts
rm /tmp/probe.ts
```
Reminder: this script **connects to** the server you launched in Section 3 — it does not start one. `createKiloServer()` from the SDK would spawn the PATH `kilo` binary (production CLI), which defeats the point of testing local code.
## 9. Pitfalls
- Running `kilo serve` instead of `bun dev serve` runs the installed prod binary, not your edits.
- Missing `x-kilo-directory` (or `?directory=`) returns `400` from `InstanceMiddleware`.
- `curl` without `-N` buffers SSE output — you won't see events until the connection closes.
- Hardcoding port `4096` breaks when a previous run didn't exit cleanly. Parse the log instead.
- `--port` must appear literally in `argv` to override `opencode.json`'s `server.port` (`packages/opencode/src/cli/network.ts:45`).
- `KILO_SERVER_PASSWORD` must be set **before** launch — changing it after doesn't rotate credentials.
- When sharing `/tmp/kilo-serve.log` / `.pid` across terminals, unique-suffix the paths to avoid clobbering parallel runs.
## 10. After changing server routes
Regenerate the SDK and OpenAPI spec so `/doc` and typed clients stay in sync:
```bash
./script/generate.ts # from repo root
```
See `AGENTS.md` for the full rationale.
+38 -30
View File
@@ -32,7 +32,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
@@ -88,7 +88,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -121,7 +121,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -172,7 +172,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -201,7 +201,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
@@ -210,7 +210,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@openrouter/ai-sdk-provider": "2.8.1",
"ai": "catalog:",
"open": "10.1.2",
"zod": "catalog:",
@@ -237,7 +237,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.2.21",
"version": "7.2.24",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -250,7 +250,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"@opentelemetry/api": "1.9.0",
@@ -270,7 +270,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@kobalte/core": "0.13.11",
"@opencode-ai/shared": "workspace:*",
@@ -305,7 +305,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-i18n": "workspace:*",
@@ -365,7 +365,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.2.21",
"version": "7.2.24",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -381,7 +381,7 @@
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.102",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
@@ -400,7 +400,7 @@
"@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-server": "1.19.13",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -416,7 +416,7 @@
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/script": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
@@ -443,7 +443,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
@@ -520,7 +520,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"effect": "catalog:",
@@ -545,7 +545,7 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"semver": "^7.6.3",
},
@@ -556,7 +556,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -571,7 +571,7 @@
},
"packages/shared": {
"name": "@opencode-ai/shared",
"version": "7.2.21",
"version": "7.2.24",
"bin": {
"opencode": "./bin/opencode",
},
@@ -595,7 +595,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.2.21",
"version": "7.2.24",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -618,7 +618,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.2.21",
"version": "7.2.24",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -722,7 +722,7 @@
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20260316.1",
"ai": "6.0.158",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"diff": "8.0.4",
"dompurify": "3.3.3",
@@ -790,7 +790,7 @@
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.102", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GrwDpaYJiVafrsA1MTbZtXPcQUI67g5AXiJo7Y1F8b+w+SiYHLk3ZIn1YmpQVoVAh2bjvxjj+Vo0AvfskuGH4g=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
@@ -1230,7 +1230,7 @@
"@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="],
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
@@ -1576,7 +1576,7 @@
"@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -2440,7 +2440,7 @@
"@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
@@ -2524,7 +2524,7 @@
"aggregate-error": ["aggregate-error@4.0.1", "", { "dependencies": { "clean-stack": "^4.0.0", "indent-string": "^5.0.0" } }, "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w=="],
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
"ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="],
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
@@ -3360,7 +3360,7 @@
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@6.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -5098,10 +5098,14 @@
"@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
"@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
"@morphllm/morphsdk/ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
"@octokit/core/@octokit/graphql": ["@octokit/graphql@7.1.1", "", { "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" } }, "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g=="],
"@octokit/core/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
@@ -5272,8 +5276,6 @@
"@vscode/vsce/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
"ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="],
@@ -5284,6 +5286,8 @@
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.75", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V8UKK4fNpI9cnrtsZBvUp9O9J6Y9fTKBRoSLyEaNGPirACewixmLDbXsSgAeownPVWiWpK34bFysd+XouI5Ywg=="],
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
"ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
@@ -5826,6 +5830,8 @@
"@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
"@morphllm/morphsdk/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
"@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
"@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
@@ -6366,6 +6372,8 @@
"@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
"@morphllm/morphsdk/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@octokit/graphql/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-PnZh7bJ97lE8BsAiICc653OV468GjehAs9lXV11DKXw=",
"aarch64-linux": "sha256-VMcH747sR0wDCOXXDbnHQQid9WeigWr/HtRrj2yHxuU=",
"aarch64-darwin": "sha256-lsNiZcz7NjGmuls22z2VCbXwsr1h3Q0DlnI46r5RxeA=",
"x86_64-darwin": "sha256-z4yto3ZjuXpKP5vP5ppl2oX7T0EleSQAByyMqPO0uiE="
"x86_64-linux": "sha256-uDu9FY2G8j6AzpXAQ3WoWLJ9uyh5R7xqcAVzij6dpdU=",
"aarch64-linux": "sha256-GtVSse+wWwmFA9e3XkAp+1spPH2u4C2jqJ9HUKQRJ44=",
"aarch64-darwin": "sha256-UrZIY7v59gB0k0+/CIXA/NFrR3FSE+5Lcsz9qZffsqM=",
"x86_64-darwin": "sha256-/thTDanC0f1rczDj7RbgeFyaRfuhMdbRXrgfH6KRejs="
}
}
+2 -2
View File
@@ -52,7 +52,7 @@
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.48",
"ai": "6.0.158",
"ai": "6.0.168",
"cross-spawn": "7.0.6",
"hono": "4.12.12",
"hono-openapi": "1.1.2",
@@ -145,6 +145,6 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch"
},
"version": "7.2.21",
"version": "7.2.24",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "7.2.21",
"version": "7.2.24",
"description": "",
"type": "module",
"exports": {
+6 -5
View File
@@ -288,11 +288,12 @@ export function Titlebar() {
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
{import.meta.env.VITE_KILO_CHANNEL && ["beta", "dev"].includes(import.meta.env.VITE_KILO_CHANNEL) && ( // kilocode_change
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_KILO_CHANNEL.toUpperCase()}
</div>
)}
{import.meta.env.VITE_KILO_CHANNEL &&
["beta", "dev"].includes(import.meta.env.VITE_KILO_CHANNEL) && ( // kilocode_change
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_KILO_CHANNEL.toUpperCase()}
</div>
)}
</div>
</div>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "7.2.21",
"version": "7.2.24",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "7.2.21",
"version": "7.2.24",
"type": "module",
"license": "MIT",
"scripts": {
+49 -49
View File
@@ -2299,6 +2299,53 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "kilo-desktop"
version = "0.0.0"
dependencies = [
"chrono",
"comrak",
"dirs",
"futures",
"gtk",
"listeners",
"objc2 0.6.3",
"objc2-web-kit",
"process-wrap",
"reqwest 0.12.24",
"semver",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-build 2.5.2",
"tauri-plugin-clipboard-manager",
"tauri-plugin-decorum",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-specta",
"tokio",
"tokio-stream",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"webkit2gtk",
"windows-core 0.62.2",
"windows-sys 0.61.2",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -3093,53 +3140,6 @@ dependencies = [
"pathdiff",
]
[[package]]
name = "kilo-desktop"
version = "0.0.0"
dependencies = [
"chrono",
"comrak",
"dirs",
"futures",
"gtk",
"listeners",
"objc2 0.6.3",
"objc2-web-kit",
"process-wrap",
"reqwest 0.12.24",
"semver",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-build 2.5.2",
"tauri-plugin-clipboard-manager",
"tauri-plugin-decorum",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-process",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tauri-specta",
"tokio",
"tokio-stream",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"webkit2gtk",
"windows-core 0.62.2",
"windows-sys 0.61.2",
]
[[package]]
name = "option-ext"
version = "0.2.0"
@@ -4163,9 +4163,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.8"
version = "0.103.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
dependencies = [
"ring",
"rustls-pki-types",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.2.21"
version = "7.2.24"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.24/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.24/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.24/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.24/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.21/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.24/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+5
View File
@@ -33,6 +33,11 @@ export const AutomateNav: NavSection[] = [
href: "/automate/extending/shell-integration",
children: "Shell Integration",
},
{
href: "/automate/extending/plugins",
children: "Plugins",
platform: "new",
},
{
href: "/automate/extending/auto-launch",
children: "Auto-launch Configuration",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.2.21",
"version": "7.2.24",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -0,0 +1,483 @@
---
title: "Plugins"
description: "Extend the Kilo CLI with custom hooks, tools, auth providers, and more"
platform: new
---
# Plugins
Plugins extend Kilo by hooking into events, adding custom tools, registering auth or model providers, and customizing runtime behavior. They are TypeScript or JavaScript modules loaded at startup, and work in both the Kilo CLI and the VS Code extension.
## What plugins can do
- **Add custom tools** the model can call (like `read`, `write`, `bash`).
- **Intercept tool calls** to mutate arguments, rewrite output, or block dangerous operations.
- **Subscribe to events** — sessions, messages, permissions, LSP diagnostics, file changes, etc.
- **Register auth providers** — OAuth or API-key flows for model providers.
- **Register model providers** — dynamic model catalogs.
- **Mutate chat parameters or headers** sent to the LLM.
- **Customize compaction** — inject or replace the prompt used when a session is compacted.
- **Inject shell environment variables** for commands executed by the agent or user.
---
## Use a plugin
There are three ways to load plugins.
### From a config file
Add an array of plugin specifiers to your config file:
```json
{
"$schema": "https://app.kilo.ai/config.json",
"plugin": [
"@your-org/your-plugin",
"your-plugin@1.2.3",
["your-plugin", { "apiKey": "{env:MY_API_KEY}" }],
"./plugins/local.ts",
"file:///abs/path/plugin.ts"
]
}
```
Each entry can be:
| Form | Loaded from |
| -------------------------------------- | ---------------------------------------------------------------- |
| `"package-name"` | Latest version from npm |
| `"package-name@1.2.3"` | Pinned version from npm |
| `["package-name", { options }]` | npm package with options passed to the plugin function |
| `"./path/plugin.ts"` / `"file:///..."` | Local file (relative to the config file or absolute `file:` URL) |
Config files live in the same locations as the rest of your CLI configuration — see the [CLI configuration reference](/docs/code-with-ai/platforms/cli#configuration).
### From a plugin directory
Drop TypeScript or JavaScript files into a `plugin/` or `plugins/` folder inside any config directory:
- Global: `~/.config/kilo/plugin/`
- Project: `.kilo/plugin/`, `.kilocode/plugin/`, or `.opencode/plugin/`
Every `.ts` or `.js` file in those directories is auto-registered at startup — no need to list them in the config file.
```text
my-project/
├── kilo.json
└── .kilo/
└── plugin/
├── env-guard.ts
└── notifications.ts
```
### From the `kilo plugin` command
Install an npm plugin and patch your config in one step:
```bash
# Install into the current project's config
kilo plugin my-plugin
# Install into your global config
kilo plugin my-plugin --global
# Replace an existing entry
kilo plugin my-plugin --force
```
The command resolves the package, reads its `package.json` for plugin entrypoints, and writes the entry into the appropriate config file (currently `.opencode/opencode.jsonc` / `.opencode/tui.jsonc` for local installs, or `~/.config/kilo/opencode.jsonc` / `~/.config/kilo/tui.jsonc` for `--global`) while preserving JSONC comments.
### How plugins are installed
- **npm plugins** are installed automatically at startup using Bun. Packages and their dependencies are cached in Kilo's XDG cache directory (`~/.cache/kilo/` on Linux, `~/Library/Caches/kilo/` on macOS, `%LOCALAPPDATA%\kilo\` on Windows).
- **Local plugins** are loaded directly from the plugin directory. If your plugin imports external packages, add a `package.json` to your config directory (see [Dependencies](#dependencies)) — Kilo runs `bun install` on startup so imports resolve.
### Load order
Plugins from all sources run on every session. They load in this order:
1. Internal built-ins (Kilo Gateway auth, Codex auth, Copilot auth, Cloudflare, etc.)
2. Global config plugin array (`~/.config/kilo/kilo.json`)
3. Global plugin directory (`~/.config/kilo/plugin/`)
4. Project config plugin array (`kilo.json` / `opencode.json`)
5. Project plugin directory (`.kilo/plugin/` and friends)
Duplicates (same package, same version) are deduplicated. Hooks from multiple plugins run sequentially in load order.
### Disabling external plugins
Set the `KILO_PURE=1` environment variable to skip all external plugins — only built-in plugins will load. Useful for reproducible CI runs or debugging.
---
## Create a plugin
A plugin is a module that exports a function returning a set of [hooks](#hooks-reference).
### Basic structure
Create a file in your plugin directory:
```ts
// .kilo/plugin/hello.ts
import type { Plugin } from "@kilocode/plugin"
const hello: Plugin = async ({ project, client, $, directory, worktree }) => {
console.log("hello plugin loaded")
return {
// hook implementations go here
}
}
export default { id: "hello", server: hello }
```
The plugin function receives a context object:
| Field | Description |
| ------------------------ | ----------------------------------------------------------------- |
| `project` | Current project metadata. |
| `directory` | Current working directory for this session. |
| `worktree` | Git worktree root for this session. |
| `client` | A Kilo SDK client (`@kilocode/sdk`) for calling the local server. |
| `$` | [Bun's shell API](https://bun.com/docs/runtime/shell). |
| `serverUrl` | URL of the local Kilo server. |
| `experimental_workspace` | Register workspace adaptors (used by Agent Manager). |
The function returns a `Hooks` object. Any second argument is the options object passed via config (e.g. the `{ apiKey: "..." }` from `["my-plugin", { apiKey: "..." }]`).
### Module shape
Plugins must default-export a module descriptor. `id` is required for local-file plugins and inferred from `package.json#name` for npm plugins.
```ts
import type { Plugin } from "@kilocode/plugin"
const server: Plugin = async (ctx) => ({
/* hooks */
})
export default {
id: "my-plugin",
server,
}
```
An npm plugin can also expose a TUI entry point (`tui`) for [TUI plugins](#tui-plugins), but `server` and `tui` are separate modules.
### TypeScript support
Install the plugin package locally and import its types:
```bash
bun add -d @kilocode/plugin
```
```ts
import type { Plugin } from "@kilocode/plugin"
import { tool } from "@kilocode/plugin/tool"
```
Kilo automatically creates a `package.json` in config directories that contain a `plugin/` folder and installs `@kilocode/plugin` so types resolve out of the box.
### Engine compatibility
Declare a CLI version range to prevent a plugin from loading against an incompatible build:
```json
{
"name": "my-plugin",
"engines": { "opencode": "^7.0.0" }
}
```
If the running CLI does not satisfy the range, the plugin is skipped and a warning is surfaced.
### Dependencies
Local plugins and custom tools can use external npm packages. Add a `package.json` to your config directory:
```json
// .kilo/package.json
{
"dependencies": {
"shescape": "^2.1.0"
}
}
```
Kilo runs `bun install` at startup so your plugins can import the packages:
```ts
// .kilo/plugin/escape-bash.ts
import { escape } from "shescape"
import type { Plugin } from "@kilocode/plugin"
const EscapeBash: Plugin = async () => ({
"tool.execute.before": async (input, output) => {
if (input.tool === "bash") {
output.args.command = escape(output.args.command)
}
},
})
export default { id: "escape-bash", server: EscapeBash }
```
---
## Hooks reference
Every hook is optional. Return only the ones you care about.
### Lifecycle
| Hook | Description |
| -------- | --------------------------------------------------------------------------------- |
| `config` | Receives the fully-resolved config at startup. Read-only — useful for inspection. |
| `event` | Called for **every** event on the internal bus (see [Events](#events)). |
### Tools
| Hook | Description |
| --------------------- | ----------------------------------------------------------------------------------------------- |
| `tool` | Map of tool name → [tool definition](#custom-tools). Added tools are callable by the model. |
| `tool.execute.before` | Fires before a tool runs; you can mutate `output.args`. |
| `tool.execute.after` | Fires after a tool returns; you can rewrite `output.title`, `output.output`, `output.metadata`. |
| `tool.definition` | Mutate a tool's `description` and `parameters` before they are sent to the model. |
### Chat
| Hook | Description |
| ------------------------ | ---------------------------------------------------------------------------- |
| `chat.message` | Fires when a new user message arrives. Inspect or modify `parts`. |
| `chat.params` | Mutate `temperature`, `topP`, `topK`, `maxOutputTokens`, provider `options`. |
| `chat.headers` | Add or replace HTTP headers on the LLM API call. |
| `permission.ask` | Auto-allow or auto-deny permission prompts. |
| `command.execute.before` | Intercept slash command execution; mutate the resulting `parts`. |
| `shell.env` | Inject environment variables into every shell command Kilo runs. |
### Providers & auth
| Hook | Description |
| ---------- | ------------------------------------------------------------------------------------ |
| `auth` | Register an auth method (OAuth or API key) for a provider, with interactive prompts. |
| `provider` | Dynamically supply a model catalog for a provider (useful for BYO-model gateways). |
### Experimental
These hooks live behind the `experimental.` prefix and may change between releases.
| Hook | Description |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `experimental.chat.messages.transform` | Rewrite the full message history before it is sent to the model. |
| `experimental.chat.system.transform` | Modify the system prompt array. |
| `experimental.session.compacting` | Inject extra context (`output.context`) or replace the compaction prompt entirely (`output.prompt`). |
| `experimental.compaction.autocontinue` | Disable the synthetic "continue" turn that follows compaction. |
| `experimental.text.complete` | Post-process final text parts (e.g. append signatures, redact secrets). |
### Events
The `event` hook fires for every event on Kilo's internal bus. Common event types include:
- **Session**: `session.created`, `session.updated`, `session.idle`, `session.error`, `session.deleted`, `session.compacted`, `session.diff`, `session.status`
- **Message**: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`
- **Tool**: `tool.execute.before`, `tool.execute.after`
- **Permission**: `permission.asked`, `permission.replied`
- **File**: `file.edited`, `file.watcher.updated`
- **Shell**: `shell.env`
- **Command**: `command.executed`
- **LSP**: `lsp.updated`, `lsp.client.diagnostics`
- **Todo**: `todo.updated`
- **Server**: `server.connected`
- **Installation**: `installation.updated`
```ts
const server: Plugin = async () => ({
event: async ({ event }) => {
if (event.type === "session.idle") {
// session finished responding
}
},
})
```
---
## Custom tools
Plugins can register tools the model can call alongside the built-in ones. Use the `tool()` helper for type-safety:
```ts
// .kilo/plugin/database.ts
import type { Plugin } from "@kilocode/plugin"
import { tool } from "@kilocode/plugin/tool"
const DatabasePlugin: Plugin = async () => ({
tool: {
query: tool({
description: "Run a read-only SQL query against the project database",
args: {
sql: tool.schema.string().describe("SQL query to execute"),
},
async execute(args, context) {
const { directory, worktree } = context
// your query logic here
return `ran: ${args.sql}`
},
}),
},
})
export default { id: "database", server: DatabasePlugin }
```
`args` uses a [Zod](https://zod.dev) schema via `tool.schema`. The tool's `execute` function receives:
- `args` — validated against your schema
- `context``{ sessionID, messageID, agent, directory, worktree, abort, metadata, ask }`
### Name precedence
If a custom tool uses the same name as a built-in tool, **the custom tool wins**. Prefer unique names unless you intentionally want to override a built-in (for example, to wrap `bash` with extra validation).
### Alternative: standalone tool files
For tools that don't need the full plugin context, drop them in a `tool/` or `tools/` folder inside any config directory — for example `.kilo/tool/database.ts` or `~/.config/kilo/tool/database.ts`. The filename becomes the tool name, and each file exports a `tool()` definition directly. The layout is identical to the [OpenCode custom tools guide](https://opencode.ai/docs/custom-tools); substitute `.kilo/` (or `.kilocode/` / `.opencode/`) for `.opencode/`.
---
## Examples
### Send a notification when a session finishes
```ts
// .kilo/plugin/notify.ts
import type { Plugin } from "@kilocode/plugin"
const Notify: Plugin = async ({ $ }) => ({
event: async ({ event }) => {
if (event.type === "session.idle") {
await $`osascript -e 'display notification "Session complete!" with title "Kilo"'`
}
},
})
export default { id: "notify", server: Notify }
```
{% callout type="tip" %}
The VS Code extension already emits system notifications when a session finishes or errors — this plugin is for the raw CLI / TUI.
{% /callout %}
### Block reads of `.env` files
```ts
// .kilo/plugin/env-guard.ts
import type { Plugin } from "@kilocode/plugin"
const EnvGuard: Plugin = async () => ({
"tool.execute.before": async (input, output) => {
if (input.tool === "read" && String(output.args.filePath).includes(".env")) {
throw new Error("reading .env files is blocked")
}
},
})
export default { id: "env-guard", server: EnvGuard }
```
### Inject environment variables into every shell command
```ts
// .kilo/plugin/inject-env.ts
import type { Plugin } from "@kilocode/plugin"
const InjectEnv: Plugin = async () => ({
"shell.env": async (input, output) => {
output.env.MY_API_KEY = "secret"
output.env.PROJECT_ROOT = input.cwd
},
})
export default { id: "inject-env", server: InjectEnv }
```
### Structured logging
Prefer `client.app.log()` over `console.log` so entries land in Kilo's log pipeline:
```ts
import type { Plugin } from "@kilocode/plugin"
const Logger: Plugin = async ({ client }) => {
await client.app.log({
body: {
service: "my-plugin",
level: "info",
message: "plugin initialized",
extra: { version: "1.0.0" },
},
})
return {}
}
export default { id: "logger", server: Logger }
```
Levels: `debug`, `info`, `warn`, `error`.
### Inject context during session compaction
```ts
// .kilo/plugin/compaction.ts
import type { Plugin } from "@kilocode/plugin"
const Compaction: Plugin = async () => ({
"experimental.session.compacting": async (input, output) => {
output.context.push(
"## Persist across compaction\n- current task status\n- files being actively edited\n- key decisions",
)
},
})
export default { id: "compaction", server: Compaction }
```
Set `output.prompt` to replace the default compaction prompt entirely — when present, `output.context` is ignored.
---
## TUI plugins
Plugins can also target the Kilo TUI itself — registering slash commands, routes, sidebar slots, dialogs, and keybinds. TUI plugins are SolidJS modules exported from `"./tui"` in your plugin package.
TUI plugins live in a separate module namespace (`@kilocode/plugin/tui`) and have their own API surface (`TuiPluginApi`). Because the TUI API is larger and still evolving, this guide doesn't cover it exhaustively — use the types in `@kilocode/plugin/tui` as the reference, and look at the built-in TUI plugins under `packages/opencode/src/cli/cmd/tui/feature-plugins/` for working examples.
---
## Troubleshooting
- **Plugin failed to load** — check the CLI logs with `kilo --print-logs --log-level DEBUG`. Load failures are also surfaced as session errors in the TUI and VS Code extension.
- **Plugin loaded but hooks never fire** — make sure the default export includes `server`:
```ts
export default { id: "my-plugin", server }
```
Named function exports are also accepted for backwards compatibility but should be considered legacy.
- **Local plugin can't find an npm import** — add a `package.json` in the config directory so `bun install` picks up the dependency (see [Dependencies](#dependencies)).
- **Plugin loads in dev but not in CI** — verify `KILO_PURE` is not set, and that npm-installed plugins are cached (Kilo's XDG cache directory — `~/.cache/kilo/` on Linux, `~/Library/Caches/kilo/` on macOS, `%LOCALAPPDATA%\kilo\` on Windows). Run with `--log-level DEBUG` to see install output.
- **Reset the plugin cache** — delete the `node_modules/` under Kilo's cache directory (or the `node_modules` cache under your config directory) and restart Kilo.
---
## Reference
- Types: [`@kilocode/plugin`](https://github.com/Kilo-Org/kilocode/tree/main/packages/plugin) — `Plugin`, `Hooks`, `PluginInput`, `ToolDefinition`, `AuthHook`, `ProviderHook`.
- Example plugin: [`packages/plugin/src/example.ts`](https://github.com/Kilo-Org/kilocode/blob/main/packages/plugin/src/example.ts)
- CLI command: [`kilo plugin`](/docs/code-with-ai/platforms/cli-reference#kilo-plugin)
- Upstream docs (behavior is identical to OpenCode): [opencode.ai/docs/plugins](https://opencode.ai/docs/plugins) and [opencode.ai/docs/custom-tools](https://opencode.ai/docs/custom-tools)
@@ -76,6 +76,61 @@ Check [our provider docs](/docs/ai-providers) for specific context limits on eac
**Recover from context limit errors:** If you hit the `input length and max tokens exceed context limit` error, you can recover by deleting a message, rolling back to a previous checkpoint, or switching over to a model with a long context window like Gemini for a message.
{% /callout %}
## Models During Delegation
When an agent delegates work to a subagent (via the `task` tool), the subagent **inherits the parent agent's model** by default. You can override this per subagent in your config:
{% tabs %}
{% tab label="CLI" %}
```json
{
"agent": {
"explore": {
"model": "anthropic/claude-haiku-4-20250514"
}
}
}
```
This sets the `explore` subagent to always use Haiku regardless of the parent's model. Any subagent without a `model` override uses whatever model the invoking agent is running.
{% /tab %}
{% tab label="VSCode" %}
Subagents inherit the model currently active in the primary agent session — the model shown in the selector at the bottom of the chat. To bypass inheritance and pin a specific model for a subagent:
- **Via Settings** — open **Settings → Models → Model per Mode**, find the subagent, and pick its model.
- **Via config file** — edit `kilo.jsonc`:
```json
{
"agent": {
"explore": {
"model": "anthropic/claude-haiku-4-5"
}
}
}
```
The Settings UI writes the same `agent.<name>.model` entry, so either method produces the same override. Subagents without an explicit model continue to inherit whatever the invoking agent is running.
{% /tab %}
{% tab label="VSCode (Legacy)" %}
In the legacy extension, each mode has **Sticky Models** — switching from one mode to another (e.g., Code → Architect) uses whatever model you last selected for that mode, not the model from the mode you came from. This means you can assign different models to different modes:
- **Architect:** a reasoning-heavy model (Gemini Pro, Claude Opus)
- **Code:** a fast coding model (Claude Sonnet, GPT-4.1)
- **Debug:** a cost-efficient model (Gemini Flash, DeepSeek)
The model selection is remembered per mode across sessions.
{% /tab %}
{% /tabs %}
For details on configuring subagent models, see [Custom Subagents](/docs/customize/custom-subagents).
## Stay Current
The AI model space moves fast. Bookmark [kilo.ai/models](https://kilo.ai/models) and check back when you're evaluating options. What's best today might not be best next month — and that's actually exciting.
@@ -232,11 +232,27 @@ If you have existing `.kilocodemodes` or `custom_modes.yaml` files from the VSCo
Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orchestrator`) are skipped during migration since they map to built-in agents (`build``code`, `architect``plan`).
### Legacy File Locations
The current VSCode extension reads the legacy `custom_modes.yaml` file from its own global storage directory. Helpful for inspecting or fixing the file before the one-time migration runs:
| OS | Path |
| ------- | ----------------------------------------------------------------------------------------------------- |
| macOS | `~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` |
| Linux | `~/.config/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` |
| Windows | `%APPDATA%\Code\User\globalStorage\kilocode.kilo-code\settings\custom_modes.yaml` |
Project-level `.kilocodemodes` and workspace-scoped files are handled by the CLI backend that the extension delegates to — see the [CLI tab](#cli) for the full load-order table. After the extension migrates on startup, the legacy file is no longer consulted; remove new modes through the extension UI instead of editing `custom_modes.yaml` directly.
{% /tab %}
{% tab label="CLI" %}
In the CLI, custom behavioral profiles are called **agents** instead of modes. Agents are defined as Markdown files with YAML frontmatter or as entries in the `agent` key of your config file.
{% callout type="warning" %}
**Legacy `custom_modes.yaml` is not loaded from `~/.config/kilo/`.** If you're migrating from the legacy VSCode extension, global custom modes are read from `~/.kilocode/cli/global/settings/custom_modes.yaml` (not from the CLI's XDG config directory). The recommended approach is to convert legacy modes to agent `.md` files and place them in `~/.config/kilo/agent/` instead — see [Markdown files](#3-markdown-files-with-yaml-frontmatter) and [Migration](#migration-from-vscode-extension-modes) below.
{% /callout %}
## What's Included in a Custom Agent?
| Property | Description |
@@ -452,6 +468,21 @@ If you have existing `.kilocodemodes` or `custom_modes.yaml` files from the VSCo
Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orchestrator`) are skipped during migration since they map to built-in agents (`build``code`, `architect``plan`).
### Legacy File Lookup Paths
The CLI reads legacy mode files from the following locations (in load order). When the same slug appears in multiple sources, the **last loaded source wins**:
| Load Order | Path | Format | Scope |
|------------|------|--------|-------|
| 1 | VSCode extension global storage `/settings/custom_modes.yaml` | YAML | Global |
| 2 | `~/.kilocode/cli/global/settings/custom_modes.yaml` | YAML | Global |
| 3 | `~/.kilocodemodes` | YAML | Global |
| 4 | `<project>/.kilocodemodes` | YAML | Project (wins on conflict) |
{% callout type="info" %}
`~/.config/kilo/` is the XDG config directory for the new agent format — legacy `custom_modes.yaml` placed there will **not** be loaded. Use `~/.config/kilo/agent/*.md` or `~/.config/kilo/kilo.jsonc` for new agent definitions instead.
{% /callout %}
{% /tab %}
{% tab label="VSCode (Legacy)" %}
+3 -1
View File
@@ -1,7 +1,7 @@
# Source Code Links
<!-- Auto-generated by script/extract-source-links.ts — DO NOT EDIT -->
<!-- 80 unique URLs extracted from extension and CLI source -->
<!-- 81 unique URLs extracted from extension and CLI source -->
- <https://api.apertis.ai/v1>
<!-- packages/opencode/src/provider/model-cache.ts -->
@@ -124,6 +124,8 @@
<!-- packages/kilo-vscode/webview-ui/src/i18n/en.ts -->
- <https://openrouter.ai/docs/use-cases/usage-accounting>
<!-- packages/opencode/src/kilocode/session/index.ts -->
- <https://opentelemetry.io/blog/2025/how-to-name-your-span-attributes/>
<!-- packages/opencode/src/server/routes/instance/trace.ts -->
- <https://opncd.ai>
<!-- packages/opencode/src/share/share-next.ts -->
- <https://platform.openai.com/docs/api-reference/responses/create>
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.2.21",
"version": "7.2.24",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
@@ -33,7 +33,7 @@
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/openai": "3.0.48",
"@ai-sdk/openai-compatible": "2.0.37",
"@openrouter/ai-sdk-provider": "2.5.1",
"@openrouter/ai-sdk-provider": "2.8.1",
"@clack/prompts": "1.0.0-alpha.1",
"ai": "catalog:",
"open": "10.1.2",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.2.21",
"version": "7.2.24",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.2.21",
"version": "7.2.24",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.2.21",
"version": "7.2.24",
"type": "module",
"license": "MIT",
"exports": {
+14
View File
@@ -1,5 +1,19 @@
# kilo-code
## 7.2.23
### Patch Changes
- [#9423](https://github.com/Kilo-Org/kilocode/pull/9423) [`a87a461`](https://github.com/Kilo-Org/kilocode/commit/a87a461e971864377d2139c35e778cc98d5cca19) - Fix two Agent Manager tab-bar regressions. New session and terminal tabs now consistently open at the right end of the tab bar instead of sometimes slipping in front of existing tabs. Dragging a terminal tab now shows the same floating label preview as dragging a session tab.
- [#9026](https://github.com/Kilo-Org/kilocode/pull/9026) [`61516b4`](https://github.com/Kilo-Org/kilocode/commit/61516b4d3f5a87f623a34b9112b8e1ff3725f93c) - Support attaching Git changes from prompt mentions in the VS Code extension.
## 7.2.22
### Patch Changes
- [#9444](https://github.com/Kilo-Org/kilocode/pull/9444) [`35f13cb`](https://github.com/Kilo-Org/kilocode/commit/35f13cbc8678f80cf6870c233f74f47b028bf705) - Fix restoring and paginating very long VS Code sessions.
## 7.2.21
### Minor Changes
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
"version": "7.2.21",
"version": "7.2.24",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
+11 -12
View File
@@ -49,6 +49,8 @@ import { handleContinueInWorktree } from "./kilo-provider/continue-worktree"
import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files"
import { handleFileSearch } from "./kilo-provider/file-search"
import { getTerminalContents } from "./services/terminal/context"
import { disposeGitChangesTarget } from "./kilo-provider/git-changes-target"
import { interceptMessage } from "./kilo-provider/git-changes-request"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { clearCommandsCache, loadCommands } from "./kilo-provider/commands"
import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page"
@@ -565,17 +567,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.autocompleteConfigDisposable?.dispose()
this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg))
this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => {
// Run interceptor if attached (e.g., AgentManagerProvider worktree logic)
if (this.onBeforeMessage) {
try {
const result = await this.onBeforeMessage(message)
if (result === null) return // consumed by interceptor
message = result
} catch (error) {
console.error("[Kilo New] KiloProvider: interceptor error:", error)
return
}
}
const intercepted = await interceptMessage(message, {
workspaceDir: (sid) => this.getWorkspaceDirectory(sid ?? this.currentSession?.id),
post: (m) => this.postMessage(m),
error: getErrorMessage,
before: this.onBeforeMessage,
})
if (intercepted === null) return
message = intercepted
await routeSuggestionWebviewMessage(this.questionCtx, message)
if (await ModelState.handleMessage(message.type, message, this.client, (msg) => this.postMessage(msg))) return
@@ -3322,6 +3321,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.sessionStatusMap.clear()
this.ignoreController?.dispose()
this.chatAutocomplete?.dispose()
this.marketplace?.dispose()
;(this.marketplace?.dispose(), disposeGitChangesTarget())
}
}
@@ -3,9 +3,10 @@ import * as path from "path"
import type { KiloClient, Session } from "@kilocode/sdk/v2/client"
import type { KiloConnectionService } from "../services/cli-backend"
import { getErrorMessage } from "../kilo-provider-utils"
import { resolveLocalDiffTarget } from "../review-utils"
import { isAbsolutePath } from "../path-utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { WorktreeStateManager } from "./WorktreeStateManager"
import { remoteRef, WorktreeStateManager } from "./WorktreeStateManager"
import { handleSection } from "./section-handler"
import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch"
import { GitStatsPoller, type LocalStats, type WorktreePresenceResult, type WorktreeStats } from "./GitStatsPoller"
@@ -281,6 +282,7 @@ export class AgentManagerProvider implements Disposable {
if (msg.type === "requestFileSearch" && typeof msg.sessionID !== "string" && this.activeSessionId) {
return { ...msg, sessionID: this.activeSessionId }
}
msg = await this.contextMessage(msg)
const m = msg as unknown as AgentManagerInMessage
const worktree = await this.onWorktreeMessage(m)
@@ -302,6 +304,36 @@ export class AgentManagerProvider implements Disposable {
return msg
}
private async contextMessage(msg: Record<string, unknown>): Promise<Record<string, unknown>> {
if (msg.type !== "requestGitChangesContext") return msg
const ctx = typeof msg.agentManagerContext === "string" ? msg.agentManagerContext : undefined
const target = ctx ? await this.contextTarget(ctx) : undefined
const sid = typeof msg.sessionID === "string" ? msg.sessionID : this.activeSessionId
const next = sid && typeof msg.sessionID !== "string" ? { ...msg, sessionID: sid } : msg
if (target) return { ...next, ...target }
if (!sid) return next
const state = this.getStateManager()
const session = state?.getSession(sid)
const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined
if (!worktree) return next
return { ...next, contextDirectory: worktree.path, gitChangesBase: remoteRef(worktree) }
}
private async contextTarget(ctx: string): Promise<Record<string, unknown> | undefined> {
if (ctx === "local") {
const root = this.getRoot()
if (!root) return undefined
const target = await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), root)
if (!target) return { contextDirectory: root }
return { contextDirectory: target.directory, gitChangesBase: target.baseBranch }
}
const worktree = this.getStateManager()?.getWorktree(ctx)
if (!worktree) return undefined
return { contextDirectory: worktree.path, gitChangesBase: remoteRef(worktree) }
}
private async onWorktreeMessage(m: AgentManagerInMessage): Promise<Record<string, unknown> | null | undefined> {
if (m.type === "agentManager.createWorktree") return this.onCreateWorktree(m.baseBranch, m.branchName)
if (m.type === "agentManager.deleteWorktree") return this.onDeleteWorktree(m.worktreeId)
@@ -0,0 +1,28 @@
import { getGitChangesContext } from "../services/git/context"
type Input = {
requestId: string
dir: string
base?: string
post: (message: unknown) => void
error: (error: unknown) => string
}
export async function captureGitChangesContext(input: Input): Promise<void> {
try {
const output = await getGitChangesContext(input.dir, input.base)
input.post({
type: "gitChangesContextResult",
requestId: input.requestId,
content: output.content,
truncated: output.truncated,
})
} catch (error) {
console.error("[Kilo New] Failed to capture git changes context:", error)
input.post({
type: "gitChangesContextError",
requestId: input.requestId,
error: input.error(error) || "Failed to capture git changes",
})
}
}
@@ -0,0 +1,32 @@
import { captureGitChangesContext } from "./git-changes-context"
import { resolveGitChangesTarget } from "./git-changes-target"
type Interceptor = (msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>
type Context = {
workspaceDir: (sessionID: string | undefined) => string
post: (message: unknown) => void
error: (error: unknown) => string
before?: Interceptor | null
}
export async function interceptMessage(
msg: Record<string, unknown>,
ctx: Context,
): Promise<Record<string, unknown> | null> {
const next = ctx.before
? await ctx.before(msg).catch((e) => (console.error("[Kilo New] interceptor error:", e), null))
: msg
if (next === null || next.type !== "requestGitChangesContext") return next
const sid = typeof next.sessionID === "string" ? next.sessionID : undefined
const dir = ctx.workspaceDir(sid)
const resolved = await resolveGitChangesTarget(next, dir)
await captureGitChangesContext({
requestId: typeof resolved.requestId === "string" ? resolved.requestId : "",
dir: typeof resolved.contextDirectory === "string" ? resolved.contextDirectory : dir,
base: typeof resolved.gitChangesBase === "string" ? resolved.gitChangesBase : undefined,
post: ctx.post,
error: ctx.error,
}).catch((e) => console.error("[Kilo New] git changes error:", e))
return null
}
@@ -0,0 +1,24 @@
import { GitOps } from "../agent-manager/GitOps"
import { resolveLocalDiffTarget } from "../review-utils"
let shared: GitOps | undefined
function ops(): GitOps {
if (shared && !shared.disposed) return shared
shared = new GitOps({ log: () => undefined })
return shared
}
export function disposeGitChangesTarget(): void {
shared?.dispose()
shared = undefined
}
export async function resolveGitChangesTarget(message: Record<string, unknown>, dir: string) {
if (message.type !== "requestGitChangesContext") return message
if (typeof message.contextDirectory === "string" || typeof message.gitChangesBase === "string") return message
const target = await resolveLocalDiffTarget(ops(), () => undefined, dir)
if (!target) return { ...message, contextDirectory: dir }
return { ...message, contextDirectory: target.directory, gitChangesBase: target.baseBranch }
}
@@ -3,6 +3,9 @@ import { retry } from "../services/cli-backend/retry"
export const MESSAGE_PAGE_LIMIT = 80
// Bound assistant-boundary backfill so corrupt histories cannot load an entire session.
const FILL_LIMIT = 2
/**
* Build the same base64url-encoded cursor format the server emits so a
* synthesized cursor round-trips through `session.messages({ before })`.
@@ -46,12 +49,13 @@ export async function fetchMessagePage(
return { items, cursor }
}
const fill = async (page: Awaited<ReturnType<typeof read>>): Promise<Awaited<ReturnType<typeof read>>> => {
const fill = async (page: Awaited<ReturnType<typeof read>>, depth = 0): Promise<Awaited<ReturnType<typeof read>>> => {
if (page.items[0]?.info.role !== "assistant") return page
if (depth >= FILL_LIMIT) return page
if (!page.cursor || input.signal?.aborted) return page
const next = await read(page.cursor)
const items = [...next.items, ...page.items]
return fill({ items, cursor: next.cursor })
return fill({ items, cursor: next.cursor }, depth + 1)
}
return fill(await read(input.before))
@@ -0,0 +1,213 @@
import * as fs from "fs/promises"
import * as path from "path"
import { spawn } from "../../util/process"
const LIMIT = 400_000
const SMALL = 80_000
const TIMEOUT = 15_000
type Result = {
out: string
err: string
code: number | null
signal: NodeJS.Signals | null
truncated: boolean
error?: string
}
export async function getGitChangesContext(
dir: string,
base?: string,
): Promise<{ content: string; truncated: boolean }> {
const probe = await run(["rev-parse", "--is-inside-work-tree"], dir, SMALL)
if (probe.error) return done(dir, `Unable to read git changes: ${probe.error}`)
if (probe.code !== 0 || probe.out.trim() !== "true") return done(dir, "Not a git repository.")
const head = await run(["rev-parse", "--verify", "HEAD"], dir, SMALL)
if (head.error) return done(dir, `Unable to read git changes: ${head.error}`)
if (base && head.code === 0) return await against(dir, base)
return await local(dir, head.code === 0)
}
async function local(dir: string, born: boolean): Promise<{ content: string; truncated: boolean }> {
const [status, diff, untracked] = await Promise.all([
run(["status", "--short"], dir, SMALL),
changes(dir, born),
run(["ls-files", "--others", "--exclude-standard", "-z"], dir, SMALL),
])
const fail = status.error ?? diff.error ?? untracked.error
if (fail) return done(dir, `Unable to read git changes: ${fail}`)
if (status.code !== 0 && !status.truncated) return done(dir, `Unable to read git status:\n${output(status)}`.trim())
if (diff.code !== 0 && !diff.truncated) return done(dir, `Unable to read git diff:\n${output(diff)}`.trim())
if (untracked.code !== 0 && !untracked.truncated)
return done(dir, `Unable to read untracked files:\n${output(untracked)}`.trim())
const extra = await untrackedDiff(dir, untracked.out)
const body = [diff.out.trim(), extra.content.trim()].filter(Boolean).join("\n\n")
const changed = status.out.trim() || body.trim()
if (!changed) return done(dir, "No changes in working directory.")
const truncated = status.truncated || diff.truncated || untracked.truncated || extra.truncated
const note = truncated ? "\n\nOutput truncated." : ""
return cap(
`Working directory: ${dir}\n\nStatus:\n${status.out.trim() || "(empty)"}\n\nDiff:\n${body || "(empty)"}${note}`,
truncated,
)
}
async function against(dir: string, base: string): Promise<{ content: string; truncated: boolean }> {
const ancestor = await run(["merge-base", "HEAD", base], dir, SMALL)
if (ancestor.error) return done(dir, `Unable to resolve git base ${base}: ${ancestor.error}`)
if (ancestor.code !== 0) return done(dir, `Unable to resolve git base ${base}:\n${output(ancestor)}`.trim())
const ref = ancestor.out.trim()
const [status, diff, untracked] = await Promise.all([
run(["diff", "--name-status", "--no-renames", ref], dir, SMALL),
run(["diff", ref], dir, LIMIT),
run(["ls-files", "--others", "--exclude-standard", "-z"], dir, SMALL),
])
const fail = status.error ?? diff.error ?? untracked.error
if (fail) return done(dir, `Unable to read git changes: ${fail}`)
if (status.code !== 0 && !status.truncated)
return done(dir, `Unable to read changed files:\n${output(status)}`.trim())
if (diff.code !== 0 && !diff.truncated) return done(dir, `Unable to read git diff:\n${output(diff)}`.trim())
if (untracked.code !== 0 && !untracked.truncated)
return done(dir, `Unable to read untracked files:\n${output(untracked)}`.trim())
const extra = await untrackedDiff(dir, untracked.out)
const files = [status.out.trim(), listed(untracked.out)].filter(Boolean).join("\n")
const body = [diff.out.trim(), extra.content.trim()].filter(Boolean).join("\n\n")
const changed = files.trim() || body.trim()
if (!changed) return done(dir, `Base: ${base}\n\nNo changes in worktree diff.`)
const truncated = status.truncated || diff.truncated || untracked.truncated || extra.truncated
const note = truncated ? "\n\nOutput truncated." : ""
return cap(
`Working directory: ${dir}\nBase: ${base}\nMerge base: ${ref}\n\nFiles:\n${files || "(empty)"}\n\nDiff:\n${body || "(empty)"}${note}`,
truncated,
)
}
function listed(raw: string): string {
return raw
.split("\0")
.filter(Boolean)
.map((file) => `A\t${file}`)
.join("\n")
}
async function changes(dir: string, born: boolean): Promise<Result> {
if (born) return run(["diff", "HEAD"], dir, LIMIT)
const [cached, work] = await Promise.all([run(["diff", "--cached"], dir, LIMIT), run(["diff"], dir, LIMIT)])
return {
out: [cached.out.trim(), work.out.trim()].filter(Boolean).join("\n\n"),
err: [cached.err.trim(), work.err.trim()].filter(Boolean).join("\n"),
code: cached.code !== 0 ? cached.code : work.code,
signal: cached.signal ?? work.signal,
truncated: cached.truncated || work.truncated,
error: cached.error ?? work.error,
}
}
async function untrackedDiff(dir: string, raw: string): Promise<{ content: string; truncated: boolean }> {
const files = raw.split("\0").filter(Boolean)
const parts: string[] = []
let used = 0
let truncated = false
for (const file of files) {
const full = path.join(dir, file)
const stat = await fs.stat(full).catch(() => undefined)
if (!stat?.isFile()) continue
if (stat.size > LIMIT) {
truncated = true
parts.push(patch(file, `<${stat.size} byte file omitted>`))
continue
}
const buf = await fs.readFile(full).catch(() => undefined)
const next = !buf
? patch(file, `<unreadable file: ${file}>`)
: binary(buf)
? patch(file, `<binary file omitted: ${file}>`)
: patch(file, buf.toString("utf8"))
const size = Buffer.byteLength(next, "utf8") + (parts.length ? 2 : 0)
if (used + size > LIMIT) {
truncated = true
break
}
parts.push(next)
used += size
}
return cap(parts.join("\n\n"), truncated)
}
function binary(buf: Buffer): boolean {
const head = buf.subarray(0, Math.min(buf.length, 8192))
return head.includes(0)
}
function patch(file: string, text: string) {
const header = `diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}`
if (!text) return header
const lines = text.endsWith("\n") ? text.slice(0, -1).split("\n") : text.split("\n")
const body = lines.map((line) => `+${line}`).join("\n")
return `${header}\n@@ -0,0 +1,${lines.length} @@\n${body}`
}
function cap(content: string, truncated = false) {
if (Buffer.byteLength(content, "utf8") <= LIMIT) return { content, truncated }
const text = Buffer.from(content, "utf8").subarray(0, LIMIT).toString("utf8")
return { content: text, truncated: true }
}
function done(dir: string, text: string) {
return { content: `Working directory: ${dir}\n\n${text}`, truncated: false }
}
function output(result: Result) {
return `${result.err.trim()}${result.err.trim() && result.out.trim() ? "\n" : ""}${result.out.trim()}`
}
function run(args: string[], cwd: string, limit: number): Promise<Result> {
return new Promise((resolve) => {
const state = { out: "", err: "", done: false, truncated: false }
const child = spawn("git", args, { cwd })
const timer = setTimeout(() => {
state.truncated = true
child.kill()
}, TIMEOUT)
const finish = (result: Pick<Result, "code" | "signal" | "error">) => {
if (state.done) return
state.done = true
clearTimeout(timer)
resolve({ out: state.out, err: state.err, truncated: state.truncated, ...result })
}
const collect = (key: "out" | "err", chunk: Buffer) => {
if (state.truncated) return
const used = Buffer.byteLength(state[key], "utf8")
const free = limit - used
if (free <= 0) {
state.truncated = true
child.kill()
return
}
if (chunk.byteLength > free) {
state[key] += chunk.subarray(0, free).toString("utf8")
state.truncated = true
child.kill()
return
}
state[key] += chunk.toString("utf8")
}
child.stdout?.on("data", (chunk: Buffer) => collect("out", chunk))
child.stderr?.on("data", (chunk: Buffer) => collect("err", chunk))
child.on("error", (err) => finish({ code: null, signal: null, error: err.message }))
child.on("close", (code, signal) => finish({ code, signal }))
})
}
@@ -31,7 +31,7 @@ describe("AT_PATTERN", () => {
})
describe("buildMentionResults", () => {
it("includes terminal for empty mention query", () => {
it("includes special mentions for empty mention query", () => {
const result = buildMentionResults("", [])
expect(result[0]).toEqual({
type: "terminal",
@@ -39,6 +39,12 @@ describe("buildMentionResults", () => {
label: "Terminal",
description: "Active terminal output",
})
expect(result[1]).toEqual({
type: "git-changes",
value: "git-changes",
label: "Git changes",
description: "Current session/worktree changes",
})
})
it("includes terminal for matching prefix", () => {
@@ -46,11 +52,21 @@ describe("buildMentionResults", () => {
expect(result.map((item) => item.type)).toEqual(["terminal", "file"])
})
it("omits terminal for unrelated query", () => {
it("includes git changes for matching prefix", () => {
const result = buildMentionResults("git", ["src/git.ts"])
expect(result.map((item) => item.type)).toEqual(["git-changes", "file"])
})
it("omits special mentions for unrelated query", () => {
const result = buildMentionResults("src", ["src/index.ts"])
expect(result.map((item) => item.type)).toEqual(["file"])
})
it("omits git changes when git is unavailable", () => {
const result = buildMentionResults("git", ["src/git.ts"], false)
expect(result.map((item) => item.type)).toEqual(["file"])
})
it("includes folder results", () => {
const result = buildMentionResults("src", [{ path: "src", type: "folder" }])
expect(result).toEqual([{ type: "folder", value: "src" }])
@@ -0,0 +1,130 @@
import { describe, expect, it } from "bun:test"
import * as fs from "fs/promises"
import * as os from "os"
import * as path from "path"
import { getGitChangesContext } from "../../src/services/git/context"
function git(cwd: string, args: string[]) {
const result = Bun.spawnSync({ cmd: ["git", ...args], cwd, stdout: "pipe", stderr: "pipe" })
if (result.exitCode === 0) return Buffer.from(result.stdout).toString("utf8")
throw new Error(Buffer.from(result.stderr).toString("utf8") || Buffer.from(result.stdout).toString("utf8"))
}
async function repo(run: (dir: string) => Promise<void>, commit = true) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-git-context-"))
try {
git(dir, ["init"])
if (commit)
git(dir, ["-c", "user.name=Kilo", "-c", "user.email=kilo@example.com", "commit", "--allow-empty", "-m", "init"])
await run(dir)
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
}
describe("getGitChangesContext", () => {
it("reports non-git directories", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-no-git-"))
try {
const result = await getGitChangesContext(dir)
expect(result.content).toContain("Not a git repository.")
expect(result.truncated).toBe(false)
} finally {
await fs.rm(dir, { recursive: true, force: true })
}
})
it("reports clean repositories", async () => {
await repo(async (dir) => {
const result = await getGitChangesContext(dir)
expect(result.content).toContain("No changes in working directory.")
expect(result.truncated).toBe(false)
})
})
it("includes tracked diffs", async () => {
await repo(async (dir) => {
await fs.writeFile(path.join(dir, "tracked.txt"), "before\n")
git(dir, ["add", "tracked.txt"])
git(dir, ["-c", "user.name=Kilo", "-c", "user.email=kilo@example.com", "commit", "-m", "tracked"])
await fs.writeFile(path.join(dir, "tracked.txt"), "after\n")
const result = await getGitChangesContext(dir)
expect(result.content).toContain("M tracked.txt")
expect(result.content).toContain("diff --git a/tracked.txt b/tracked.txt")
expect(result.content).toContain("+after")
})
})
it("includes base diffs with patches", async () => {
await repo(async (dir) => {
await fs.writeFile(path.join(dir, "base.txt"), "before\n")
git(dir, ["add", "base.txt"])
git(dir, ["-c", "user.name=Kilo", "-c", "user.email=kilo@example.com", "commit", "-m", "base"])
await fs.writeFile(path.join(dir, "base.txt"), "after\n")
git(dir, ["add", "base.txt"])
git(dir, ["-c", "user.name=Kilo", "-c", "user.email=kilo@example.com", "commit", "-m", "change"])
await fs.writeFile(path.join(dir, "new.txt"), "new\n")
const result = await getGitChangesContext(dir, "HEAD~1")
expect(result.content).toContain("Base: HEAD~1")
expect(result.content).toContain("M\tbase.txt")
expect(result.content).toContain("A\tnew.txt")
expect(result.content).toContain("diff --git a/base.txt b/base.txt")
expect(result.content).toContain("+after")
expect(result.content).toContain("diff --git a/new.txt b/new.txt")
expect(result.content).toContain("+new")
})
})
it("includes untracked file contents as patches", async () => {
await repo(async (dir) => {
await fs.mkdir(path.join(dir, "src"))
await fs.writeFile(path.join(dir, "src", "new.txt"), "hello\nworld\n")
const result = await getGitChangesContext(dir)
expect(result.content).toContain("?? src/")
expect(result.content).toContain("diff --git a/src/new.txt b/src/new.txt")
expect(result.content).toContain("--- /dev/null")
expect(result.content).toContain("+++ b/src/new.txt")
expect(result.content).toContain("+hello")
expect(result.content).toContain("+world")
})
})
it("handles repositories without an initial commit", async () => {
await repo(async (dir) => {
await fs.writeFile(path.join(dir, "new.txt"), "hello\n")
git(dir, ["add", "new.txt"])
const result = await getGitChangesContext(dir)
expect(result.content).not.toContain("Unable to read git diff")
expect(result.content).toContain("A new.txt")
expect(result.content).toContain("diff --git a/new.txt b/new.txt")
expect(result.content).toContain("+hello")
}, false)
})
it("omits binary untracked file contents", async () => {
await repo(async (dir) => {
const bytes = Buffer.from([0x00, 0x01, 0x02, 0x03, 0x04, 0xff, 0xfe])
await fs.writeFile(path.join(dir, "blob.bin"), bytes)
const result = await getGitChangesContext(dir)
expect(result.content).toContain("diff --git a/blob.bin b/blob.bin")
expect(result.content).toContain("<binary file omitted: blob.bin>")
expect(result.content).not.toContain("+\u0001")
})
})
it("omits the hunk header for empty untracked files", async () => {
await repo(async (dir) => {
await fs.writeFile(path.join(dir, "empty.txt"), "")
const result = await getGitChangesContext(dir)
expect(result.content).toContain("diff --git a/empty.txt b/empty.txt")
expect(result.content).toContain("new file mode 100644")
expect(result.content).not.toMatch(/empty\.txt[\s\S]*@@ -0,0/)
})
})
})
@@ -14,7 +14,9 @@ import fs from "node:fs"
import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "../..")
const MESSAGES_FILE = path.join(ROOT, "webview-ui/src/types/messages.ts")
const MESSAGES_DIR = path.join(ROOT, "webview-ui/src/types/messages")
const EXTENSION_MESSAGES_FILE = path.join(MESSAGES_DIR, "extension-messages.ts")
const WEBVIEW_MESSAGES_FILE = path.join(MESSAGES_DIR, "webview-messages.ts")
const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
const KILO_PROVIDER_UTILS_FILE = path.join(ROOT, "src/kilo-provider-utils.ts")
// Some wire types (partUpdated, partsUpdated) live in a file shared by the
@@ -25,52 +27,68 @@ function readFile(filePath: string): string {
return fs.readFileSync(filePath, "utf-8")
}
function readMessagesDir(): string {
return fs
.readdirSync(MESSAGES_DIR)
.filter((f) => f.endsWith(".ts"))
.map((f) => readFile(path.join(MESSAGES_DIR, f)))
.join("\n")
}
function readMessageTypeSources(): string {
return readFile(MESSAGES_FILE) + "\n" + readFile(SHARED_STREAM_MESSAGES_FILE)
return readMessagesDir() + "\n" + readFile(SHARED_STREAM_MESSAGES_FILE)
}
/**
* Extract the named union's member names from a single source file.
*
* Reads each ` | MemberName` line after `export type Name =`, skipping
* blank lines and `//` comments, and stops at the first other line.
*/
function extractUnionMembers(src: string, name: string): string[] {
const lines = src.split("\n")
const start = lines.findIndex((l) => new RegExp(`^export type ${name}\\s*=\\s*$`).test(l))
if (start === -1) throw new Error(`Could not find union "${name}"`)
const members: string[] = []
for (const line of lines.slice(start + 1)) {
const m = line.match(/^\s*\|\s*([A-Z]\w+)\b/)
if (m) {
members.push(m[1]!)
continue
}
if (line.trim() === "" || /^\s*\/\//.test(line)) continue
break
}
return members
}
describe("ExtensionMessage type members", () => {
it("all members of ExtensionMessage union are defined as interfaces/types in messages.ts", () => {
const content = readFile(MESSAGES_FILE)
// Extract ExtensionMessage union members
const unionMatch = content.match(
/export type ExtensionMessage\s*=\s*([\s\S]*?)(?=\nexport type|\nexport interface|\nexport function|\n\/\/|$)/,
)
if (!unionMatch) {
expect(false, "Could not find ExtensionMessage union in messages.ts").toBe(true)
return
}
const unionBody = unionMatch[1]!
const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!)
it("all members of ExtensionMessage union are defined in message type sources", () => {
const memberNames = extractUnionMembers(readFile(EXTENSION_MESSAGES_FILE), "ExtensionMessage")
const defined = readMessageTypeSources()
const missing = memberNames.filter((name) => {
return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined)
})
expect(missing, `ExtensionMessage members without definitions: ${missing.join(", ")}`).toEqual([])
expect(
missing,
`ExtensionMessage members without definitions in message type sources: ${missing.join(", ")}`,
).toEqual([])
})
it("all members of WebviewMessage union are defined as interfaces/types in messages.ts", () => {
const content = readFile(MESSAGES_FILE)
const unionMatch = content.match(/export type WebviewMessage\s*=\s*([\s\S]*?)(?=\n\/\/|$)/)
if (!unionMatch) {
expect(false, "Could not find WebviewMessage union in messages.ts").toBe(true)
return
}
const unionBody = unionMatch[1]!
const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!)
it("all members of WebviewMessage union are defined in message type sources", () => {
const memberNames = extractUnionMembers(readFile(WEBVIEW_MESSAGES_FILE), "WebviewMessage")
const defined = readMessageTypeSources()
const missing = memberNames.filter((name) => {
return !new RegExp(`(interface|type)\\s+${name}\\b`).test(defined)
})
expect(missing, `WebviewMessage members without definitions: ${missing.join(", ")}`).toEqual([])
expect(
missing,
`WebviewMessage members without definitions in message type sources: ${missing.join(", ")}`,
).toEqual([])
})
})
@@ -109,6 +127,9 @@ describe("mapSSEEventToWebviewMessage output types", () => {
const missing = typeMatches.filter((t) => !typeSet.has(t))
expect(missing, `Types in mapSSEEventToWebviewMessage not in messages.ts: ${missing.join(", ")}`).toEqual([])
expect(
missing,
`Types in mapSSEEventToWebviewMessage not found in message type sources: ${missing.join(", ")}`,
).toEqual([])
})
})
@@ -164,4 +164,34 @@ describe("fetchMessagePage / cursor fallback", () => {
expect(page.items.map((item) => item.info.id)).toEqual(["m1", "m2", "m3", "m4", "m5"])
expect(page.cursor).toBeUndefined()
})
it("bounds assistant turn filling when older pages never reach a user message", async () => {
const { client, calls } = mockClient([
{
items: [message("m5", "assistant", 50), message("m6", "assistant", 60)],
cursor: "c1",
},
{
items: [message("m3", "assistant", 30), message("m4", "assistant", 40)],
cursor: "c2",
},
{
items: [message("m1", "assistant", 10), message("m2", "assistant", 20)],
cursor: "c3",
},
{
items: [message("m0", "user", 0)],
},
])
const page = await fetchMessagePage(client as never, {
sessionID: "s1",
workspaceDir: "/repo",
limit: 2,
})
expect(calls.map((call) => call.before)).toEqual([undefined, "c1", "c2"])
expect(page.items.map((item) => item.info.id)).toEqual(["m1", "m2", "m3", "m4", "m5", "m6"])
expect(page.cursor).toBe("c3")
})
})
@@ -1,5 +1,10 @@
import { describe, expect, it } from "bun:test"
import { activeUserMessageID, messageTurns, queuedUserMessageIDs } from "../../webview-ui/src/context/session-queue"
import {
activeUserMessageID,
messageTurns,
queuedUserMessageIDs,
stableMessageTurns,
} from "../../webview-ui/src/context/session-queue"
import type { Message } from "../../webview-ui/src/types/messages"
const base = {
@@ -84,6 +89,56 @@ describe("messageTurns", () => {
{ user: "message_4", assistant: [] },
])
})
it("surfaces leading assistant output as partial turns grouped by parent", () => {
const messages = [
assistant("message_2", "message_1"),
assistant("message_4", "message_3"),
assistant("message_5", "message_3"),
user("message_6"),
]
const turns = messageTurns(messages)
expect(
turns.map((turn) => ({ id: turn.id, partial: turn.partial, assistant: turn.assistant.map((msg) => msg.id) })),
).toEqual([
{ id: "message_1", partial: true, assistant: ["message_2"] },
{ id: "message_3", partial: true, assistant: ["message_4", "message_5"] },
{ id: "message_6", partial: undefined, assistant: [] },
])
})
})
describe("stableMessageTurns", () => {
it("keeps existing turn identities stable when older turns are prepended", () => {
const u1 = user("message_1")
const a2 = assistant("message_2", "message_1")
const u3 = user("message_3")
const prev = messageTurns([u1, a2, u3])
const next = stableMessageTurns(messageTurns([user("message_0"), u1, a2, u3]), prev)
expect(next[1]).toBe(prev[0])
expect(next[2]).toBe(prev[1])
})
it("replaces a turn identity when its assistant messages change", () => {
const u1 = user("message_1")
const a2 = assistant("message_2", "message_1")
const prev = messageTurns([u1, a2])
const next = stableMessageTurns(messageTurns([u1, a2, assistant("message_3", "message_1")]), prev)
expect(next[0]).not.toBe(prev[0])
expect(next[0]?.assistant.map((msg) => msg.id)).toEqual(["message_2", "message_3"])
})
it("keeps partial turn identities stable while their assistant messages are unchanged", () => {
const a2 = assistant("message_2", "message_1")
const a3 = assistant("message_3", "message_1")
const prev = messageTurns([a2, a3])
const next = stableMessageTurns(messageTurns([a2, a3, user("message_4")]), prev)
expect(next[0]).toBe(prev[0])
})
})
describe("activeUserMessageID", () => {
@@ -381,10 +381,10 @@ describe("constants", () => {
})
it("KNOWN_KEYS matches all keys in the Config interface (drift guard)", async () => {
// Read the Config interface from messages.ts and extract its keys.
// Read the Config interface from messages/config.ts and extract its keys.
// If someone adds a new field to Config, this test fails as a reminder
// to also add it to KNOWN_KEYS in settings-io.ts.
const src = await Bun.file(require("path").join(__dirname, "../../webview-ui/src/types/messages.ts")).text()
const src = await Bun.file(require("path").join(__dirname, "../../webview-ui/src/types/messages/config.ts")).text()
const match = src.match(/export interface Config \{([^}]+)\}/)
expect(match).not.toBeNull()
const body = match![1]
@@ -0,0 +1,223 @@
import { describe, it, expect } from "bun:test"
import { createTabOrderSync, type TabOrderSyncDeps } from "../../webview-ui/agent-manager/tab-order-sync"
import { applyTabOrder } from "../../webview-ui/agent-manager/tab-order"
// Builds a simulated AgentManager tab state with controllable accessors.
// Mirrors the real call order: source state (localSessionIDs, terminals)
// is mutated BEFORE the factory method is invoked.
function scene(init: {
order?: Record<string, string[]>
sessions?: string[]
worktreeSessions?: { key: string; ids: string[] }[]
review?: Record<string, boolean>
terminals?: Record<string, string[]>
}) {
const state = {
order: { ...(init.order ?? {}) } as Record<string, string[]>,
localIds: [...(init.sessions ?? [])],
worktreeSessions: init.worktreeSessions ?? [],
review: init.review ?? {},
terminals: { ...(init.terminals ?? {}) } as Record<string, string[]>,
persisted: [] as { key: string; order: string[] }[],
}
const deps: TabOrderSyncDeps = {
LOCAL: "LOCAL",
REVIEW_TAB_ID: "review",
order: () => state.order,
setOrder: (u) => {
state.order = u(state.order)
},
persist: (key, order) => {
state.persisted.push({ key, order: [...order] })
},
localSessionIDs: () => state.localIds,
sessions: () =>
state.worktreeSessions.flatMap((w, wi) =>
w.ids.map((id, i) => ({ id, createdAt: new Date(1700000000000 + wi * 1000 + i).toISOString() })),
),
managedSessions: () => state.worktreeSessions.flatMap((w) => w.ids.map((id) => ({ id, worktreeId: w.key }))),
reviewOpenByContext: () => state.review,
terminalIdsFor: (key) => state.terminals[key] ?? [],
}
return { state, sync: createTabOrderSync(deps), deps }
}
// Simulate how `tabIds()` renders the final tab bar: base composed as
// `[...sessions, review, ...terminals]` and `applyTabOrder` layered on top.
function render(deps: TabOrderSyncDeps, key: string): string[] {
const sids =
key === deps.LOCAL
? deps.localSessionIDs()
: deps
.sessions()
.filter((s) => deps.managedSessions().some((ms) => ms.id === s.id && ms.worktreeId === key))
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.map((s) => s.id)
const withReview = deps.reviewOpenByContext()[key] === true ? [...sids, deps.REVIEW_TAB_ID] : sids
const base = [...withReview, ...deps.terminalIdsFor(key)]
return applyTabOrder(
base.map((id) => ({ id })),
deps.order()[key],
).map((i) => i.id)
}
describe("createTabOrderSync.append", () => {
it("puts a new pending tab at the tail when a terminal already exists (regression)", () => {
// Setup: existing session s1, existing terminal t1, no stored order yet
// (terminal append was a no-op under the old logic because t1 was
// already in base). User presses Cmd+T → pending_1 added to local
// sessions first, then append is called.
const { state, sync, deps } = scene({
sessions: ["s1", "pending_1"],
terminals: { LOCAL: ["t1"] },
})
sync.append("LOCAL", "pending_1")
expect(render(deps, "LOCAL")).toEqual(["s1", "t1", "pending_1"])
expect(state.order.LOCAL).toEqual(["s1", "t1", "pending_1"])
})
it("appends to tail when stored order exists and lacks the id", () => {
const { state, sync, deps } = scene({
order: { LOCAL: ["s1", "t1"] },
sessions: ["s1", "s2"],
terminals: { LOCAL: ["t1"] },
})
sync.append("LOCAL", "s2")
expect(state.order.LOCAL).toEqual(["s1", "t1", "s2"])
expect(render(deps, "LOCAL")).toEqual(["s1", "t1", "s2"])
})
it("moves the id to the tail if it was already present in stored", () => {
const { state, sync } = scene({
order: { LOCAL: ["s1", "s2", "t1"] },
sessions: ["s1", "s2"],
terminals: { LOCAL: ["t1"] },
})
sync.append("LOCAL", "s1")
expect(state.order.LOCAL).toEqual(["s2", "t1", "s1"])
})
it("resolves undefined key to LOCAL", () => {
const { state, sync } = scene({ sessions: ["s1"] })
sync.append(undefined, "s1")
expect(state.order.LOCAL).toEqual(["s1"])
})
it("handles an empty base (nothing mutated yet) by just writing the id", () => {
const { state, sync } = scene({})
sync.append("LOCAL", "x")
expect(state.order.LOCAL).toEqual(["x"])
})
})
describe("createTabOrderSync.replaceOrAppend", () => {
it("swaps a pending id for a real session id, preserving position", () => {
const { state, sync, deps } = scene({
order: { LOCAL: ["s1", "pending_1", "t1"] },
sessions: ["s1", "real_1"], // caller already mapped pending_1 → real_1
terminals: { LOCAL: ["t1"] },
})
sync.replaceOrAppend("LOCAL", "pending_1", "real_1")
expect(state.order.LOCAL).toEqual(["s1", "real_1", "t1"])
expect(render(deps, "LOCAL")).toEqual(["s1", "real_1", "t1"])
})
it("appends at the tail when the anchor isn't in stored", () => {
const { state, sync } = scene({
order: { LOCAL: ["s1", "t1"] },
sessions: ["s1", "s2"], // s2 not yet in stored
terminals: { LOCAL: ["t1"] },
})
sync.replaceOrAppend("LOCAL", "missing", "s2")
expect(state.order.LOCAL).toEqual(["s1", "t1", "s2"])
})
})
describe("createTabOrderSync.insertAfter", () => {
it("inserts a fork directly after its parent in the rendered order", () => {
const { state, sync, deps } = scene({
order: { LOCAL: ["s1", "t1"] },
sessions: ["s1", "child", "s2"], // caller already inserted child after s1
terminals: { LOCAL: ["t1"] },
})
sync.insertAfter("LOCAL", "s1", "child")
expect(state.order.LOCAL).toEqual(["s1", "child", "t1", "s2"])
// Rendered result has child immediately after s1.
expect(render(deps, "LOCAL")).toEqual(["s1", "child", "t1", "s2"])
})
it("appends when the anchor is missing entirely", () => {
const { state, sync } = scene({
order: { LOCAL: ["s1"] },
sessions: ["s1", "fork"],
})
sync.insertAfter("LOCAL", "missing_anchor", "fork")
expect(state.order.LOCAL).toEqual(["s1", "fork"])
})
it("seeds from base when no stored order exists (first fork)", () => {
const { state, sync } = scene({
sessions: ["s1", "child", "s2"],
})
sync.insertAfter("LOCAL", "s1", "child")
expect(state.order.LOCAL).toEqual(["s1", "child", "s2"])
})
})
describe("createTabOrderSync persistence filter", () => {
it("callers strip transient ids before persisting (review + terminal:* never hit disk)", () => {
// Mirror AgentManagerApp's real filter: strip review + terminal ids.
const { state, sync } = scene({
sessions: ["s1", "pending_1"],
terminals: { LOCAL: ["terminal:abc"] },
review: { LOCAL: true },
})
// Rebuild sync with a filtering persist to mimic the call site.
const filteredSync = createTabOrderSync({
LOCAL: "LOCAL",
REVIEW_TAB_ID: "review",
order: () => state.order,
setOrder: (u) => {
state.order = u(state.order)
},
persist: (key, order) => {
const clean = order.filter((id) => id !== "review" && !id.startsWith("terminal:"))
state.persisted.push({ key, order: clean })
},
localSessionIDs: () => state.localIds,
sessions: () => [],
managedSessions: () => [],
reviewOpenByContext: () => state.review,
terminalIdsFor: (key) => state.terminals[key] ?? [],
})
filteredSync.append("LOCAL", "pending_1")
// In-memory order still has terminals/review for drag state.
expect(state.order.LOCAL).toEqual(["s1", "review", "terminal:abc", "pending_1"])
// Persisted payload is session-only.
expect(state.persisted.at(-1)?.order).toEqual(["s1", "pending_1"])
})
})
describe("createTabOrderSync cross-method scenario", () => {
it("preserves position through terminal → pending → real lifecycle", () => {
const s = scene({ sessions: ["s1"] })
// 1. Terminal created — caller added t1 to terms state first.
s.state.terminals.LOCAL = ["t1"]
s.sync.append("LOCAL", "t1")
expect(s.state.order.LOCAL).toEqual(["s1", "t1"])
// 2. User presses Cmd+T → pending_1 appended to localIds first.
s.state.localIds = ["s1", "pending_1"]
s.sync.append("LOCAL", "pending_1")
expect(s.state.order.LOCAL).toEqual(["s1", "t1", "pending_1"])
// 3. Real session created → caller mapped pending_1 → real_1 in localIds.
s.state.localIds = ["s1", "real_1"]
s.sync.replaceOrAppend("LOCAL", "pending_1", "real_1")
expect(s.state.order.LOCAL).toEqual(["s1", "t1", "real_1"])
// Rendered tab bar keeps the new session in its slot (right of t1).
expect(render(s.deps, "LOCAL")).toEqual(["s1", "t1", "real_1"])
})
})
@@ -1,5 +1,11 @@
import { describe, it, expect } from "bun:test"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "../../webview-ui/agent-manager/tab-order"
import {
reorderTabs,
applyTabOrder,
firstOrderedTitle,
replaceInTabOrder,
insertInTabOrderAfter,
} from "../../webview-ui/agent-manager/tab-order"
describe("reorderTabs", () => {
const tabs = ["a", "b", "c", "d"]
@@ -146,6 +152,63 @@ describe("firstOrderedTitle", () => {
})
})
describe("replaceInTabOrder", () => {
it("replaces an id while preserving position", () => {
expect(replaceInTabOrder(["a", "b", "c"], "b", "B")).toEqual(["a", "B", "c"])
})
it("replaces at the head", () => {
expect(replaceInTabOrder(["a", "b"], "a", "A")).toEqual(["A", "b"])
})
it("replaces at the tail", () => {
expect(replaceInTabOrder(["a", "b"], "b", "B")).toEqual(["a", "B"])
})
it("returns undefined when oldId is not in order", () => {
expect(replaceInTabOrder(["a", "b"], "x", "X")).toBeUndefined()
})
it("returns undefined when order is undefined", () => {
expect(replaceInTabOrder(undefined, "a", "A")).toBeUndefined()
})
it("does not mutate the original array", () => {
const order = ["a", "b"]
replaceInTabOrder(order, "a", "A")
expect(order).toEqual(["a", "b"])
})
})
describe("insertInTabOrderAfter", () => {
it("inserts directly after the anchor", () => {
expect(insertInTabOrderAfter(["a", "b", "c"], "b", "B2")).toEqual(["a", "b", "B2", "c"])
})
it("inserts after the last item", () => {
expect(insertInTabOrderAfter(["a", "b"], "b", "c")).toEqual(["a", "b", "c"])
})
it("appends when anchor is missing", () => {
expect(insertInTabOrderAfter(["a", "b"], "x", "c")).toEqual(["a", "b", "c"])
})
it("appends when order is undefined", () => {
expect(insertInTabOrderAfter(undefined, "x", "c")).toEqual(["c"])
})
it("returns the base unchanged when id is already present", () => {
const order = ["a", "b"]
expect(insertInTabOrderAfter(order, "a", "b")).toBe(order)
})
it("does not mutate the original array", () => {
const order = ["a", "b"]
insertInTabOrderAfter(order, "a", "c")
expect(order).toEqual(["a", "b"])
})
})
// Helper: simulate reconciliation the same way handleDragOver does
function reconcile(current: string[], stored: string[]): string[] {
return applyTabOrder(
@@ -85,6 +85,7 @@ import { useLanguage } from "../src/context/language"
import { formatRelativeDate } from "../src/utils/date"
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, restoreLocalSessions, LOCAL } from "./navigate"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
import { createTabOrderSync } from "./tab-order-sync"
import { ConstrainDragYAxis } from "./sortable-tab"
import { isTerminalTabId, createTerminalState, createTerminalHandlers, createTerminalMessageHandler } from "./terminal"
import { renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
@@ -667,17 +668,24 @@ const AgentManagerContent: Component = () => {
const [renamingSection, setRenamingSection] = createSignal<string | null>(null)
let pendingNewSection = false
/** Append an id to `worktreeTabOrder[key]` and persist server-side.
* Keeps new sessions/terminals at the right end of the tab bar
* instead of slipping in front of already-rendered tabs via
* tabIds()'s `[...sessions, review, ...terminals]` base order. */
const appendToTabOrder = (key: string, id: string) => {
const current = worktreeTabOrder()[key] ?? []
if (current.includes(id)) return
const next = [...current, id]
setWorktreeTabOrder((prev) => ({ ...prev, [key]: next }))
vscode.postMessage({ type: "agentManager.setTabOrder", key, order: next })
// Pin new tabs at the tail (see tab-order-sync); strip ephemeral ids so agent-manager.json stays clean.
const persistTabOrder = (key: string, order: string[]) => {
const durable = order.filter((id) => id !== REVIEW_TAB_ID && !isTerminalTabId(id))
vscode.postMessage({ type: "agentManager.setTabOrder", key, order: durable })
}
const tabOrderSync = createTabOrderSync({
LOCAL,
REVIEW_TAB_ID,
order: worktreeTabOrder,
setOrder: setWorktreeTabOrder,
persist: persistTabOrder,
localSessionIDs,
sessions: session.sessions,
managedSessions,
reviewOpenByContext,
terminalIdsFor: (key) => terms.forSelection(key).map((t) => t.id),
})
const appendToTabOrder = tabOrderSync.append
const addPendingTab = () => {
const id = `${PENDING_PREFIX}${++pendingCounter}`
@@ -1164,9 +1172,8 @@ const AgentManagerContent: Component = () => {
}
window.addEventListener("focus", onWindowFocus)
// When a session is created, add it as a local tab. This handles both direct
// creation from the prompt input and backend-created follow-up sessions (plan
// follow-up "Start new session"). Guard against duplicates (HTTP + SSE can both fire).
// Add created sessions as local tabs (both direct from the prompt and
// backend follow-ups). Dedups HTTP + SSE firing together.
const unsubCreate = vscode.onMessage((msg) => {
if (msg.type !== "sessionCreated") return
const created = msg as { type: string; session: { id: string } }
@@ -1175,10 +1182,12 @@ const AgentManagerContent: Component = () => {
const pending = selection() === LOCAL ? activePendingId() : undefined
if (pending) {
setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? created.session.id : id)))
tabOrderSync.replaceOrAppend(LOCAL, pending, created.session.id)
setActivePendingId(undefined)
} else {
saveTabMemory()
setLocalSessionIDs((prev) => [...prev, created.session.id])
tabOrderSync.append(LOCAL, created.session.id)
setSelection(LOCAL)
}
vscode.postMessage({ type: "agentManager.persistSession", sessionId: created.session.id })
@@ -1196,8 +1205,7 @@ const AgentManagerContent: Component = () => {
setRunStatuses((prev) => ({ ...prev, [ev.worktreeId]: ev }))
})
// Terminal messages live in their own subscription to keep the main
// message handler's cyclomatic complexity under the lint cap.
// Terminal messages have their own subscription to keep main-handler complexity in check.
const terminalDispatch = createTerminalMessageHandler({
state: terms,
activate: termHandlers.activate,
@@ -1257,12 +1265,14 @@ const AgentManagerContent: Component = () => {
if (msg.type === "agentManager.sessionAdded") {
const ev = msg as { type: string; sessionId: string; worktreeId: string }
saveTabMemory()
appendToTabOrder(ev.worktreeId, ev.sessionId)
setSelection(ev.worktreeId)
session.selectSession(ev.sessionId)
}
if (msg.type === "agentManager.sessionForked") {
const ev = msg as { type: string; sessionId: string; forkedFromId: string; worktreeId?: string }
tabOrderSync.insertAfter(ev.worktreeId, ev.forkedFromId, ev.sessionId)
if (!ev.worktreeId) {
// Local session: insert new tab after the forked-from tab
setLocalSessionIDs((prev) => {
@@ -2075,18 +2085,18 @@ const AgentManagerContent: Component = () => {
const sel = selection()
if (sel === null) return
const key = sel === LOCAL ? LOCAL : sel
// Persist the full mixed order. Terminal IDs are ephemeral; on
// reload their PTYs are gone and `applyTabOrder` filters them out.
const order = worktreeTabOrder()[key]
if (order && order.length > 0) {
vscode.postMessage({ type: "agentManager.setTabOrder", key, order })
}
if (order && order.length > 0) persistTabOrder(key, order)
}
const draggedTab = createMemo(() => {
const id = draggingTab()
if (!id) return undefined
if (id === REVIEW_TAB_ID) return { id, title: t("session.tab.review") }
if (isTerminalTabId(id)) {
const term = terms.lookup().get(id)
return term ? { id, title: term.title } : undefined
}
return activeTabs().find((s) => s.id === id)
})
@@ -0,0 +1,83 @@
/**
* Factory for tab-order mutations used when sessions/terminals are created,
* a pending tab is promoted to a real session id, or a session is forked.
*
* Exists as a separate module to keep the tab-order branching out of
* AgentManagerApp's main message-handler arrow (complexity cap).
*/
import { replaceInTabOrder, insertInTabOrderAfter } from "./tab-order"
export interface TabOrderSyncDeps {
/** Constants that identify the local context and review tab. */
LOCAL: string
REVIEW_TAB_ID: string
/** Read/update the `contextKey → ordered tab ids` map (in-memory). */
order: () => Record<string, string[]>
setOrder: (updater: (prev: Record<string, string[]>) => Record<string, string[]>) => void
/** Persist to durable state. Callers should strip transient ids here. */
persist: (key: string, value: string[]) => void
/** State accessors used to rebuild the base order `[sessions, review, terminals]`. */
localSessionIDs: () => string[]
sessions: () => { id: string; createdAt: string }[]
managedSessions: () => { id: string; worktreeId?: string | null }[]
reviewOpenByContext: () => Record<string, boolean>
terminalIdsFor: (key: string) => string[]
}
export function createTabOrderSync(deps: TabOrderSyncDeps) {
const baseFor = (key: string): string[] => {
const sids =
key === deps.LOCAL
? deps.localSessionIDs()
: deps
.sessions()
.filter((s) => deps.managedSessions().some((ms) => ms.id === s.id && ms.worktreeId === key))
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
.map((s) => s.id)
const withReview = deps.reviewOpenByContext()[key] === true ? [...sids, deps.REVIEW_TAB_ID] : sids
return [...withReview, ...deps.terminalIdsFor(key)]
}
const commit = (key: string, next: string[]) => {
deps.setOrder((prev) => ({ ...prev, [key]: next }))
deps.persist(key, next)
}
const resolve = (key: string | undefined): string => key ?? deps.LOCAL
// Merge stored + any base ids not yet in stored — mirrors `applyTabOrder`'s
// output so we can pin `id` at a specific rendered position (tail for
// append, right-of-anchor for insertAfter) regardless of whether the
// caller already mutated source state (localSessionIDs, terms, etc).
const merge = (key: string): string[] => {
const stored = deps.order()[key] ?? []
const set = new Set(stored)
const unknowns = baseFor(key).filter((x) => !set.has(x))
return [...stored, ...unknowns]
}
return {
/** Place `id` at the tail of the persisted order for `key`. */
append(key: string | undefined, id: string) {
const k = resolve(key)
const rest = merge(k).filter((x) => x !== id)
commit(k, [...rest, id])
},
/** Swap `oldId` for `newId` preserving position, or append if missing. */
replaceOrAppend(key: string | undefined, oldId: string, newId: string) {
const k = resolve(key)
const stored = deps.order()[k] ?? []
const swapped = replaceInTabOrder(stored, oldId, newId)
if (swapped) return commit(k, swapped)
const rest = merge(k).filter((x) => x !== newId)
commit(k, [...rest, newId])
},
/** Place `id` directly after `anchorId`; append if anchor is missing. */
insertAfter(key: string | undefined, anchorId: string, id: string) {
const k = resolve(key)
const rest = merge(k).filter((x) => x !== id)
const next = insertInTabOrderAfter(rest, anchorId, id)
commit(k, next)
},
}
}
@@ -39,6 +39,33 @@ export function applyTabOrder<T extends { id: string }>(items: T[], order: strin
return ordered
}
/**
* Replace `oldId` with `newId` in `order`, preserving its position.
* Returns a new array, or undefined if `oldId` isn't in `order`.
* Used when a pending session tab is promoted to a real session id.
*/
export function replaceInTabOrder(order: string[] | undefined, oldId: string, newId: string): string[] | undefined {
if (!order) return undefined
const i = order.indexOf(oldId)
if (i === -1) return undefined
const next = [...order]
next[i] = newId
return next
}
/**
* Insert `id` into `order` directly after `afterId`.
* If `afterId` is missing from `order`, appends `id` at the end.
* Returns a new array, or undefined if `id` is already present.
*/
export function insertInTabOrderAfter(order: string[] | undefined, afterId: string, id: string): string[] {
const base = order ?? []
if (base.includes(id)) return base
const i = base.indexOf(afterId)
if (i === -1) return [...base, id]
return [...base.slice(0, i + 1), id, ...base.slice(i + 1)]
}
/**
* Find the title of the first item according to a custom order.
*
@@ -29,6 +29,8 @@ import {
activeUserMessageID as getActiveUserMessageID,
messageTurns,
queuedUserMessageIDs,
stableMessageTurns,
type MessageTurn,
} from "../../context/session-queue"
import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
@@ -84,7 +86,9 @@ export const MessageList: Component<MessageListProps> = (props) => {
const positions = new Map<string, { top: number; userScrolled: boolean }>()
const boundary = () => session.revert()?.messageID
const turns = createMemo(() => messageTurns(session.messages(), boundary()))
const turns = createMemo((prev: MessageTurn[] | undefined) =>
stableMessageTurns(messageTurns(session.messages(), boundary()), prev),
)
const isEmpty = () => turns().length === 0 && !session.loading() && !boundary()
const recent = createMemo(() =>
@@ -151,11 +155,11 @@ export const MessageList: Component<MessageListProps> = (props) => {
if (pos?.userScrolled) {
el.scrollTop = pos.top
autoScroll.pause()
maybeLoadOlder()
} else {
autoScroll.forceScrollToBottom()
}
setPendingRestore(undefined)
maybeLoadOlder()
})
})
})
@@ -22,7 +22,9 @@ import { ModeSwitcher } from "../shared/ModeSwitcher"
import { ThinkingSelector } from "../shared/ThinkingSelector"
import { useFileMention } from "../../hooks/useFileMention"
import { useTerminalContext } from "../../hooks/useTerminalContext"
import { useGitChangesContext } from "../../hooks/useGitChangesContext"
import { hasTerminalMention } from "../../hooks/terminal-context-utils"
import { hasGitChangesMention } from "../../hooks/git-changes-context-utils"
import { useSlashCommand } from "../../hooks/useSlashCommand"
import { useGhostText } from "../../hooks/useGhostText"
import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments"
@@ -65,11 +67,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const vscode = useVSCode()
const worktree = useWorktreeMode()
const dialog = useDialog()
const mention = useFileMention(
vscode,
() => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID(),
)
const sid = () => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID() ?? undefined
const ctx = () => {
const id = props.boxId
if (!id || !id.startsWith("agent-manager:")) return undefined
const rest = id.slice("agent-manager:".length)
return rest === "unassigned" ? undefined : rest
}
const hasGit = () => server.gitInstalled()
const mention = useFileMention(vscode, sid, hasGit)
const terminal = useTerminalContext(vscode)
const git = useGitChangesContext(vscode, ctx, hasGit)
const slash = useSlashCommand(vscode)
const imageAttach = useImageAttachments()
imageAttach.setFilePathDropHandler((paths) => {
@@ -277,13 +285,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isBusy = () => isPromptBusy(session.status(), !!props.suggesting?.(), !!props.questioning?.())
const isDisabled = () => !server.isConnected()
const hasInput = () => text().trim().length > 0 || imageAttach.images().length > 0 || reviewComments().length > 0
const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !props.blocked?.()
const canSend = () => hasInput() && !isDisabled() && !terminal.pending() && !git.pending() && !props.blocked?.()
const showStop = () => isBusy() && !hasInput()
const isAtEnd = () =>
textareaRef ? atEnd(textareaRef.selectionStart, textareaRef.selectionEnd, textareaRef.value.length) : false
const highlightMentions = () => {
const paths = new Set(mention.mentionedPaths())
if (hasTerminalMention(text())) paths.add("terminal")
if (hasGit() && hasGitChangesMention(text())) paths.add("git-changes")
return paths
}
const placeholder = () => {
@@ -614,21 +623,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const pending = reviewComments()
const review = pending.length > 0 ? formatReviewCommentsMarkdown(pending) : ""
const message = draft && review ? `${review}\n\n${draft}` : draft || review
if ((!message && imgs.length === 0) || isDisabled() || terminal.pending() || props.blocked?.()) return
if ((!message && imgs.length === 0) || isDisabled() || terminal.pending() || git.pending() || props.blocked?.())
return
const mentionFiles = mention.parseFileAttachments(draft)
const imgFiles = imgs.map((img) => ({ mime: img.mime, url: img.dataUrl, filename: img.filename }))
const sel = session.selected()
const pendingId = props.pendingSessionID ?? session.draftSessionID()
const sid = session.currentSessionID()
const id = sid()
const terminalFile = await terminal.resolveAttachment(message, sid).catch((err: Error) => {
const terminalFile = await terminal.resolveAttachment(message, id).catch((err: Error) => {
showToast({ variant: "error", title: "Terminal context unavailable", description: err.message })
return undefined
})
if (hasTerminalMention(message) && !terminalFile) return
const allFiles = [...mentionFiles, ...imgFiles, ...(terminalFile ? [terminalFile] : [])]
const gitFile = await git.resolveAttachment(message, id).catch((err: Error) => {
showToast({ variant: "error", title: "Git changes unavailable", description: err.message })
return undefined
})
if (hasGit() && hasGitChangesMention(message) && !gitFile) return
const allFiles = [
...mentionFiles,
...imgFiles,
...(terminalFile ? [terminalFile] : []),
...(gitFile ? [gitFile] : []),
]
const attachments = allFiles.length > 0 ? allFiles : undefined
const key = draftKey()
@@ -736,6 +757,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<span class="file-mention-name">{item.label}</span>
<span class="file-mention-dir">{item.description}</span>
</>
) : item.type === "git-changes" ? (
<>
<Icon name="branch" class="file-mention-icon" />
<span class="file-mention-name">{item.label}</span>
<span class="file-mention-dir">{item.description}</span>
</>
) : (
<>
<FileIcon
@@ -50,6 +50,7 @@ export interface VscodeTurn {
id: string
user: WebMessage
assistant: WebMessage[]
partial?: boolean
}
interface VscodeSessionTurnProps {
@@ -71,7 +72,8 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
createEffect(() => {
const turn = props.turn
session.hydrateParts([turn.user.id, ...turn.assistant.map((m) => m.id)])
const ids = turn.partial ? turn.assistant.map((m) => m.id) : [turn.user.id, ...turn.assistant.map((m) => m.id)]
session.hydrateParts(ids)
})
const message = createMemo(() => props.turn.user as SDKMessage & { role: "user" })
@@ -138,33 +140,35 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
{(msg) => (
<div class="vscode-session-turn" data-message={msg().id}>
{/* User message */}
<div
class="vscode-session-turn-user"
data-revert-disabled={
assistantMessages().length > 0 && !session.revert() && session.status() !== "idle" ? "" : undefined
}
title={
assistantMessages().length > 0 && !session.revert() && session.status() !== "idle"
? language.t("revert.disabled.agentBusy")
: undefined
}
>
<UserMessageDisplay
message={msg() as unknown as Parameters<typeof UserMessageDisplay>[0]["message"]}
parts={parts() as unknown as Parameters<typeof UserMessageDisplay>[0]["parts"]}
interrupted={interrupted()}
queued={props.queued}
onFork={props.onForkMessage ? () => props.onForkMessage?.(msg().sessionID, msg().id) : undefined}
onRevert={
assistantMessages().length > 0 && !session.revert()
? () => {
if (session.status() !== "idle") return
session.revertSession(msg().id)
}
<Show when={!props.turn.partial}>
<div
class="vscode-session-turn-user"
data-revert-disabled={
assistantMessages().length > 0 && !session.revert() && session.status() !== "idle" ? "" : undefined
}
title={
assistantMessages().length > 0 && !session.revert() && session.status() !== "idle"
? language.t("revert.disabled.agentBusy")
: undefined
}
/>
</div>
>
<UserMessageDisplay
message={msg() as unknown as Parameters<typeof UserMessageDisplay>[0]["message"]}
parts={parts() as unknown as Parameters<typeof UserMessageDisplay>[0]["parts"]}
interrupted={interrupted()}
queued={props.queued}
onFork={props.onForkMessage ? () => props.onForkMessage?.(msg().sessionID, msg().id) : undefined}
onRevert={
assistantMessages().length > 0 && !session.revert()
? () => {
if (session.status() !== "idle") return
session.revertSession(msg().id)
}
: undefined
}
/>
</div>
</Show>
{/* Assistant parts — flat list, no context grouping */}
<Show when={assistantMessages().length > 0}>
@@ -4,10 +4,47 @@ export interface MessageTurn {
id: string
user: Message
assistant: Message[]
partial?: boolean
}
export function messageTurns(messages: Message[], boundary?: string) {
function key(msg: Message) {
return msg.parentID ?? msg.id
}
function partial(messages: Message[]): MessageTurn {
const first = messages[0]!
const id = first.parentID ?? `${first.id}:partial`
return {
id,
user: {
id,
sessionID: first.sessionID,
role: "user",
createdAt: first.createdAt,
time: first.time,
},
assistant: messages,
partial: true,
}
}
function partials(messages: Message[]): MessageTurn[] {
return messages
.reduce<Message[][]>((groups, msg) => {
const prev = groups[groups.length - 1]
if (!prev || key(prev[0]!) !== key(msg)) {
groups.push([msg])
return groups
}
prev.push(msg)
return groups
}, [])
.map(partial)
}
export function messageTurns(messages: Message[], boundary?: string): MessageTurn[] {
const result: MessageTurn[] = []
const lead: Message[] = []
const by = new Map<string, MessageTurn>()
for (const msg of messages) {
@@ -20,11 +57,43 @@ export function messageTurns(messages: Message[], boundary?: string) {
}
if (msg.role !== "assistant") continue
const turn = (msg.parentID ? by.get(msg.parentID) : undefined) ?? result[result.length - 1]
if (turn) turn.assistant.push(msg)
const turn = msg.parentID ? by.get(msg.parentID) : undefined
if (turn) {
turn.assistant.push(msg)
continue
}
const last = result[result.length - 1]
if (last) {
last.assistant.push(msg)
continue
}
lead.push(msg)
}
return result
if (lead.length === 0) return result
return [...partials(lead), ...result]
}
function sameMessages(a: Message[], b: Message[]) {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
// Keep virtua's item keys stable across prepends; Solid's adapter keys by data object identity.
export function stableMessageTurns(next: MessageTurn[], prev: MessageTurn[] = []): MessageTurn[] {
if (prev.length === 0) return next
const by = new Map(prev.map((turn) => [turn.user.id, turn]))
return next.map((turn) => {
const old = by.get(turn.user.id)
if (!old) return turn
if (old.partial !== turn.partial) return turn
if (!turn.partial && old.user !== turn.user) return turn
if (!sameMessages(old.assistant, turn.assistant)) return turn
return old
})
}
function active(messages: Message[]) {
@@ -1,10 +1,12 @@
import type { FileAttachment, FileSearchItem } from "../types/messages"
import { GIT_CHANGES_MENTION } from "./git-changes-context-utils"
import { TERMINAL_MENTION } from "./terminal-context-utils"
export const AT_PATTERN = /(?:^|\s)@(\S*)$/
export type MentionResult =
| { type: "terminal"; value: typeof TERMINAL_MENTION; label: string; description: string }
| { type: "git-changes"; value: typeof GIT_CHANGES_MENTION; label: string; description: string }
| { type: "file"; value: string }
| { type: "folder"; value: string }
@@ -15,6 +17,13 @@ export const TERMINAL_RESULT: MentionResult = {
description: "Active terminal output",
}
export const GIT_CHANGES_RESULT: MentionResult = {
type: "git-changes",
value: GIT_CHANGES_MENTION,
label: "Git changes",
description: "Current session/worktree changes",
}
/**
* Escape special regex characters in a string so it can be used in a RegExp.
*/
@@ -28,13 +37,19 @@ export function getTerminalMentionResult(query: string): MentionResult[] {
return [TERMINAL_RESULT]
}
export function buildMentionResults(query: string, items: Array<FileSearchItem | string>): MentionResult[] {
export function getGitChangesMentionResult(query: string): MentionResult[] {
const normalized = query.toLowerCase()
if (normalized && !GIT_CHANGES_MENTION.startsWith(normalized) && !"git".startsWith(normalized)) return []
return [GIT_CHANGES_RESULT]
}
export function buildMentionResults(query: string, items: Array<FileSearchItem | string>, git = true): MentionResult[] {
const results: MentionResult[] = items.map((item) => {
if (typeof item === "string") return { type: "file", value: item }
if (item.type === "folder") return { type: "folder", value: item.path }
return { type: "file", value: item.path }
})
return [...getTerminalMentionResult(query), ...results]
return [...getTerminalMentionResult(query), ...(git ? getGitChangesMentionResult(query) : []), ...results]
}
/**
@@ -0,0 +1,42 @@
import type { FileAttachment } from "../types/messages"
export const GIT_CHANGES_MENTION = "git-changes"
export const GIT_CHANGES_FILENAME = "git-changes.txt"
export const GIT_CHANGES_PATTERN = /(^|\s)@git-changes(?=\s|$)/g
export type GitChangesMention = {
value: string
start: number
end: number
}
export function findGitChangesMention(text: string): GitChangesMention | undefined {
GIT_CHANGES_PATTERN.lastIndex = 0
const match = GIT_CHANGES_PATTERN.exec(text)
if (!match) return undefined
const prefix = match[1] ?? ""
const start = match.index + prefix.length
const value = `@${GIT_CHANGES_MENTION}`
return { value, start, end: start + value.length }
}
export function hasGitChangesMention(text: string): boolean {
return findGitChangesMention(text) !== undefined
}
export function buildGitChangesAttachment(text: string, content: string): FileAttachment | undefined {
const mention = findGitChangesMention(text)
if (!mention) return undefined
return {
mime: "text/plain",
url: `data:text/plain;charset=utf-8,${encodeURIComponent(content)}`,
filename: GIT_CHANGES_FILENAME,
source: {
type: "file",
path: GIT_CHANGES_FILENAME,
text: mention,
},
}
}
@@ -42,7 +42,11 @@ export interface FileMention {
addPaths: (paths: string[], cwd: string) => void
}
export function useFileMention(vscode: VSCodeContext, sessionID?: Accessor<string | undefined>): FileMention {
export function useFileMention(
vscode: VSCodeContext,
sessionID?: Accessor<string | undefined>,
git?: Accessor<boolean>,
): FileMention {
const [mentionedPaths, setMentionedPaths] = createSignal<Set<string>>(new Set())
const [mentionQuery, setMentionQuery] = createSignal<string | null>(null)
const [mentionResults, setMentionResults] = createSignal<MentionResult[]>([])
@@ -63,7 +67,7 @@ export function useFileMention(vscode: VSCodeContext, sessionID?: Accessor<strin
if (message.requestId === `file-search-${fileSearchCounter}`) {
const items = message.items ?? message.paths.map((path) => ({ path, type: "file" as const }))
workspaceDir = message.dir
setMentionResults(buildMentionResults(mentionQuery() ?? "", items))
setMentionResults(buildMentionResults(mentionQuery() ?? "", items, git?.() ?? true))
setMentionIndex(0)
}
})
@@ -129,7 +133,7 @@ export function useFileMention(vscode: VSCodeContext, sessionID?: Accessor<strin
if (match) {
const query = match[1] ?? ""
setMentionQuery(query)
setMentionResults(buildMentionResults(query, []))
setMentionResults(buildMentionResults(query, [], git?.() ?? true))
requestFileSearch(query)
} else {
closeMention()
@@ -0,0 +1,86 @@
import { createSignal, onCleanup } from "solid-js"
import type { Accessor } from "solid-js"
import type { ExtensionMessage, FileAttachment, WebviewMessage } from "../types/messages"
import { buildGitChangesAttachment, hasGitChangesMention } from "./git-changes-context-utils"
const GIT_CHANGES_TIMEOUT_MS = 15_000
type Pending = {
resolve: (content: string) => void
reject: (err: Error) => void
timer: ReturnType<typeof setTimeout>
}
interface VSCodeContext {
postMessage: (message: WebviewMessage) => void
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
}
export interface GitChangesContext {
pending: Accessor<boolean>
resolveAttachment: (text: string, sessionID?: string) => Promise<FileAttachment | undefined>
}
export function useGitChangesContext(
vscode: VSCodeContext,
context?: Accessor<string | undefined>,
git?: Accessor<boolean>,
): GitChangesContext {
const [pending, setPending] = createSignal(false)
const requests = new Map<string, Pending>()
let counter = 0
const settle = (requestId: string, run: (req: Pending) => void) => {
const req = requests.get(requestId)
if (!req) return
clearTimeout(req.timer)
requests.delete(requestId)
setPending(requests.size > 0)
run(req)
}
const unsubscribe = vscode.onMessage((message) => {
if (message.type === "gitChangesContextResult") {
settle(message.requestId, (req) => req.resolve(message.content))
return
}
if (message.type === "gitChangesContextError") {
settle(message.requestId, (req) => req.reject(new Error(message.error)))
}
})
onCleanup(() => {
unsubscribe()
for (const req of requests.values()) {
clearTimeout(req.timer)
req.reject(new Error("Git changes context request cancelled"))
}
requests.clear()
setPending(false)
})
const request = (sessionID?: string) =>
new Promise<string>((resolve, reject) => {
counter++
const requestId = `git-changes-context-${counter}`
const timer = setTimeout(() => {
settle(requestId, (req) => req.reject(new Error("Timed out while reading git changes")))
}, GIT_CHANGES_TIMEOUT_MS)
requests.set(requestId, { resolve, reject, timer })
setPending(true)
vscode.postMessage({ type: "requestGitChangesContext", requestId, sessionID, agentManagerContext: context?.() })
})
const resolveAttachment = async (text: string, sessionID?: string) => {
if (!hasGitChangesMention(text)) return undefined
if (git?.() === false) return undefined
const content = await request(sessionID)
return buildGitChangesAttachment(text, content)
}
return { pending, resolveAttachment }
}
@@ -679,6 +679,7 @@ const mockServer = {
vscodeLanguage: () => "en",
languageOverride: () => undefined,
workspaceDirectory: () => "/project",
gitInstalled: () => true,
}
export const WelcomeWithSwitcherAndNotification: Story = {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
export type WorktreeErrorCode = "git_not_found" | "not_git_repo" | "lfs_missing"
// Agent Manager worktree state types (mirrored from WorktreeStateManager)
export interface WorktreeState {
id: string
branch: string
path: string
/** Bare branch name (e.g. "main"), without remote prefix. */
parentBranch: string
/** Remote name (e.g. "origin"). */
remote?: string
createdAt: string
/** Shared identifier for worktrees created together via multi-version mode. */
groupId?: string
/** User-provided display name for the worktree. */
label?: string
/** Cached PR number for instant badge display on reload. */
prNumber?: number
/** Cached PR URL for instant badge display on reload. */
prUrl?: string
/** Cached PR state for correct badge color on reload (open/merged/closed/draft). */
prState?: string
/** Section this worktree belongs to, or undefined for ungrouped. */
sectionId?: string
}
export interface SectionState {
id: string
name: string
/** Color label (e.g. "Red", "Blue") or null for default. */
color: string | null
order: number
collapsed: boolean
}
// ---------------------------------------------------------------------------
// PR status types (mirrored from extension types.ts)
// ---------------------------------------------------------------------------
export type PRState = "open" | "draft" | "merged" | "closed"
export type ReviewDecision = "approved" | "changes_requested" | "pending"
export type CheckStatus = "success" | "failure" | "pending" | "skipped" | "cancelled"
export type AggregateCheckStatus = "success" | "failure" | "pending" | "none"
export interface PRCheck {
name: string
status: CheckStatus
url?: string
duration?: string
}
export interface PRComment {
id: string
author: string
avatar?: string
body: string
file?: string
line?: number
url?: string
resolved: boolean
createdAt?: number
}
export interface PRStatus {
number: number
title: string
url: string
state: PRState
review: ReviewDecision | null
checks: {
status: AggregateCheckStatus
total: number
passed: number
failed: number
pending: number
items: PRCheck[]
}
comments?: {
total: number
unresolved: number
items: PRComment[]
}
additions: number
deletions: number
files: number
}
export type RunState = "idle" | "running" | "stopping"
export interface RunStatus {
worktreeId: string
state: RunState
exitCode?: number
signal?: string
startedAt?: string
finishedAt?: string
error?: string
}
export interface ManagedSessionState {
id: string
worktreeId: string | null
createdAt: string
}
export interface BranchInfo {
name: string
isLocal: boolean
isRemote: boolean
isDefault: boolean
lastCommitDate?: string
isCheckedOut?: boolean
}
// Agent Manager Import tab: external worktrees (extension → webview)
export interface ExternalWorktreeInfo {
path: string
branch: string
}
// Shared FileDiff shape (matches Snapshot.FileDiff from CLI backend)
export interface WorktreeFileDiff {
file: string
before: string
after: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
tracked?: boolean
generatedLike?: boolean
summarized?: boolean
stamp?: string
}
export type AgentManagerApplyWorktreeDiffStatus = "checking" | "applying" | "success" | "conflict" | "error"
export interface AgentManagerApplyWorktreeDiffConflict {
file?: string
reason: string
}
// Per-worktree git stats: diff additions/deletions and ahead/behind counts
export interface WorktreeGitStats {
worktreeId: string
files: number
additions: number
deletions: number
ahead: number
behind: number
}
// Per-local-workspace git stats: branch name, diff additions/deletions, ahead/behind counts
export interface LocalGitStats {
branch: string
files: number
additions: number
deletions: number
ahead: number
behind: number
}
export interface ReviewComment {
id: string
file: string
side: "additions" | "deletions"
line: number
comment: string
selectedText: string
}
/**
* Maximum number of parallel worktree versions for multi-version mode.
* Keep in sync with MAX_MULTI_VERSIONS in src/agent-manager/constants.ts.
*/
export const MAX_MULTI_VERSIONS = 4
// Per-version model allocation for multi-model comparison mode
export interface ModelAllocation {
providerID: string
modelID: string
count: number
}
export type ContinueInWorktreeStatus =
| "capturing"
| "creating"
| "setup"
| "transferring"
| "forking"
| "done"
| "error"
@@ -0,0 +1,42 @@
import type { PermissionConfig, PermissionRuleItem } from "./permissions"
// Skill info from CLI backend
export interface SkillInfo {
name: string
description: string
location: string
}
// Slash command info from CLI backend
export interface SlashCommandInfo {
name: string
description?: string
source?: "command" | "mcp" | "skill"
hints: string[]
}
// Agent/mode info from CLI backend
export interface AgentInfo {
name: string
displayName?: string
description?: string
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
deprecated?: boolean
color?: string
permission?: PermissionRuleItem[]
}
export interface AgentConfig {
model?: string | null
prompt?: string
description?: string
mode?: "subagent" | "primary" | "all"
hidden?: boolean
disable?: boolean
temperature?: number
top_p?: number
steps?: number
permission?: PermissionConfig
}
@@ -0,0 +1,81 @@
import type { PermissionConfig } from "./permissions"
import type { AgentConfig } from "./agents"
import type { ProviderConfig } from "./providers"
export interface McpConfig {
type?: "local" | "remote"
command?: string[] | string
args?: string[]
env?: Record<string, string>
environment?: Record<string, string>
url?: string
headers?: Record<string, string>
enabled?: boolean
}
export interface CommandConfig {
template: string
description?: string
agent?: string
model?: string
}
export interface SkillsConfig {
paths?: string[]
urls?: string[]
}
export interface CompactionConfig {
auto?: boolean
prune?: boolean
}
export interface WatcherConfig {
ignore?: string[]
}
export interface ExperimentalConfig {
disable_paste_summary?: boolean
batch_tool?: boolean
codebase_search?: boolean
primary_tools?: string[]
continue_loop_on_deny?: boolean
mcp_timeout?: number
}
export interface CommitMessageConfig {
prompt?: string
}
export interface BrowserSettings {
enabled: boolean
useSystemChrome: boolean
headless: boolean
}
export interface Config {
permission?: PermissionConfig
model?: string | null
small_model?: string | null
default_agent?: string
agent?: Record<string, AgentConfig>
provider?: Record<string, ProviderConfig>
disabled_providers?: string[]
enabled_providers?: string[]
mcp?: Record<string, McpConfig>
command?: Record<string, CommandConfig>
instructions?: string[]
skills?: SkillsConfig
snapshot?: boolean
remote_control?: boolean
share?: "manual" | "auto" | "disabled"
username?: string
watcher?: WatcherConfig
formatter?: false | Record<string, unknown>
lsp?: false | Record<string, unknown>
compaction?: CompactionConfig
commit_message?: CommitMessageConfig
tools?: Record<string, boolean>
layout?: "auto" | "stretch"
experimental?: ExperimentalConfig
}
@@ -0,0 +1,30 @@
// Connection states
export type ConnectionState = "connecting" | "connected" | "disconnected" | "error"
// Session status (simplified from backend)
export type SessionStatus = "idle" | "busy" | "retry" | "offline"
// Rich status info for retry countdown and future extensions
export type SessionStatusInfo =
| { type: "idle" }
| { type: "busy" }
| { type: "retry"; attempt: number; message: string; next: number }
| { type: "offline"; message: string }
// Server info
export interface ServerInfo {
port: number
version?: string
}
// Device auth flow status
export type DeviceAuthStatus = "idle" | "initiating" | "pending" | "success" | "error" | "cancelled"
// Device auth state
export interface DeviceAuthState {
status: DeviceAuthStatus
code?: string
verificationUrl?: string
expiresIn?: number
error?: string
}
@@ -0,0 +1,921 @@
import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@kilocode/sdk/v2/client"
import type { PartBatch, PartUpdate } from "../../../../src/shared/stream-messages"
import type { SessionMode } from "../../context/worktree-mode"
import type { MarketplaceItem, MarketplaceInstalledMetadata } from "../marketplace"
import type { ConnectionState, ServerInfo, SessionStatus } from "./connection"
import type { FileAttachment, Part } from "./parts"
import type { CloudSessionInfo, Message, MessageLoadMode, SessionInfo } from "./sessions"
import type { PermissionRequest } from "./permissions"
import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions"
import type { ModelSelection, Provider, ProviderAuthState } from "./providers"
import type { AgentInfo, SkillInfo, SlashCommandInfo } from "./agents"
import type { BrowserSettings, Config } from "./config"
import type { KilocodeNotification, ProfileData } from "./profile"
import type {
AgentManagerApplyWorktreeDiffConflict,
AgentManagerApplyWorktreeDiffStatus,
BranchInfo,
ContinueInWorktreeStatus,
ExternalWorktreeInfo,
LocalGitStats,
ManagedSessionState,
PRStatus,
ReviewComment,
RunStatus,
SectionState,
WorktreeErrorCode,
WorktreeFileDiff,
WorktreeGitStats,
WorktreeState,
} from "./agent-manager"
import type {
LegacyMigrationCompleteMessage,
LegacyMigrationDataMessage,
LegacyMigrationProgressMessage,
LegacyMigrationSessionProgressMessage,
MigrationStateMessage,
} from "./migration"
// ============================================
// Messages FROM extension TO webview
// ============================================
export interface ReadyMessage {
type: "ready"
serverInfo?: ServerInfo
extensionVersion?: string
vscodeLanguage?: string
languageOverride?: string
workspaceDirectory?: string
}
export interface GitStatusMessage {
type: "gitStatus"
repo: boolean
}
export interface WorkspaceDirectoryChangedMessage {
type: "workspaceDirectoryChanged"
directory: string
}
export interface LanguageChangedMessage {
type: "languageChanged"
locale: string
}
export interface ConnectionStateMessage {
type: "connectionState"
state: ConnectionState
error?: string
userMessage?: string
userDetails?: string
}
export interface ErrorMessage {
type: "error"
message: string
code?: string
sessionID?: string
}
export interface SendMessageFailedMessage {
type: "sendMessageFailed"
error: string
text: string
sessionID?: string
draftID?: string
messageID?: string
files?: FileAttachment[]
}
// Wire shape lives in src/shared/stream-messages.ts; narrow `part` to the
// webview's concrete union.
export type PartUpdatedMessage = PartUpdate<Part>
export type PartsUpdatedMessage = PartBatch<Part>
export interface SessionStatusMessage {
type: "sessionStatus"
sessionID: string
status: SessionStatus
// Retry fields (present when status === "retry")
attempt?: number
message?: string
next?: number
}
export interface SessionErrorMessage {
type: "sessionError"
sessionID?: string
error?: { name: string; data?: Record<string, unknown> }
}
export interface PermissionRequestMessage {
type: "permissionRequest"
permission: PermissionRequest
}
export interface PermissionResolvedMessage {
type: "permissionResolved"
permissionID: string
}
export interface PermissionErrorMessage {
type: "permissionError"
permissionID: string
}
export interface TodoUpdatedMessage {
type: "todoUpdated"
sessionID: string
items: TodoItem[]
}
export interface SessionCreatedMessage {
type: "sessionCreated"
session: SessionInfo
draftID?: string
}
export interface SessionForkedMessage {
type: "sessionForked"
sessionID: string
}
export interface SessionUpdatedMessage {
type: "sessionUpdated"
session: SessionInfo
}
export interface SessionDeletedMessage {
type: "sessionDeleted"
sessionID: string
}
export interface MessageRemovedMessage {
type: "messageRemoved"
sessionID: string
messageID: string
}
export interface MessagesLoadedMessage {
type: "messagesLoaded"
sessionID: string
messages: Message[]
mode?: Exclude<MessageLoadMode, "focus">
cursor?: string
hasMore?: boolean
}
export interface MessageCreatedMessage {
type: "messageCreated"
message: Message
}
export interface SessionsLoadedMessage {
type: "sessionsLoaded"
sessions: SessionInfo[]
preserveSessionIds?: string[]
}
export interface CloudSessionsLoadedMessage {
type: "cloudSessionsLoaded"
sessions: CloudSessionInfo[]
nextCursor: string | null
}
export interface GitRemoteUrlLoadedMessage {
type: "gitRemoteUrlLoaded"
gitUrl: string | null
}
export interface CloudSessionDataLoadedMessage {
type: "cloudSessionDataLoaded"
cloudSessionId: string
title: string
messages: Message[]
}
export interface CloudSessionImportedMessage {
type: "cloudSessionImported"
cloudSessionId: string
session: SessionInfo
}
export interface CloudSessionImportFailedMessage {
type: "cloudSessionImportFailed"
cloudSessionId: string
error: string
}
export interface OpenCloudSessionMessage {
type: "openCloudSession"
sessionId: string
}
export interface ActionMessage {
type: "action"
action: string
}
export interface SetChatBoxMessage {
type: "setChatBoxMessage"
text: string
}
export interface AppendChatBoxMessage {
type: "appendChatBoxMessage"
text: string
}
export interface AppendReviewCommentsMessage {
type: "appendReviewComments"
comments: ReviewComment[]
autoSend?: boolean
}
export interface TriggerTaskMessage {
type: "triggerTask"
text: string
}
export interface ProfileDataMessage {
type: "profileData"
data: ProfileData | null
}
export interface DeviceAuthStartedMessage {
type: "deviceAuthStarted"
code?: string
verificationUrl: string
expiresIn: number
}
export interface DeviceAuthCompleteMessage {
type: "deviceAuthComplete"
}
export interface DeviceAuthFailedMessage {
type: "deviceAuthFailed"
error: string
}
export interface DeviceAuthCancelledMessage {
type: "deviceAuthCancelled"
}
export interface NavigateMessage {
type: "navigate"
view: "newTask" | "marketplace" | "history" | "profile" | "settings" | "subAgentViewer"
tab?: string
}
export interface ProvidersLoadedMessage {
type: "providersLoaded"
providers: Record<string, Provider>
connected: string[]
defaults: Record<string, string>
defaultSelection: ModelSelection
authMethods: Record<string, ProviderAuthMethod[]>
authStates: Record<string, ProviderAuthState>
}
export interface AgentsLoadedMessage {
type: "agentsLoaded"
agents: AgentInfo[]
allAgents: AgentInfo[]
defaultAgent: string
}
export interface SkillsLoadedMessage {
type: "skillsLoaded"
skills: SkillInfo[]
}
export interface CommandsLoadedMessage {
type: "commandsLoaded"
commands: SlashCommandInfo[]
}
export interface AutocompleteSettingsLoadedMessage {
type: "autocompleteSettingsLoaded"
settings: {
enableAutoTrigger: boolean
enableSmartInlineTaskKeybinding: boolean
enableChatAutocomplete: boolean
}
}
export interface ChatCompletionResultMessage {
type: "chatCompletionResult"
text: string
requestId: string
}
export interface FileSearchItem {
path: string
type: "file" | "folder"
}
export interface FileSearchResultMessage {
type: "fileSearchResult"
paths: string[]
items?: FileSearchItem[]
dir: string
requestId: string
}
export interface TerminalContextResultMessage {
type: "terminalContextResult"
requestId: string
content: string
truncated?: boolean
}
export interface TerminalContextErrorMessage {
type: "terminalContextError"
requestId: string
error: string
}
export interface GitChangesContextResultMessage {
type: "gitChangesContextResult"
requestId: string
content: string
truncated?: boolean
}
export interface GitChangesContextErrorMessage {
type: "gitChangesContextError"
requestId: string
error: string
}
export interface QuestionRequestMessage {
type: "questionRequest"
question: QuestionRequest
}
export interface QuestionResolvedMessage {
type: "questionResolved"
requestID: string
}
export interface QuestionErrorMessage {
type: "questionError"
requestID: string
}
export interface SuggestionRequestMessage {
type: "suggestionRequest"
suggestion: SuggestionRequest
}
export interface SuggestionResolvedMessage {
type: "suggestionResolved"
requestID: string
}
export interface SuggestionErrorMessage {
type: "suggestionError"
requestID: string
}
export interface BrowserSettingsLoadedMessage {
type: "browserSettingsLoaded"
settings: BrowserSettings
}
export interface ClaudeCompatSettingLoadedMessage {
type: "claudeCompatSettingLoaded"
enabled: boolean
}
export interface ConfigLoadedMessage {
type: "configLoaded"
config: Config
}
export interface ConfigUpdatedMessage {
type: "configUpdated"
config: Config
}
export interface ConfigUpdateFailedMessage {
type: "configUpdateFailed"
message: string
details?: string
}
export interface GlobalConfigLoadedMessage {
type: "globalConfigLoaded"
config: Config
}
export interface NotificationSettingsLoadedMessage {
type: "notificationSettingsLoaded"
settings: {
notifyAgent: boolean
notifyPermissions: boolean
notifyErrors: boolean
soundAgent: string
soundPermissions: string
soundErrors: string
}
}
export interface TimelineSettingLoadedMessage {
type: "timelineSettingLoaded"
visible: boolean
}
export interface NotificationsLoadedMessage {
type: "notificationsLoaded"
notifications: KilocodeNotification[]
dismissedIds: string[]
}
// Agent Manager worktree session metadata
export interface AgentManagerSessionMetaMessage {
type: "agentManager.sessionMeta"
sessionId: string
mode: SessionMode
branch?: string
path?: string
parentBranch?: string
}
// Agent Manager repo info (current branch of the main workspace)
export interface AgentManagerRepoInfoMessage {
type: "agentManager.repoInfo"
branch: string
defaultBranch?: string
}
// Agent Manager worktree setup progress
export interface AgentManagerWorktreeSetupMessage {
type: "agentManager.worktreeSetup"
status: "creating" | "starting" | "ready" | "error"
message: string
sessionId?: string
branch?: string
worktreeId?: string
errorCode?: WorktreeErrorCode
}
// Agent Manager session added to an existing worktree (no setup overlay needed)
export interface AgentManagerSessionAddedMessage {
type: "agentManager.sessionAdded"
sessionId: string
worktreeId: string
}
// Agent Manager session forked from an existing session
export interface AgentManagerSessionForkedMessage {
type: "agentManager.sessionForked"
sessionId: string
forkedFromId: string
worktreeId?: string
}
// Full state push from extension to webview
export interface AgentManagerStateMessage {
type: "agentManager.state"
worktrees: WorktreeState[]
sessions: ManagedSessionState[]
sections?: SectionState[]
staleWorktreeIds?: string[]
tabOrder?: Record<string, string[]>
worktreeOrder?: string[]
sessionsCollapsed?: boolean
reviewDiffStyle?: "unified" | "split"
isGitRepo?: boolean
defaultBaseBranch?: string
runStatuses?: RunStatus[]
runScriptConfigured?: boolean
runScriptPath?: string
}
// ---------------------------------------------------------------------------
// Agent Manager terminal messages
// ---------------------------------------------------------------------------
export interface AgentManagerTerminalCreatedMessage {
type: "agentManager.terminal.created"
/** null for LOCAL, worktree id otherwise */
worktreeId: string | null
terminalId: string
title: string
wsUrl: string
}
export interface AgentManagerTerminalClosedMessage {
type: "agentManager.terminal.closed"
terminalId: string
}
export interface AgentManagerTerminalErrorMessage {
type: "agentManager.terminal.error"
terminalId?: string
message: string
}
export interface AgentManagerRunStatusMessage extends RunStatus {
type: "agentManager.runStatus"
}
// Resolved keybindings for agent manager actions
export interface AgentManagerKeybindingsMessage {
type: "agentManager.keybindings"
bindings: Record<string, string>
}
// Multi-version creation progress (extension → webview)
export interface AgentManagerMultiVersionProgressMessage {
type: "agentManager.multiVersionProgress"
status: "creating" | "done"
total: number
completed: number
groupId?: string
}
// Stored variant selections loaded from extension globalState (extension → webview)
export interface VariantsLoadedMessage {
type: "variantsLoaded"
variants: Record<string, string>
}
export interface RecentsLoadedMessage {
type: "recentsLoaded"
recents: ModelSelection[]
}
export interface FavoritesLoadedMessage {
type: "favoritesLoaded"
favorites: ModelSelection[]
}
// Per-mode model selections loaded from model.json (extension → webview)
export interface ModelSelectionsLoadedMessage {
type: "modelSelectionsLoaded"
selections: Record<string, ModelSelection>
}
export interface AgentManagerBranchesMessage {
type: "agentManager.branches"
branches: BranchInfo[]
defaultBranch: string
}
export interface AgentManagerExternalWorktreesMessage {
type: "agentManager.externalWorktrees"
worktrees: ExternalWorktreeInfo[]
}
// Agent Manager Import tab: result feedback (extension → webview)
export interface AgentManagerImportResultMessage {
type: "agentManager.importResult"
success: boolean
message: string
errorCode?: WorktreeErrorCode
}
// Agent Manager: Diff data push (extension → webview)
export interface AgentManagerWorktreeDiffMessage {
type: "agentManager.worktreeDiff"
sessionId: string
diffs: WorktreeFileDiff[]
}
export interface AgentManagerWorktreeDiffFileMessage {
type: "agentManager.worktreeDiffFile"
sessionId: string
file: string
diff: WorktreeFileDiff | null
}
// Agent Manager: Diff loading state (extension → webview)
export interface AgentManagerWorktreeDiffLoadingMessage {
type: "agentManager.worktreeDiffLoading"
sessionId: string
loading: boolean
}
export interface AgentManagerApplyWorktreeDiffResultMessage {
type: "agentManager.applyWorktreeDiffResult"
worktreeId: string
status: AgentManagerApplyWorktreeDiffStatus
message: string
conflicts?: AgentManagerApplyWorktreeDiffConflict[]
}
// Agent Manager: Revert single file result (extension → webview)
export interface AgentManagerRevertWorktreeFileResultMessage {
type: "agentManager.revertWorktreeFileResult"
sessionId: string
file: string
status: "success" | "error"
message: string
}
// Agent Manager: Worktree git stats push (extension → webview)
export interface AgentManagerWorktreeStatsMessage {
type: "agentManager.worktreeStats"
stats: WorktreeGitStats[]
}
// Agent Manager: Local workspace git stats push (extension → webview)
export interface AgentManagerLocalStatsMessage {
type: "agentManager.localStats"
stats: LocalGitStats
}
// Agent Manager: PR status push (extension → webview)
export interface AgentManagerPRStatusMessage {
type: "agentManager.prStatus"
worktreeId: string
pr: PRStatus | null
error?: "gh_missing" | "gh_auth" | "fetch_failed"
}
// Sidebar: Live worktree diff stats (extension → webview)
export interface WorktreeStatsLoadedMessage {
type: "worktreeStatsLoaded"
files: number
additions: number
deletions: number
}
// Set the model for a session (extension → webview, used during multi-version creation)
export interface AgentManagerSetSessionModelMessage {
type: "agentManager.setSessionModel"
sessionId: string
providerID: string
modelID: string
}
// Request webview to send initial prompt to a newly created session (extension → webview)
export interface AgentManagerSendInitialMessage {
type: "agentManager.sendInitialMessage"
sessionId: string
worktreeId: string
text?: string
providerID?: string
modelID?: string
agent?: string
files?: Array<{ mime: string; url: string }>
}
// Enhance prompt result (extension → webview)
export interface EnhancePromptResultMessage {
type: "enhancePromptResult"
text: string
requestId: string
}
// Enhance prompt error (extension → webview)
export interface EnhancePromptErrorMessage {
type: "enhancePromptError"
error: string
requestId: string
}
// Sub-agent viewer: open a child session in read-only mode (extension → webview)
export interface ViewSubAgentSessionMessage {
type: "viewSubAgentSession"
sessionID: string
}
export interface DiffViewerDiffsMessage {
type: "diffViewer.diffs"
diffs: WorktreeFileDiff[]
}
export interface DiffViewerLoadingMessage {
type: "diffViewer.loading"
loading: boolean
}
export interface DiffViewerRevertFileResultMessage {
type: "diffViewer.revertFileResult"
file: string
status: "success" | "error"
message: string
}
export interface ClearPendingPromptsMessage {
type: "clearPendingPrompts"
}
export interface ExtensionDataReadyMessage {
type: "extensionDataReady"
}
// ============================================
// Marketplace Messages
// ============================================
export interface MarketplaceDataMessage {
type: "marketplaceData"
marketplaceItems: MarketplaceItem[]
marketplaceInstalledMetadata: MarketplaceInstalledMetadata
errors?: string[]
}
export interface MarketplaceInstallResultMessage {
type: "marketplaceInstallResult"
success: boolean
slug: string
error?: string
}
export interface MarketplaceRemoveResultMessage {
type: "marketplaceRemoveResult"
success: boolean
slug: string
error?: string
}
export interface ProviderOAuthReadyMessage {
type: "providerOAuthReady"
requestId: string
providerID: string
authorization: ProviderAuthAuthorization
}
export interface ProviderConnectedMessage {
type: "providerConnected"
requestId: string
providerID: string
}
export interface ProviderDisconnectedMessage {
type: "providerDisconnected"
requestId: string
providerID: string
}
export interface ProviderActionErrorMessage {
type: "providerActionError"
requestId: string
providerID: string
action: "authorize" | "connect" | "disconnect"
message: string
}
export interface CustomProviderModelsFetchedMessage {
type: "customProviderModelsFetched"
requestId: string
models?: Array<{ id: string; name: string }>
error?: string
/** True when error was HTTP 401/403 — hints the user to check their API key */
auth?: boolean
}
export interface McpStatusEntry {
status: "connected" | "disabled" | "failed" | "needs_auth" | "needs_client_registration"
error?: string
}
export interface McpStatusLoadedMessage {
type: "mcpStatusLoaded"
status: Record<string, McpStatusEntry>
}
// Continue in Worktree: progress updates (extension → webview)
export interface ContinueInWorktreeProgressMessage {
type: "continueInWorktreeProgress"
status: ContinueInWorktreeStatus
detail?: string
error?: string
}
export interface RemoteStatusMessage {
type: "remoteStatus"
enabled: boolean
connected: boolean
}
export type ExtensionMessage =
| ReadyMessage
| GitStatusMessage
| ConnectionStateMessage
| ErrorMessage
| SendMessageFailedMessage
| PartUpdatedMessage
| PartsUpdatedMessage
| SessionStatusMessage
| SessionErrorMessage
| PermissionRequestMessage
| PermissionResolvedMessage
| PermissionErrorMessage
| TodoUpdatedMessage
| SessionCreatedMessage
| SessionForkedMessage
| SessionUpdatedMessage
| SessionDeletedMessage
| MessageRemovedMessage
| MessagesLoadedMessage
| MessageCreatedMessage
| SessionsLoadedMessage
| CloudSessionsLoadedMessage
| GitRemoteUrlLoadedMessage
| ActionMessage
| ProfileDataMessage
| DeviceAuthStartedMessage
| DeviceAuthCompleteMessage
| DeviceAuthFailedMessage
| DeviceAuthCancelledMessage
| NavigateMessage
| ProvidersLoadedMessage
| AgentsLoadedMessage
| SkillsLoadedMessage
| CommandsLoadedMessage
| AutocompleteSettingsLoadedMessage
| ChatCompletionResultMessage
| FileSearchResultMessage
| TerminalContextResultMessage
| TerminalContextErrorMessage
| GitChangesContextResultMessage
| GitChangesContextErrorMessage
| QuestionRequestMessage
| QuestionResolvedMessage
| QuestionErrorMessage
| SuggestionRequestMessage
| SuggestionResolvedMessage
| SuggestionErrorMessage
| BrowserSettingsLoadedMessage
| ClaudeCompatSettingLoadedMessage
| ConfigLoadedMessage
| ConfigUpdatedMessage
| ConfigUpdateFailedMessage
| GlobalConfigLoadedMessage
| NotificationSettingsLoadedMessage
| TimelineSettingLoadedMessage
| NotificationsLoadedMessage
| AgentManagerSessionMetaMessage
| AgentManagerRepoInfoMessage
| AgentManagerWorktreeSetupMessage
| AgentManagerSessionAddedMessage
| AgentManagerSessionForkedMessage
| AgentManagerStateMessage
| AgentManagerRunStatusMessage
| AgentManagerKeybindingsMessage
| AgentManagerMultiVersionProgressMessage
| AgentManagerSetSessionModelMessage
| AgentManagerSendInitialMessage
| SetChatBoxMessage
| AppendChatBoxMessage
| AppendReviewCommentsMessage
| TriggerTaskMessage
| VariantsLoadedMessage
| CloudSessionDataLoadedMessage
| CloudSessionImportedMessage
| CloudSessionImportFailedMessage
| OpenCloudSessionMessage
| AgentManagerBranchesMessage
| AgentManagerExternalWorktreesMessage
| AgentManagerImportResultMessage
| WorkspaceDirectoryChangedMessage
| AgentManagerWorktreeDiffMessage
| AgentManagerWorktreeDiffFileMessage
| AgentManagerWorktreeDiffLoadingMessage
| AgentManagerApplyWorktreeDiffResultMessage
| AgentManagerRevertWorktreeFileResultMessage
| AgentManagerWorktreeStatsMessage
| AgentManagerLocalStatsMessage
| AgentManagerPRStatusMessage
| AgentManagerTerminalCreatedMessage
| AgentManagerTerminalClosedMessage
| AgentManagerTerminalErrorMessage
// legacy-migration start
| MigrationStateMessage
| LegacyMigrationDataMessage
| LegacyMigrationProgressMessage
| LegacyMigrationSessionProgressMessage
| LegacyMigrationCompleteMessage
// legacy-migration end
| EnhancePromptResultMessage
| EnhancePromptErrorMessage
| ViewSubAgentSessionMessage
| DiffViewerDiffsMessage
| DiffViewerLoadingMessage
| DiffViewerRevertFileResultMessage
| MarketplaceDataMessage
| MarketplaceInstallResultMessage
| MarketplaceRemoveResultMessage
| ProviderOAuthReadyMessage
| ProviderConnectedMessage
| ProviderDisconnectedMessage
| ProviderActionErrorMessage
| CustomProviderModelsFetchedMessage
| RecentsLoadedMessage
| FavoritesLoadedMessage
| ModelSelectionsLoadedMessage
| LanguageChangedMessage
| ContinueInWorktreeProgressMessage
| WorktreeStatsLoadedMessage
| McpStatusLoadedMessage
| ClearPendingPromptsMessage
| ExtensionDataReadyMessage
| RemoteStatusMessage
@@ -0,0 +1,17 @@
/**
* Types for extension <-> webview message communication
*/
export * from "./connection"
export * from "./parts"
export * from "./sessions"
export * from "./permissions"
export * from "./questions"
export * from "./providers"
export * from "./agents"
export * from "./config"
export * from "./profile"
export * from "./agent-manager"
export * from "./migration"
export * from "./extension-messages"
export * from "./webview-messages"
@@ -0,0 +1,150 @@
// legacy-migration start
export interface MigrationProviderInfo {
profileName: string
provider: string
model?: string
hasApiKey: boolean
supported: boolean
newProviderName?: string
}
export interface MigrationMcpServerInfo {
name: string
type: string
}
export interface MigrationCustomModeInfo {
name: string
slug: string
}
export interface LegacyAutocompleteSettings {
enableAutoTrigger?: boolean
enableSmartInlineTaskKeybinding?: boolean
enableChatAutocomplete?: boolean
}
export interface LegacySettings {
autoApprovalEnabled?: boolean
allowedCommands?: string[]
deniedCommands?: string[]
// Fine-grained auto-approval (legacy globalState keys — no prefix)
alwaysAllowReadOnly?: boolean
alwaysAllowReadOnlyOutsideWorkspace?: boolean
alwaysAllowWrite?: boolean
alwaysAllowExecute?: boolean
alwaysAllowMcp?: boolean
alwaysAllowModeSwitch?: boolean
alwaysAllowSubtasks?: boolean
language?: string
autocomplete?: LegacyAutocompleteSettings
}
export interface MigrationSessionInfo {
id: string
title: string
directory: string
time: number
}
export interface MigrationResultItem {
item: string
category: "provider" | "mcpServer" | "customMode" | "session" | "defaultModel" | "settings"
status: "success" | "warning" | "error"
message?: string
}
export interface MigrationStateMessage {
type: "migrationState"
needed: boolean
data?: {
providers: MigrationProviderInfo[]
mcpServers: MigrationMcpServerInfo[]
customModes: MigrationCustomModeInfo[]
sessions?: MigrationSessionInfo[]
defaultModel?: { provider: string; model: string }
settings?: LegacySettings
}
}
export interface LegacyMigrationDataMessage {
type: "legacyMigrationData"
data: {
providers: MigrationProviderInfo[]
mcpServers: MigrationMcpServerInfo[]
customModes: MigrationCustomModeInfo[]
sessions?: MigrationSessionInfo[]
defaultModel?: { provider: string; model: string }
settings?: LegacySettings
}
}
export interface LegacyMigrationProgressMessage {
type: "legacyMigrationProgress"
item: string
status: "migrating" | "success" | "warning" | "error"
message?: string
}
export type LegacyMigrationSessionPhase = "preparing" | "storing" | "skipped" | "done" | "summary" | "error"
export interface LegacyMigrationSessionProgressMessage {
type: "legacyMigrationSessionProgress"
session: MigrationSessionInfo
index: number
total: number
phase: LegacyMigrationSessionPhase
error?: string
}
export interface LegacyMigrationCompleteMessage {
type: "legacyMigrationComplete"
results: MigrationResultItem[]
}
export interface RequestLegacyMigrationDataMessage {
type: "requestLegacyMigrationData"
}
export interface MigrationAutoApprovalSelections {
commandRules: boolean
readPermission: boolean
writePermission: boolean
executePermission: boolean
mcpPermission: boolean
taskPermission: boolean
}
export interface MigrationSessionSelection {
id: string
force?: boolean
}
export interface StartLegacyMigrationMessage {
type: "startLegacyMigration"
selections: {
providers: string[]
mcpServers: string[]
customModes: string[]
sessions?: MigrationSessionSelection[]
defaultModel: boolean
settings: {
autoApproval: MigrationAutoApprovalSelections
language: boolean
autocomplete: boolean
}
}
}
export interface SkipLegacyMigrationMessage {
type: "skipLegacyMigration"
}
export interface ClearLegacyDataMessage {
type: "clearLegacyData"
}
export interface FinalizeLegacyMigrationMessage {
type: "finalizeLegacyMigration"
}
// legacy-migration end
@@ -0,0 +1,94 @@
// Tool state for tool parts
export type ToolState =
| { status: "pending"; input: Record<string, unknown> }
| { status: "running"; input: Record<string, unknown>; title?: string }
| { status: "completed"; input: Record<string, unknown>; output: string; title: string }
| { status: "error"; input: Record<string, unknown>; error: string }
// Base part interface - all parts have these fields
export interface BasePart {
id: string
sessionID?: string
messageID?: string
}
// Part types from the backend
export interface TextPart extends BasePart {
type: "text"
text: string
}
export interface FilePartSource {
type: "file"
path: string
text: {
value: string
start: number
end: number
}
}
export interface FilePart extends BasePart {
type: "file"
mime: string
url: string
filename?: string
source?: FilePartSource
}
export interface ToolPart extends BasePart {
type: "tool"
tool: string
state: ToolState
}
export interface ReasoningPart extends BasePart {
type: "reasoning"
text: string
}
// Step parts from the backend
export interface StepStartPart extends BasePart {
type: "step-start"
}
export interface StepFinishPart extends BasePart {
type: "step-finish"
reason?: string
cost?: number
tokens?: {
input: number
output: number
reasoning?: number
cache?: { read: number; write: number }
}
}
export type Part = TextPart | FilePart | ToolPart | ReasoningPart | StepStartPart | StepFinishPart
// Part delta for streaming updates
export interface PartDelta {
type: "text-delta"
textDelta?: string
}
// Token usage for assistant messages
export interface TokenUsage {
input: number
output: number
reasoning?: number
cache?: { read: number; write: number }
}
// Context usage derived from the last assistant message's tokens
export interface ContextUsage {
tokens: number
percentage: number | null
}
export interface FileAttachment {
mime: string
url: string
filename?: string
source?: FilePartSource
}
@@ -0,0 +1,39 @@
export type PermissionLevel = "allow" | "ask" | "deny"
/** null in a PermissionRule object is a delete sentinel — removes the key from the config */
export type PermissionRule = PermissionLevel | Record<string, PermissionLevel | null>
export type PermissionConfig = Partial<Record<string, PermissionRule>>
// A single resolved permission rule from the CLI backend (matches PermissionNext.Rule)
export interface PermissionRuleItem {
permission: string
pattern: string
action: PermissionLevel
}
// Permission request
export interface PermissionFileDiff {
file: string
patch?: string
before?: string
after?: string
additions: number
deletions: number
}
export interface PermissionRequest {
id: string
sessionID: string
toolName: string
patterns: string[]
always: string[]
args: Record<string, unknown> & {
rules?: string[]
diff?: string
filepath?: string
filediff?: PermissionFileDiff
}
message?: string
tool?: { messageID: string; callID: string }
}
@@ -0,0 +1,29 @@
// Kilo notification types (mirrored from kilo-gateway)
export interface KilocodeNotificationAction {
actionText: string
actionURL: string
}
export interface KilocodeNotification {
id: string
title: string
message: string
action?: KilocodeNotificationAction
showIn?: string[]
suggestModelId?: string
}
// Profile types from kilo-gateway
export interface KilocodeBalance {
balance: number
}
export interface ProfileData {
profile: {
email: string
name?: string
organizations?: Array<{ id: string; name: string; role: string }>
}
balance: KilocodeBalance | null
currentOrgId: string | null
}
@@ -0,0 +1,54 @@
// Provider/model types for model selector
export interface ProviderModel {
id: string
name: string
inputPrice?: number
outputPrice?: number
contextLength?: number
releaseDate?: string
latest?: boolean
// Actual shape returned by the server (Provider.Model)
limit?: { context: number; input?: number; output: number }
variants?: Record<string, Record<string, unknown>>
capabilities?: {
reasoning: boolean
input?: { text: boolean; image: boolean; audio: boolean; video: boolean; pdf: boolean }
}
options?: { description?: string }
recommendedIndex?: number
isFree?: boolean
cost?: {
input: number
output: number
cache?: {
read: number
write: number
}
}
}
export interface Provider {
id: string
name: string
models: Record<string, ProviderModel>
source?: "env" | "config" | "custom" | "api"
env?: string[]
}
export interface ModelSelection {
providerID: string
modelID: string
}
export type ProviderAuthState = "api" | "oauth" | "wellknown"
export interface ProviderConfig {
name?: string
api_key?: string
base_url?: string
models?: Record<string, unknown>
npm?: string
env?: string[]
options?: Record<string, unknown>
}
@@ -0,0 +1,57 @@
// Todo item
export interface TodoItem {
id: string
content: string
status: "pending" | "in_progress" | "completed"
}
// Question types
export interface QuestionOption {
label: string
description: string
mode?: string
// Optional i18n keys — the backend fills these for strings it wants translated in the webview.
// The canonical English `label` stays on the reply wire, so server-side matching is unaffected.
labelKey?: string
descriptionKey?: string
}
export interface QuestionInfo {
question: string
header: string
options: QuestionOption[]
multiple?: boolean
custom?: boolean
// Optional i18n keys for question text and header (see QuestionOption for details).
questionKey?: string
headerKey?: string
}
export interface QuestionRequest {
id: string
sessionID: string
questions: QuestionInfo[]
blocking?: boolean
tool?: {
messageID: string
callID: string
}
}
export interface SuggestionAction {
label: string
description?: string
prompt: string
}
export interface SuggestionRequest {
id: string
sessionID: string
text: string
actions: SuggestionAction[]
blocking?: boolean
tool?: {
messageID: string
callID: string
}
}
@@ -0,0 +1,65 @@
import type { Part, TokenUsage } from "./parts"
// Message structure (simplified for webview)
export interface Message {
id: string
sessionID: string
role: "user" | "assistant"
content?: string
parts?: Part[]
createdAt: string
time?: { created: number; completed?: number }
agent?: string
model?: { providerID: string; modelID: string }
providerID?: string
modelID?: string
mode?: string
parentID?: string
path?: { cwd: string; root: string }
error?: { name: string; data?: Record<string, unknown> }
summary?: { title?: string; body?: string; diffs?: unknown[] } | boolean
cost?: number
tokens?: TokenUsage
finish?: string
}
// File diff info (matches Snapshot.FileDiff from CLI backend)
export interface SessionFileDiff {
file: string
before: string
after: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
// Session info (simplified for webview)
export interface SessionInfo {
id: string
parentID?: string | null
title?: string
createdAt: string
updatedAt: string
revert?: {
messageID: string
partID?: string
snapshot?: string
diff?: string
} | null
summary?: {
additions: number
deletions: number
files: number
diffs?: SessionFileDiff[]
} | null
}
// Cloud session info (from Kilo cloud API)
export interface CloudSessionInfo {
session_id: string
title: string | null
created_at: string
updated_at: string
}
export type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile"
File diff suppressed because it is too large Load Diff
+18
View File
@@ -1,5 +1,23 @@
# @kilocode/cli
## 7.2.23
### Minor Changes
- [#9418](https://github.com/Kilo-Org/kilocode/pull/9418) [`12c2d86`](https://github.com/Kilo-Org/kilocode/commit/12c2d86c84ecfce118ffb5b4db7ed4155bbca8fc) - Show the open GitHub PR for the current branch in the session sidebar.
### Patch Changes
- [#9470](https://github.com/Kilo-Org/kilocode/pull/9470) [`7fe4508`](https://github.com/Kilo-Org/kilocode/commit/7fe4508eecf7e7da8336f75c0884d1b310af6c6e) - Fix multi-turn tool calls with DeepSeek thinking mode by preserving empty `reasoning_content` in the interleaved transform.
## 7.2.22
### Patch Changes
- [#9455](https://github.com/Kilo-Org/kilocode/pull/9455) [`567ca0d`](https://github.com/Kilo-Org/kilocode/commit/567ca0d34178a6a896aa58c10cc946565c116d4e) - Fix a 1-2 second startup delay before home content (agents, news, tips) appears in the TUI.
- [#9425](https://github.com/Kilo-Org/kilocode/pull/9425) [`6ee160f`](https://github.com/Kilo-Org/kilocode/commit/6ee160f89c10293d635990798779988d34b092b4) - Preserve typed text in the main prompt when a blocking question, suggestion, permission, or network overlay is shown and then dismissed.
## 7.2.21
### Minor Changes
+5 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.2.21",
"version": "7.2.24",
"name": "@kilocode/cli",
"type": "module",
"license": "MIT",
@@ -86,7 +86,7 @@
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.102",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/google": "3.0.63",
"@ai-sdk/google-vertex": "4.0.112",
"@ai-sdk/groq": "3.0.31",
@@ -105,7 +105,7 @@
"@effect/platform-node": "catalog:",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-server": "1.19.13",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
@@ -121,7 +121,7 @@
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/script": "workspace:*",
"@openrouter/ai-sdk-provider": "2.5.1",
"@openrouter/ai-sdk-provider": "2.8.1",
"@opentelemetry/api": "1.9.0",
"@opentelemetry/context-async-hooks": "2.6.1",
"@opentelemetry/exporter-trace-otlp-http": "0.214.0",
@@ -148,7 +148,7 @@
"drizzle-orm": "catalog:",
"effect": "catalog:",
"fuzzysort": "3.1.0",
"gitlab-ai-provider": "6.4.2",
"gitlab-ai-provider": "6.6.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
+23 -8
View File
@@ -7,6 +7,22 @@ import { fileURLToPath } from "url"
const dir = fileURLToPath(new URL("..", import.meta.url))
process.chdir(dir)
async function published(name: string, version: string) {
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
}
async function publish(dir: string, name: string, version: string) {
// GitHub artifact downloads can drop the executable bit, and Docker uses the
// unpacked dist binaries directly rather than the published tarball.
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
if (await published(name, version)) {
console.log(`already published ${name}@${version}`)
return
}
await $`bun pm pack`.cwd(dir)
await $`npm publish *.tgz --access public --tag ${Script.channel} --provenance`.cwd(dir) // kilocode_change
}
const binaries: Record<string, string> = {}
// kilocode_change start
for (const filepath of new Bun.Glob("*/*/package.json").scanSync({ cwd: "./dist" })) {
@@ -52,14 +68,10 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
)
const tasks = Object.entries(binaries).map(async ([name]) => {
if (process.platform !== "win32") {
await $`chmod -R 755 .`.cwd(`./dist/${name}`)
}
await $`bun pm pack`.cwd(`./dist/${name}`)
await $`npm publish *.tgz --access public --tag ${Script.channel} --provenance`.cwd(`./dist/${name}`) // kilocode_change
await publish(`./dist/${name}`, name, binaries[name])
})
await Promise.all(tasks)
await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${Script.channel} --provenance` // kilocode_change
await publish(`./dist/${pkg.name}`, pkg.name, version) // kilocode_change
const image = "ghcr.io/kilo-org/kilo" // kilocode_change
const platforms = "linux/amd64,linux/arm64"
@@ -117,6 +129,7 @@ if (!Script.preview) {
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/aur-${pkg} && git push`
break
@@ -189,6 +202,8 @@ if (!Script.preview) {
await $`git clone ${tap} ./dist/homebrew-tap`
await Bun.file("./dist/homebrew-tap/kilo.rb").write(homebrewFormula) // kilocode_change
await $`cd ./dist/homebrew-tap && git add kilo.rb` // kilocode_change
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) {
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
await $`cd ./dist/homebrew-tap && git push`
}
}
+32 -49
View File
@@ -1,12 +1,13 @@
# Facade removal checklist
Concrete inventory of the remaining `makeRuntime(...)`-backed service facades in `packages/opencode`.
Concrete inventory of the remaining `makeRuntime(...)`-backed facades in `packages/opencode`.
As of 2026-04-13, latest `origin/dev`:
Current status on this branch:
- `src/` still has 15 `makeRuntime(...)` call sites.
- 13 of those are still in scope for facade removal.
- 2 are excluded from this checklist: `bus/index.ts` and `effect/cross-spawn-spawner.ts`.
- `src/` has 5 `makeRuntime(...)` call sites total.
- 2 are intentionally excluded from this checklist: `src/bus/index.ts` and `src/effect/cross-spawn-spawner.ts`.
- 1 is tracked primarily by the instance-context migration rather than facade removal: `src/project/instance.ts`.
- That leaves 2 live runtime-backed service facades still worth tracking here: `src/npm/index.ts` and `src/cli/cmd/tui/config/tui.ts`.
Recent progress:
@@ -15,8 +16,9 @@ Recent progress:
## Priority hotspots
- `server/instance/session.ts` still depends on `Session`, `SessionPrompt`, `SessionRevert`, `SessionCompaction`, `SessionSummary`, `ShareSession`, `Agent`, and `Permission` facades.
- `src/effect/app-runtime.ts` still references many facade namespaces directly, so it should stay in view during each deletion.
- `src/cli/cmd/tui/config/tui.ts` still exports `makeRuntime(...)` plus async facade helpers for `get()` and `waitForDependencies()`.
- `src/npm/index.ts` still exports `makeRuntime(...)` plus async facade helpers for `install()`, `add()`, `outdated()`, and `which()`.
- `src/project/instance.ts` still uses a dedicated runtime for project boot, but that file is really part of the broader legacy instance-context transition tracked in `instance-context.md`.
## Completed Batches
@@ -184,53 +186,34 @@ These were the recurring mistakes and useful corrections from the first two batc
5. For CLI readability, extract file-local preload helpers when the handler starts doing config load + service load + batched effect fanout inline.
6. When rebasing a facade branch after nearby merges, prefer the already-cleaned service/test version over older inline facade-era code.
## Next batch
## Remaining work
Recommended next five, in order:
Most of the original facade-removal backlog is already done. The practical remaining work is narrower now:
1. `src/permission/index.ts`
2. `src/agent/agent.ts`
3. `src/session/summary.ts`
4. `src/session/revert.ts`
5. `src/mcp/auth.ts`
Why this batch:
- It keeps pushing the session-adjacent cleanup without jumping straight into `session/index.ts` or `session/prompt.ts`.
- `Permission`, `Agent`, `SessionSummary`, and `SessionRevert` all reduce fanout in `server/instance/session.ts`.
- `McpAuth` is small and closely related to the just-landed `MCP` cleanup.
After that batch, the expected follow-up is the main session cluster:
1. `src/session/index.ts`
2. `src/session/prompt.ts`
3. `src/session/compaction.ts`
1. remove the `Npm` runtime-backed facade from `src/npm/index.ts`
2. remove the `TuiConfig` runtime-backed facade from `src/cli/cmd/tui/config/tui.ts`
3. keep `src/project/instance.ts` in the separate instance-context migration, not this checklist
## Checklist
- [ ] `src/session/index.ts` (`Session`) - facades: `create`, `fork`, `get`, `setTitle`, `setArchived`, `setPermission`, `setRevert`, `messages`, `children`, `remove`, `updateMessage`, `removeMessage`, `removePart`, `updatePart`; main callers: `server/instance/session.ts`, `cli/cmd/session.ts`, `cli/cmd/export.ts`, `cli/cmd/github.ts`; tests: `test/server/session-actions.test.ts`, `test/server/session-list.test.ts`, `test/server/global-session-list.test.ts`
- [ ] `src/session/prompt.ts` (`SessionPrompt`) - facades: `prompt`, `resolvePromptParts`, `cancel`, `loop`, `shell`, `command`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`; tests: `test/session/prompt.test.ts`, `test/session/prompt-effect.test.ts`, `test/session/structured-output-integration.test.ts`
- [ ] `src/session/revert.ts` (`SessionRevert`) - facades: `revert`, `unrevert`, `cleanup`; main callers: `server/instance/session.ts`; tests: `test/session/revert-compact.test.ts`
- [ ] `src/session/compaction.ts` (`SessionCompaction`) - facades: `isOverflow`, `prune`, `create`; main callers: `server/instance/session.ts`; tests: `test/session/compaction.test.ts`
- [ ] `src/session/summary.ts` (`SessionSummary`) - facades: `summarize`, `diff`; main callers: `session/prompt.ts`, `session/processor.ts`, `server/instance/session.ts`; tests: `test/session/snapshot-tool-race.test.ts`
- [ ] `src/share/session.ts` (`ShareSession`) - facades: `create`, `share`, `unshare`; main callers: `server/instance/session.ts`, `cli/cmd/github.ts`
- [ ] `src/agent/agent.ts` (`Agent`) - facades: `get`, `list`, `defaultAgent`, `generate`; main callers: `cli/cmd/agent.ts`, `server/instance/session.ts`, `server/instance/experimental.ts`; tests: `test/agent/agent.test.ts`
- [ ] `src/permission/index.ts` (`Permission`) - facades: `ask`, `reply`, `list`; main callers: `server/instance/permission.ts`, `server/instance/session.ts`, `session/llm.ts`; tests: `test/permission/next.test.ts`
- [x] `src/file/index.ts` (`File`) - facades removed and merged.
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged.
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged.
- [x] `src/config/config.ts` (`Config`) - facades removed and merged.
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged.
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged.
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged.
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged.
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged.
- [ ] `src/worktree/index.ts` (`Worktree`) - facades: `makeWorktreeInfo`, `createFromInfo`, `create`, `remove`, `reset`; main callers: `control-plane/adaptors/worktree.ts`, `server/instance/experimental.ts`; tests: `test/project/worktree.test.ts`, `test/project/worktree-remove.test.ts`
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged.
- [ ] `src/mcp/auth.ts` (`McpAuth`) - facades: `get`, `getForUrl`, `all`, `set`, `remove`, `updateTokens`, `updateClientInfo`, `updateCodeVerifier`, `updateOAuthState`; main callers: `mcp/oauth-provider.ts`, `cli/cmd/mcp.ts`; tests: `test/mcp/oauth-auto-connect.test.ts`
- [ ] `src/plugin/index.ts` (`Plugin`) - facades: `trigger`, `list`, `init`; main callers: `agent/agent.ts`, `session/llm.ts`, `project/bootstrap.ts`; tests: `test/plugin/trigger.test.ts`, `test/provider/provider.test.ts`
- [ ] `src/project/project.ts` (`Project`) - facades: `fromDirectory`, `discover`, `initGit`, `update`, `sandboxes`, `addSandbox`, `removeSandbox`; main callers: `project/instance.ts`, `server/instance/project.ts`, `server/instance/experimental.ts`; tests: `test/project/project.test.ts`, `test/project/migrate-global.test.ts`
- [ ] `src/snapshot/index.ts` (`Snapshot`) - facades: `init`, `track`, `patch`, `restore`, `revert`, `diff`, `diffFull`; main callers: `project/bootstrap.ts`, `cli/cmd/debug/snapshot.ts`; tests: `test/snapshot/snapshot.test.ts`, `test/session/revert-compact.test.ts`
- [ ] `src/npm/index.ts` (`Npm`) - still exports runtime-backed async facade helpers on top of `Npm.Service`
- [ ] `src/cli/cmd/tui/config/tui.ts` (`TuiConfig`) - still exports runtime-backed async facade helpers on top of `TuiConfig.Service`
- [x] `src/session/session.ts` / `src/session/prompt.ts` / `src/session/revert.ts` / `src/session/summary.ts` - service-local facades removed
- [x] `src/agent/agent.ts` (`Agent`) - service-local facades removed
- [x] `src/permission/index.ts` (`Permission`) - service-local facades removed
- [x] `src/worktree/index.ts` (`Worktree`) - service-local facades removed
- [x] `src/plugin/index.ts` (`Plugin`) - service-local facades removed
- [x] `src/snapshot/index.ts` (`Snapshot`) - service-local facades removed
- [x] `src/file/index.ts` (`File`) - facades removed and merged
- [x] `src/lsp/index.ts` (`LSP`) - facades removed and merged
- [x] `src/mcp/index.ts` (`MCP`) - facades removed and merged
- [x] `src/config/config.ts` (`Config`) - facades removed and merged
- [x] `src/provider/provider.ts` (`Provider`) - facades removed and merged
- [x] `src/pty/index.ts` (`Pty`) - facades removed and merged
- [x] `src/skill/index.ts` (`Skill`) - facades removed and merged
- [x] `src/project/vcs.ts` (`Vcs`) - facades removed and merged
- [x] `src/tool/registry.ts` (`ToolRegistry`) - facades removed and merged
- [x] `src/auth/index.ts` (`Auth`) - facades removed and merged
## Excluded `makeRuntime(...)` sites
+85 -46
View File
@@ -76,7 +76,7 @@ Many route boundaries still use Zod-first validators. That does not block all ex
### Mixed handler styles
Many current `server/instance/*.ts` handlers still call async facades directly. Migrating those to composed `Effect.gen(...)` handlers is the low-risk step to do first.
Many current `server/routes/instance/*.ts` handlers still mix composed Effect code with smaller Promise- or ALS-backed seams. Migrating those to consistent `Effect.gen(...)` handlers is the low-risk step to do first.
### Non-JSON routes
@@ -90,7 +90,7 @@ The current server composition, middleware, and docs flow are Hono-centered toda
### 1. Finish the prerequisites first
- continue route-handler effectification in `server/instance/*.ts`
- continue route-handler effectification in `server/routes/instance/*.ts`
- continue schema migration toward Effect Schema-first DTOs and errors
- keep removing service facades
@@ -98,9 +98,9 @@ The current server composition, middleware, and docs flow are Hono-centered toda
Introduce one small `HttpApi` group for plain JSON endpoints only. Good initial candidates are the least stateful endpoints in:
- `server/instance/question.ts`
- `server/instance/provider.ts`
- `server/instance/permission.ts`
- `server/routes/instance/question.ts`
- `server/routes/instance/provider.ts`
- `server/routes/instance/permission.ts`
Avoid `session.ts`, SSE, websocket, and TUI-facing routes first.
@@ -155,9 +155,9 @@ This gives:
As each route group is ported to `HttpApi`:
1. change its `root` path from `/experimental/httpapi/<group>` to `/<group>`
2. add `.all("/<group>", handler)` / `.all("/<group>/*", handler)` to the flag block in `instance/index.ts`
3. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
1. add `.get(...)` / `.post(...)` bridge entries to the flag block in `server/routes/instance/index.ts`
2. for partial ports (e.g. only `GET /provider/auth`), bridge only the specific path
3. keep the legacy Hono route registered behind it for OpenAPI / SDK generation until the spec pipeline changes
4. verify SDK output is unchanged
Leave streaming-style endpoints on Hono until there is a clear reason to move them.
@@ -189,10 +189,46 @@ Ordering for a route-group migration:
SDK shape rule:
- every schema migration must preserve the generated SDK output byte-for-byte
- `Schema.Class` emits a named `$ref` in OpenAPI via its identifier — use it only for types that already had `.meta({ ref })` in the old Zod schema
- inner / nested types that were anonymous in the old Zod schema should stay as `Schema.Struct` (not `Schema.Class`) to avoid introducing new named components in the OpenAPI spec
- if a diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
- every schema migration must preserve the generated SDK output byte-for-byte **unless the new ref is intentional** (see Schema.Class vs Schema.Struct below)
- if an unintended diff appears in `packages/sdk/js/src/v2/gen/types.gen.ts`, the migration introduced an unintended API surface change — fix it before merging
### Schema.Class vs Schema.Struct
The pattern choice determines whether a schema becomes a **named** export in the SDK or stays **anonymous inline**.
**Schema.Class** emits a named `$ref` in OpenAPI via its identifier → produces a named `export type Foo = ...` in `types.gen.ts`:
```ts
export class Info extends Schema.Class<Info>("FooConfig")({ ... }) {
static readonly zod = zod(this)
}
```
**Schema.Struct** stays anonymous and is inlined everywhere it is referenced:
```ts
export const Info = Schema.Struct({ ... }).pipe(
withStatics((s) => ({ zod: zod(s) })),
)
export type Info = Schema.Schema.Type<typeof Info>
```
When to use each:
- Use **Schema.Class** when:
- the original Zod had `.meta({ ref: ... })` (preserve the existing named SDK type byte-for-byte)
- the schema is a top-level endpoint request or response (SDK consumers benefit from a stable importable name)
- Use **Schema.Struct** when:
- the type is only used as a nested field inside another named schema
- the original Zod was anonymous and promoting it would bloat SDK types with no import value
Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape.
Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior:
```ts
export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" })
```
Temporary exception:
@@ -231,7 +267,7 @@ Use the same sequence for each route group.
3. Apply the schema migration ordering above so those types are Effect Schema-first.
4. Define the `HttpApi` contract separately from the handlers.
5. Implement handlers by yielding the existing service from context.
6. Mount the new surface in parallel under an experimental prefix.
6. Mount the new surface in parallel behind the `KILO_EXPERIMENTAL_HTTPAPI` bridge.
7. Regenerate the SDK and verify zero diff against `dev` (see SDK shape rule above).
8. Add one end-to-end test and one OpenAPI-focused test.
9. Compare ergonomics before migrating the next endpoint.
@@ -250,20 +286,20 @@ Placement rule:
- keep `HttpApi` code under `src/server`, not `src/effect`
- `src/effect` should stay focused on runtimes, layers, instance state, and shared Effect plumbing
- place each `HttpApi` slice next to the HTTP boundary it serves
- for instance-scoped routes, prefer `src/server/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/control/httpapi/*`
- for instance-scoped routes, prefer `src/server/routes/instance/httpapi/*`
- if control-plane routes ever migrate, prefer `src/server/routes/control/httpapi/*`
Suggested file layout for a repeatable spike:
- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/instance/httpapi/server.ts`standalone Effect HTTP server that composes all groups
- `test/server/question-httpapi.test.ts` — end-to-end test against the real service
- `src/server/routes/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/routes/instance/httpapi/server.ts`bridged Effect HTTP layer that composes all groups
- route or OpenAPI verification should live alongside the existing server tests; there is no dedicated `question-httpapi` test file on this branch
Suggested responsibilities:
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
- `server.ts` composes all route groups into one `HttpRouter.toWebHandler(...)` bridge with shared middleware (auth, instance lookup)
- tests should verify the bridged routes through the normal server surface
## Example migration shape
@@ -283,33 +319,33 @@ Each route-group spike should follow the same shape.
- keep handler bodies thin
- keep transport mapping at the HTTP boundary only
### 3. Standalone server
### 3. Bridged server
- the Effect HTTP server is self-contained in `httpapi/server.ts`
- it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
- each route group exposes its own OpenAPI doc endpoint
- the Effect HTTP layer is composed in `httpapi/server.ts`
- it is mounted into the Hono app via `HttpRouter.toWebHandler(...)`
- routes keep their normal instance paths and are gated by the `KILO_EXPERIMENTAL_HTTPAPI` flag
- the legacy Hono handlers stay registered after the bridge so current OpenAPI / SDK generation still works
### 4. Verification
- seed real state through the existing service
- call the experimental endpoints
- call the bridged endpoints with the flag enabled
- assert that the service behavior is unchanged
- assert that the generated OpenAPI contains the migrated paths and schemas
## Boundary composition
The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server.
The Effect `HttpApi` layer owns its own auth and instance middleware, but it is currently mounted inside the existing Hono server.
### Auth
- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- the bridged `HttpApi` layer implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
- this is independent of the Hono auth layer; the current bridge keeps the responsibility local to the `HttpApi` slice
### Instance and workspace lookup
- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-kilo-directory` headers and `directory` query params
- the bridged `HttpApi` layer resolves instance context via an `HttpRouter.middleware` that reads `x-kilo-directory` headers and `directory` query params
- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
- `HttpApi` handlers yield services from context and assume the correct instance has already been provided
@@ -324,7 +360,7 @@ The standalone Effect server owns its own middleware stack. It does not share mi
The first slice is successful if:
- the standalone Effect server starts and serves the endpoints independently of the Hono server
- the bridged endpoints serve correctly through the existing Hono host when the flag is enabled
- the handlers reuse the existing Effect service
- request decoding and response shapes are schema-defined from canonical Effect schemas
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary
@@ -365,17 +401,16 @@ Current instance route inventory:
endpoints: `GET /question`, `POST /question/:requestID/reply`, `POST /question/:requestID/reject`
- `permission` - `bridged`
endpoints: `GET /permission`, `POST /permission/:requestID/reply`
- `provider` - `bridged` (partial)
bridged endpoint: `GET /provider/auth`
not yet ported: `GET /provider`, OAuth mutations
- `config` - `next`
best next endpoint: `GET /config/providers`
- `provider` - `bridged`
endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback`
- `config` - `bridged` (partial)
bridged endpoint: `GET /config/providers`
later endpoint: `GET /config`
defer `PATCH /config` for now
- `project` - `later`
best small reads: `GET /project`, `GET /project/current`
- `project` - `bridged` (partial)
bridged endpoints: `GET /project`, `GET /project/current`
defer git-init mutation first
- `workspace` - `later`
- `workspace` - `next`
best small reads: `GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`
defer create/remove mutations first
- `file` - `later`
@@ -393,12 +428,12 @@ Current instance route inventory:
- `tui` - `defer`
queue-style UI bridge, weak early `HttpApi` fit
Recommended near-term sequence after the first spike:
Recommended near-term sequence:
1. `provider` auth read endpoint
2. `config` providers read endpoint
3. `project` read endpoints
4. `workspace` read endpoints
1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`)
2. `config` full read endpoint (`GET /config`)
3. `file` JSON read endpoints
4. `mcp` JSON read endpoints
## Checklist
@@ -411,8 +446,12 @@ Recommended near-term sequence after the first spike:
- [x] gate behind `KILO_EXPERIMENTAL_HTTPAPI` flag
- [x] verify OTEL spans and HTTP logs flow to motel
- [x] bridge question, permission, and provider auth routes
- [ ] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [ ] port `config` read endpoints
- [x] port remaining provider endpoints (`GET /provider`, OAuth mutations)
- [x] port `config` providers read endpoint
- [x] port `project` read endpoints (`GET /project`, `GET /project/current`)
- [ ] port `workspace` read endpoints
- [ ] port `GET /config` full read endpoint
- [ ] port `file` JSON read endpoints
- [ ] decide when to remove the flag and make Effect routes the default
## Rule of thumb
@@ -157,7 +157,7 @@ Direct legacy usage means any source file that still calls one of:
- `Instance.reload(...)`
- `Instance.dispose()` / `Instance.disposeAll()`
Current total: `54` files in `packages/opencode/src`.
Current total: `56` files in `packages/opencode/src`.
### Core bridge and plumbing
@@ -177,13 +177,13 @@ Migration rule:
These are the current request-entry seams that still create or consume instance context through the legacy helper.
- `src/server/instance/middleware.ts`
- `src/server/instance/index.ts`
- `src/server/instance/project.ts`
- `src/server/instance/workspace.ts`
- `src/server/instance/file.ts`
- `src/server/instance/experimental.ts`
- `src/server/instance/global.ts`
- `src/server/routes/instance/middleware.ts`
- `src/server/routes/instance/index.ts`
- `src/server/routes/instance/project.ts`
- `src/server/routes/control/workspace.ts`
- `src/server/routes/instance/file.ts`
- `src/server/routes/instance/experimental.ts`
- `src/server/routes/global.ts`
Migration rule:
@@ -239,7 +239,7 @@ Migration rule:
These modules are already the best near-term migration targets because they are in Effect code but still read sync getters from the legacy helper.
- `src/agent/agent.ts`
- `src/config/tui-migrate.ts`
- `src/cli/cmd/tui/config/tui-migrate.ts`
- `src/file/index.ts`
- `src/file/watcher.ts`
- `src/format/formatter.ts`
@@ -250,7 +250,7 @@ These modules are already the best near-term migration targets because they are
- `src/project/vcs.ts`
- `src/provider/provider.ts`
- `src/pty/index.ts`
- `src/session/index.ts`
- `src/session/session.ts`
- `src/session/instruction.ts`
- `src/session/llm.ts`
- `src/session/system.ts`
+6 -8
View File
@@ -4,11 +4,11 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
## Config / TUI
- [ ] `config/tui.ts` - finish the internal Effect migration after the `Instance.state(...)` removal.
- [ ] `cli/cmd/tui/config/tui.ts` - finish the internal Effect migration.
Keep the current precedence and migration semantics intact while converting the remaining internal async helpers (`loadState`, `mergeFile`, `loadFile`, `load`) to `Effect.gen(...)` / `Effect.fn(...)`.
- [ ] `config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
- [ ] `cli/cmd/tui/config/tui.ts` callers - once the internal service is stable, migrate plain async callers to use `TuiConfig.Service` directly where that actually simplifies the code.
Likely first callers: `cli/cmd/tui/attach.ts`, `cli/cmd/tui/thread.ts`, `cli/cmd/tui/plugin/runtime.ts`.
- [ ] `env/index.ts` - move the last production `Instance.state(...)` usage onto `InstanceState` (or its replacement) so `Instance.state` can be deleted.
- [x] `env/index.ts` - already uses `InstanceState.make(...)`.
## ConfigPaths
@@ -21,14 +21,12 @@ Small follow-ups that do not fit neatly into the main facade, route, tool, or sc
- `readFile(...)`
- `parseText(...)`
- [ ] `config/config.ts` - switch internal config loading from `Effect.promise(() => ConfigPaths.*(...))` to `yield* paths.*(...)` once the service exists.
- [ ] `config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
- [ ] `cli/cmd/tui/config/tui.ts` - switch TUI config loading from async `ConfigPaths.*` wrappers to the `ConfigPaths.Service` once that service exists.
- [ ] `cli/cmd/tui/config/tui-migrate.ts` - decide whether to leave this as a plain async module using wrapper functions or effectify it fully after `ConfigPaths.Service` lands.
## Instance cleanup
- [ ] `project/instance.ts` - remove `Instance.state(...)` once `env/index.ts` is migrated.
- [ ] `project/state.ts` - delete the bespoke per-instance state helper after the last production caller is gone.
- [ ] `test/project/state.test.ts` - replace or delete the old `Instance.state(...)` tests after the removal.
- [ ] `project/instance.ts` - keep shrinking the legacy ALS / Promise cache after the remaining `Instance.*` callers move over.
## Notes
+34 -44
View File
@@ -19,53 +19,43 @@ See `instance-context.md` for the phased plan to remove the legacy ALS / promise
## Service shape
Every service follows the same pattern — a single namespace with the service definition, layer, `runPromise`, and async facade functions:
Every service follows the same pattern: one module, flat top-level exports, traced Effect methods, and a self-reexport at the bottom when the file is the public module.
```ts
export namespace Foo {
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
// For instance-scoped services:
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
// Optional: wire dependencies
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
// Per-service runtime (inside the namespace)
const { runPromise } = makeRuntime(Service, defaultLayer)
// Async facade functions
export async function get(id: FooID) {
return runPromise((svc) => svc.get(id))
}
export interface Interface {
readonly get: (id: FooID) => Effect.Effect<FooInfo, FooError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Foo") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const state = yield* InstanceState.make<State>(
Effect.fn("Foo.state")(() => Effect.succeed({ ... })),
)
const get = Effect.fn("Foo.get")(function* (id: FooID) {
const s = yield* InstanceState.get(state)
// ...
})
return Service.of({ get })
}),
)
export const defaultLayer = layer.pipe(Layer.provide(FooDep.layer))
export * as Foo from "."
```
Rules:
- Keep everything in one namespace, one file — no separate `service.ts` / `index.ts` split
- `runPromise` goes inside the namespace (not exported unless tests need it)
- Facade functions are plain `async function` — no `fn()` wrappers
- Use `Effect.fn("Namespace.method")` for all Effect functions (for tracing)
- No `Layer.fresh` — InstanceState handles per-directory isolation
- Keep the service surface in one module; prefer flat top-level exports over `export namespace Foo { ... }`
- Use `Effect.fn("Foo.method")` for Effect methods
- Use a self-reexport (`export * as Foo from "."` or `"./foo"`) for the public namespace projection
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
## Schema → Zod interop
@@ -266,7 +256,7 @@ Tool-specific filesystem cleanup notes live in `tools.md`.
## Destroying the facades
This phase is still broadly open. As of 2026-04-13 there are still 15 `makeRuntime(...)` call sites under `src/`, with 13 still in scope for facade removal. The live checklist now lives in `facades.md`.
This phase is no longer broadly open. There are 5 `makeRuntime(...)` call sites under `src/`, and only a small subset are still ordinary facade-removal targets. The live checklist now lives in `facades.md`.
These facades exist because cyclic imports used to force each service to build its own independent runtime. Now that the layer DAG is acyclic and `AppRuntime` (`src/effect/app-runtime.ts`) composes everything into one `ManagedRuntime`, we're removing them.
@@ -297,11 +287,11 @@ For each service, the migration is roughly:
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/instance/session.ts` converted; facade removed.
- `Account` — migrated 2026-04-11. Callers in `server/routes/instance/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/instance/question.ts` and test converted; facade removed.
- `Question` — migrated 2026-04-11. Callers in `server/routes/instance/question.ts` and test converted; facade removed.
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
## Route handler effectification
+16 -18
View File
@@ -39,28 +39,26 @@ This eliminates multiple `runPromise` round-trips and lets handlers compose natu
## Current route files
Current instance route files live under `src/server/instance`, not `server/routes`.
Current instance route files live under `src/server/routes/instance`.
The main migration targets are:
Files that are already mostly on the intended service-yielding shape:
- [ ] `server/instance/session.ts` — heaviest; still has many direct facade calls for Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, Agent, Bus
- [ ] `server/instance/global.ts` — still has direct facade calls for Config and instance lifecycle actions
- [ ] `server/instance/provider.ts` — still has direct facade calls for Config and Provider
- [ ] `server/instance/question.ts` — partially converted; still worth tracking here until it consistently uses the composed style
- [ ] `server/instance/pty.ts`still calls Pty facades directly
- [ ] `server/instance/experimental.ts` — mixed state; some handlers are already composed, others still use facades
- [x] `server/routes/instance/question.ts` — handlers yield `Question.Service`
- [x] `server/routes/instance/provider.ts` — handlers yield `Provider.Service`, `ProviderAuth.Service`, and `Config.Service`
- [x] `server/routes/instance/permission.ts` — handlers yield `Permission.Service`
- [x] `server/routes/instance/mcp.ts` — handlers mostly yield `MCP.Service`
- [x] `server/routes/instance/pty.ts`handlers yield `Pty.Service`
Additional route files that still participate in the migration:
Files still worth tracking here:
- [ ] `server/instance/index.ts`Vcs, Agent, Skill, LSP, Format
- [ ] `server/instance/file.ts`Ripgrep, File, LSP
- [ ] `server/instance/mcp.ts`MCP facade-heavy
- [ ] `server/instance/permission.ts`Permission
- [ ] `server/instance/workspace.ts` — Workspace
- [ ] `server/instance/tui.ts` — Bus and Session
- [ ] `server/instance/middleware.ts` — Session and Workspace lookups
- [ ] `server/routes/instance/session.ts`still the heaviest mixed file; many handlers are composed, but the file still mixes patterns and has direct `Bus.publish(...)` / `Session.list(...)` usage
- [ ] `server/routes/instance/index.ts`mostly converted, but still has direct `Instance.dispose()` / `Instance.*` reads for `/instance/dispose` and `/path`
- [ ] `server/routes/instance/file.ts`most handlers yield services, but `/find` still passes `Instance.directory` directly into ripgrep and `/find/symbol` is still stubbed
- [ ] `server/routes/instance/experimental.ts`mixed state; many handlers are composed, but some still rely on `runRequest(...)` or direct `Instance.project` reads
- [ ] `server/routes/instance/middleware.ts` — still enters the instance via `Instance.provide(...)`
- [ ] `server/routes/global.ts` — still uses `Instance.disposeAll()` and remains partly outside the fully-composed style
## Notes
- Some handlers already use `AppRuntime.runPromise(Effect.gen(...))` in isolated places. Keep pushing those files toward one consistent style.
- Route conversion is closely tied to facade removal. As services lose `makeRuntime`-backed async exports, route handlers should switch to yielding the service directly.
- Route conversion is now less about facade removal and more about removing the remaining direct `Instance.*` reads, `Instance.provide(...)` boundaries, and small Promise-style bridges inside route files.
- `jsonRequest(...)` / `runRequest(...)` already provide a good intermediate shape for many handlers. The remaining cleanup is mostly consistency work in the heavier files.
+252 -26
View File
@@ -1,12 +1,19 @@
# Schema migration
Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
Practical reference for migrating data types in `packages/opencode` from
Zod-first definitions to Effect Schema with Zod compatibility shims.
## Goal
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
Use Effect Schema as the source of truth for domain models, IDs, inputs,
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
compatibility boundaries by exposing a `.zod` static derived from the Effect
schema via `@/util/effect-zod`.
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
moves to `@effect/platform`, every Schema-first DTO can flow through
`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
`effect-zod` walker plus every `.zod` static can be deleted.
## Preferred shapes
@@ -24,17 +31,14 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
}
```
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
If the class cannot reference itself cleanly during initialization, use the
two-step `withStatics` pattern:
```ts
const _Info = Schema.Struct({
export const Info = Schema.Struct({
id: FooID,
name: Schema.String,
})
export const Info = Object.assign(_Info, {
zod: zod(_Info),
})
}).pipe(withStatics((s) => ({ zod: zod(s) })))
```
### Errors
@@ -49,27 +53,89 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
### IDs and branded leaf types
Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
Keep branded/schema-backed IDs as Effect schemas and expose
`static readonly zod` for compatibility when callers still expect Zod.
### Refinements
Reuse named refinements instead of re-spelling `z.number().int().positive()`
in every schema. The `effect-zod` walker translates the Effect versions into
the corresponding zod methods, so JSON Schema output (`type: integer`,
`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
```ts
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
```
See `test/util/effect-zod.test.ts` for the full set of translated checks.
## Compatibility rule
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
During migration, route validators, tool parameters, and any existing
Zod-based boundary should consume the derived `.zod` schema instead of
maintaining a second hand-written Zod schema.
The default should be:
- Effect Schema owns the type
- `.zod` exists only as a compatibility surface
- new domain models should not start Zod-first unless there is a concrete boundary-specific need
- new domain models should not start Zod-first unless there is a concrete
boundary-specific need
## When Zod can stay
It is fine to keep a Zod-native schema temporarily when:
- the type is only used at an HTTP or tool boundary
- the type is only used at an HTTP or tool boundary and is not reused elsewhere
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
- the migration would force unrelated churn across a large call graph
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
When this happens, prefer leaving a short note or TODO rather than silently
creating a parallel schema source of truth.
## Escape hatches
The walker in `@/util/effect-zod` exposes three explicit escape hatches for
cases the pure-Schema path cannot express. Each one stays in the codebase
only as long as its upstream or local dependency requires it — inline
comments document when each can be deleted.
### `ZodOverride` annotation
Replaces the entire derivation with a hand-crafted zod schema. Used when:
- the target carries external `$ref` metadata (e.g.
`config/model-id.ts` points at `https://models.dev/...`)
- the target is a zod-only schema that cannot yet be expressed as Schema
(e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`)
### `ZodPreprocess` annotation
Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by
`config/permission.ts` to inject `__originalKeys` before parsing, because
`Schema.StructWithRest` canonicalises output (known fields first, catchall
after) and destroys the user's original property order — which permission
rule precedence depends on.
Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder
(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and
the `__originalKeys` hack can both be deleted.
### Local `DeepMutable<T>` in `config/config.ts`
`Schema.Struct` produces `readonly` types. Some consumer code (notably the
`Config` service) mutates `Info` objects directly, so a readonly-stripping
utility is needed when casting the derived zod schema's output type.
`Types.DeepMutable` from effect-smol would be a drop-in, but it widens
`unknown` to `{}` in the fallback branch — a bug that affects any schema
using `Schema.Record(String, Schema.Unknown)`.
Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown
to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and
`Types.DeepMutable` used directly.
## Ordering
@@ -81,19 +147,179 @@ Migrate in this order:
4. Service-local internal models
5. Route and tool boundary validators that can switch to `.zod`
This keeps shared types canonical first and makes boundary updates mostly mechanical.
This keeps shared types canonical first and makes boundary updates mostly
mechanical.
## Checklist
## Progress tracker
- [ ] Shared `schema.ts` leaf models are Effect Schema-first
- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
- [ ] Domain errors use `Schema.TaggedErrorClass`
- [ ] Migrated types expose `.zod` for back compatibility
- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
- [ ] New domain models default to Effect Schema first
### `src/config/` ✅ complete
All of `packages/opencode/src/config/` has been migrated. Files that still
import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
type annotations — the `export const <Info|Spec>` values are all Effect
Schema at source.
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
- [x] server, layout
- [x] keybinds
- [x] permission#Info
- [x] agent
- [x] config.ts root
### `src/*/schema.ts` leaf modules
These are the highest-priority next targets. Each is a small, self-contained
schema module with a clear domain.
- [ ] `src/control-plane/schema.ts`
- [ ] `src/permission/schema.ts`
- [ ] `src/project/schema.ts`
- [ ] `src/provider/schema.ts`
- [ ] `src/pty/schema.ts`
- [ ] `src/question/schema.ts`
- [ ] `src/session/schema.ts`
- [ ] `src/sync/schema.ts`
- [ ] `src/tool/schema.ts`
### Session domain
Major cluster. Message + event types flow through the SSE API and every SDK
output, so byte-identical SDK surface is critical.
- [ ] `src/session/compaction.ts`
- [ ] `src/session/message-v2.ts`
- [ ] `src/session/message.ts`
- [ ] `src/session/prompt.ts`
- [ ] `src/session/revert.ts`
- [ ] `src/session/session.ts`
- [ ] `src/session/status.ts`
- [ ] `src/session/summary.ts`
- [ ] `src/session/todo.ts`
### Provider domain
- [ ] `src/provider/auth.ts`
- [ ] `src/provider/models.ts`
- [ ] `src/provider/provider.ts`
### Tool schemas
Each tool declares its parameters via a zod schema. Tools are consumed by
both the in-process runtime and the AI SDK's tool-calling layer, so the
emitted JSON Schema must stay byte-identical.
- [ ] `src/tool/apply_patch.ts`
- [ ] `src/tool/bash.ts`
- [ ] `src/tool/codesearch.ts`
- [ ] `src/tool/edit.ts`
- [ ] `src/tool/glob.ts`
- [ ] `src/tool/grep.ts`
- [ ] `src/tool/invalid.ts`
- [ ] `src/tool/lsp.ts`
- [ ] `src/tool/multiedit.ts`
- [ ] `src/tool/plan.ts`
- [ ] `src/tool/question.ts`
- [ ] `src/tool/read.ts`
- [ ] `src/tool/registry.ts`
- [ ] `src/tool/skill.ts`
- [ ] `src/tool/task.ts`
- [ ] `src/tool/todo.ts`
- [ ] `src/tool/tool.ts`
- [ ] `src/tool/webfetch.ts`
- [ ] `src/tool/websearch.ts`
- [ ] `src/tool/write.ts`
### HTTP route boundaries
Every file in `src/server/routes/` uses hono-openapi with zod validators for
route inputs/outputs. Migrating these individually is the last step; most
will switch to `.zod` derived from the Schema-migrated domain types above,
which means touching them is largely mechanical once the domain side is
done.
- [ ] `src/server/error.ts`
- [ ] `src/server/event.ts`
- [ ] `src/server/projectors.ts`
- [ ] `src/server/routes/control/index.ts`
- [ ] `src/server/routes/control/workspace.ts`
- [ ] `src/server/routes/global.ts`
- [ ] `src/server/routes/instance/index.ts`
- [ ] `src/server/routes/instance/config.ts`
- [ ] `src/server/routes/instance/event.ts`
- [ ] `src/server/routes/instance/experimental.ts`
- [ ] `src/server/routes/instance/file.ts`
- [ ] `src/server/routes/instance/mcp.ts`
- [ ] `src/server/routes/instance/permission.ts`
- [ ] `src/server/routes/instance/project.ts`
- [ ] `src/server/routes/instance/provider.ts`
- [ ] `src/server/routes/instance/pty.ts`
- [ ] `src/server/routes/instance/question.ts`
- [ ] `src/server/routes/instance/session.ts`
- [ ] `src/server/routes/instance/sync.ts`
- [ ] `src/server/routes/instance/tui.ts`
The bigger prize for this group is the `@effect/platform` HTTP migration
described in `specs/effect/http-api.md`. Once that lands, every one of
these files changes shape entirely (`HttpApi.endpoint(...)` and friends),
so the Schema-first domain types become a prerequisite rather than a
sibling task.
### Everything else
Small / shared / control-plane / CLI. Mostly independent; can be done
piecewise.
- [ ] `src/acp/agent.ts`
- [ ] `src/agent/agent.ts`
- [ ] `src/bus/bus-event.ts`
- [ ] `src/bus/index.ts`
- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
- [ ] `src/cli/cmd/tui/config/tui.ts`
- [ ] `src/cli/cmd/tui/event.ts`
- [ ] `src/cli/ui.ts`
- [ ] `src/command/index.ts`
- [ ] `src/control-plane/adaptors/worktree.ts`
- [ ] `src/control-plane/types.ts`
- [ ] `src/control-plane/workspace.ts`
- [ ] `src/file/index.ts`
- [ ] `src/file/ripgrep.ts`
- [ ] `src/file/watcher.ts`
- [ ] `src/format/index.ts`
- [ ] `src/id/id.ts`
- [ ] `src/ide/index.ts`
- [ ] `src/installation/index.ts`
- [ ] `src/lsp/client.ts`
- [ ] `src/lsp/lsp.ts`
- [ ] `src/mcp/auth.ts`
- [ ] `src/patch/index.ts`
- [ ] `src/plugin/github-copilot/models.ts`
- [ ] `src/project/project.ts`
- [ ] `src/project/vcs.ts`
- [ ] `src/pty/index.ts`
- [ ] `src/skill/index.ts`
- [ ] `src/snapshot/index.ts`
- [ ] `src/storage/db.ts`
- [ ] `src/storage/storage.ts`
- [ ] `src/sync/index.ts`
- [ ] `src/util/fn.ts`
- [ ] `src/util/log.ts`
- [ ] `src/util/update-schema.ts`
- [ ] `src/worktree/index.ts`
### Do-not-migrate
- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
(it's what emits zod from Schema). Goes away only when the `.zod`
compatibility layer is no longer needed anywhere.
## Notes
- Use `@/util/effect-zod` for all Schema -> Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
- Use `@/util/effect-zod` for all Schema Zod conversion.
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
Effect definitions for the same domain type.
- Keep the migration incremental. Converting the domain model first is more
valuable than converting every boundary in the same change.
- Every migrated file should leave the generated SDK output (`packages/sdk/
openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical
unless the change is deliberately user-visible.
@@ -40,13 +40,13 @@ Everything still lives in `packages/opencode`.
Important current facts:
- there is no `packages/core` or `packages/cli` workspace yet
- `packages/server` now exists as a minimal scaffold package, but it does not own any real route contracts, handlers, or runtime composition yet
- there is no `packages/server` workspace yet on this branch
- the main host server is still Hono-based in `src/server/server.ts`
- current OpenAPI generation is Hono-based through `Server.openapi()` and `cli/cmd/generate.ts`
- the Effect runtime and app layer are centralized in `src/effect/app-runtime.ts` and `src/effect/run-service.ts`
- there is already one experimental Effect `HttpApi` slice at `src/server/instance/httpapi/question.ts`
- that experimental slice is mounted under `/experimental/httpapi/question`
- that experimental slice already has an end-to-end test at `test/server/question-httpapi.test.ts`
- there are already bridged Effect `HttpApi` slices under `src/server/routes/instance/httpapi/*`
- those slices are mounted into the Hono server behind `KILO_EXPERIMENTAL_HTTPAPI`
- the bridge currently covers `question`, `permission`, `provider`, partial `config`, and partial `project` routes
This means the package split should start from an extraction path, not from greenfield package ownership.
@@ -209,17 +209,19 @@ Current host and route composition:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/index.ts`
- `src/server/routes/instance/index.ts`
- `src/server/middleware.ts`
- `src/server/adapter.bun.ts`
- `src/server/adapter.node.ts`
Current experimental `HttpApi` slice:
Current bridged `HttpApi` slices:
- `src/server/instance/httpapi/question.ts`
- `src/server/instance/httpapi/index.ts`
- `src/server/instance/experimental.ts`
- `test/server/question-httpapi.test.ts`
- `src/server/routes/instance/httpapi/question.ts`
- `src/server/routes/instance/httpapi/permission.ts`
- `src/server/routes/instance/httpapi/provider.ts`
- `src/server/routes/instance/httpapi/config.ts`
- `src/server/routes/instance/httpapi/project.ts`
- `src/server/routes/instance/httpapi/server.ts`
Current OpenAPI flow:
@@ -245,7 +247,7 @@ Keep in `packages/opencode` for now:
- `src/server/server.ts`
- `src/server/control/index.ts`
- `src/server/instance/*.ts`
- `src/server/routes/**/*.ts`
- `src/server/middleware.ts`
- `src/server/adapter.*.ts`
- `src/effect/app-runtime.ts`
@@ -305,14 +307,13 @@ Bad early migration targets:
## First vertical slice
The first slice for the package split is the existing experimental `question` group.
The first slice for the package split is still the existing `question` `HttpApi` group.
Why `question` first:
- it already exists as an experimental `HttpApi` slice
- it already follows the desired contract and implementation split in one file
- it is already mounted through the current Hono host
- it already has an end-to-end test
- it is JSON-only
- it has low blast radius
@@ -357,7 +358,7 @@ Done means:
Scope:
- extract the pure `HttpApi` contract from `src/server/instance/httpapi/question.ts`
- extract the pure `HttpApi` contract from `src/server/routes/instance/httpapi/question.ts`
- place it in `packages/server/src/definition/question.ts`
- aggregate it in `packages/server/src/definition/api.ts`
- generate OpenAPI in `packages/server/src/openapi.ts`
@@ -399,8 +400,9 @@ Scope:
- replace local experimental question route wiring in `packages/opencode`
- keep the same mount path:
- `/experimental/httpapi/question`
- `/experimental/httpapi/question/doc`
- `/question`
- `/question/:requestID/reply`
- `/question/:requestID/reject`
Rules:
@@ -569,7 +571,7 @@ For package-split PRs, validate the smallest useful thing.
Typical validation for the first waves:
- `bun typecheck` in the touched package directory or directories
- the relevant route test, especially `test/server/question-httpapi.test.ts`
- the relevant server / route coverage for the migrated slice
- merged OpenAPI coverage if the PR touches spec generation
Do not run tests from repo root.
+3 -5
View File
@@ -36,7 +36,7 @@ This keeps tool tests aligned with the production service graph and makes follow
## Exported tools
These exported tool definitions already exist in `src/tool` and are on the current Effect-native `Tool.define(...)` path:
These exported tool definitions currently use `Tool.define(...)` in `src/tool`:
- [x] `apply_patch.ts`
- [x] `bash.ts`
@@ -45,7 +45,6 @@ These exported tool definitions already exist in `src/tool` and are on the curre
- [x] `glob.ts`
- [x] `grep.ts`
- [x] `invalid.ts`
- [x] `ls.ts`
- [x] `lsp.ts`
- [x] `multiedit.ts`
- [x] `plan.ts`
@@ -60,7 +59,7 @@ These exported tool definitions already exist in `src/tool` and are on the curre
Notes:
- `batch.ts` is no longer a current tool file and should not be tracked here.
- There is no current `ls.ts` tool file on this branch.
- `truncate.ts` is an Effect service used by tools, not a tool definition itself.
- `mcp-exa.ts`, `external-directory.ts`, and `schema.ts` are support modules, not standalone tool definitions.
@@ -73,7 +72,7 @@ Current spot cleanups worth tracking:
- [ ] `read.ts` — still bridges to Node stream / `readline` helpers and Promise-based binary detection
- [ ] `bash.ts` — already uses Effect child-process primitives; only keep tracking shell-specific platform bridges and parser/loading details as they come up
- [ ] `webfetch.ts` — already uses `HttpClient`; remaining work is limited to smaller boundary helpers like HTML text extraction
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and `ls.ts`
- [ ] `file/ripgrep.ts` — adjacent to tool migration; still has raw fs/process usage that affects `grep.ts` and file-search routes
- [ ] `patch/index.ts` — adjacent to tool migration; still has raw fs usage behind patch application
Notable items that are already effectively on the target path and do not need separate migration bullets right now:
@@ -83,7 +82,6 @@ Notable items that are already effectively on the target path and do not need se
- `write.ts`
- `codesearch.ts`
- `websearch.ts`
- `ls.ts`
- `multiedit.ts`
- `edit.ts`
+13 -1
View File
@@ -31,14 +31,26 @@ export const GenerateCommand = {
]
}
}
const raw = JSON.stringify(specs, null, 2)
// kilocode_change start - replace upstream product name in all descriptions
const json = JSON.stringify(specs, null, 2)
.replaceAll("OpenCode", "Kilo")
.replaceAll("opencode.local", "kilo.local")
.replaceAll("opencode serve", "kilo serve")
.replaceAll("https://opencode.ai/", "https://kilo.ai/")
// kilocode_change end
// Format through prettier so output is byte-identical to committed file
// regardless of whether ./script/format.ts runs afterward.
const prettier = await import("prettier")
const babel = await import("prettier/plugins/babel")
const estree = await import("prettier/plugins/estree")
const format = prettier.format ?? prettier.default?.format
const json = await format(raw, {
parser: "json",
plugins: [babel.default ?? babel, estree.default ?? estree],
printWidth: 120,
})
// Wait for stdout to finish writing before process.exit() is called
await new Promise<void>((resolve, reject) => {
process.stdout.write(json, (err) => {
+1 -1
View File
@@ -624,7 +624,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
category: "System",
},
{
title: "Toggle theme mode",
title: mode() === "dark" ? "Switch to light mode" : "Switch to dark mode",
value: "theme.switch_mode",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
@@ -63,6 +63,7 @@ function init() {
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
if (evt.defaultPrevented) return
for (const option of entries()) {
if (!isEnabled(option)) continue
if (option.keybind && keybind.match(option.keybind, evt)) {
@@ -163,15 +163,10 @@ export function DialogSessionList() {
{desc}{" "}
<span
style={{
fg:
workspaceStatus === "error"
? theme.error
: workspaceStatus === "disconnected"
? theme.textMuted
: theme.success,
fg: workspaceStatus === "connected" ? theme.success : theme.error,
}}
>
</span>
</>
)
@@ -139,7 +139,13 @@ export async function restoreWorkspaceSession(input: {
total: result.data.total,
})
await Promise.all([input.project.workspace.sync(), input.sync.session.refresh()]).catch((err) => {
input.project.workspace.set(input.workspaceID)
try {
await input.sync.bootstrap({ fatal: false })
} catch (e) {}
await Promise.all([input.project.workspace.sync(), input.sync.session.sync(input.sessionID)]).catch((err) => {
log.error("session restore refresh failed", {
workspaceID: input.workspaceID,
sessionID: input.sessionID,
@@ -0,0 +1,81 @@
import { TextAttributes } from "@opentui/core"
import { useKeyboard } from "@opentui/solid"
import { createStore } from "solid-js/store"
import { For } from "solid-js"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
export function DialogWorkspaceUnavailable(props: { onRestore?: () => boolean | void | Promise<boolean | void> }) {
const dialog = useDialog()
const { theme } = useTheme()
const [store, setStore] = createStore({
active: "restore" as "cancel" | "restore",
})
const options = ["cancel", "restore"] as const
async function confirm() {
if (store.active === "cancel") {
dialog.clear()
return
}
const result = await props.onRestore?.()
if (result === false) return
}
useKeyboard((evt) => {
if (evt.name === "return") {
evt.preventDefault()
evt.stopPropagation()
void confirm()
return
}
if (evt.name === "left") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "cancel")
return
}
if (evt.name === "right") {
evt.preventDefault()
evt.stopPropagation()
setStore("active", "restore")
}
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Workspace Unavailable
</text>
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<text fg={theme.textMuted} wrapMode="word">
This session is attached to a workspace that is no longer available.
</text>
<text fg={theme.textMuted} wrapMode="word">
Would you like to restore this session into a new workspace?
</text>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1} gap={1}>
<For each={options}>
{(item) => (
<box
paddingLeft={2}
paddingRight={2}
backgroundColor={item === store.active ? theme.primary : undefined}
onMouseUp={() => {
setStore("active", item)
void confirm()
}}
>
<text fg={item === store.active ? theme.selectedListItemText : theme.textMuted}>{item}</text>
</box>
)}
</For>
</box>
</box>
)
}
@@ -52,6 +52,9 @@ export type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void
onCursorChange: () => void
// kilocode_change start - let the prompt close autocomplete without mutating draft text
dismiss: () => void
// kilocode_change end
visible: false | "@" | "/"
}
@@ -487,6 +490,14 @@ export function Autocomplete(props: {
})
}
// kilocode_change start - keep slash text intact when overlays hide the prompt,
// but still allow normal autocomplete dismissal to clean it up.
function dismiss() {
if (!store.visible) return
command.keybinds(true)
setStore("visible", false)
}
function hide() {
const text = props.input().plainText
if (store.visible === "/" && !text.endsWith(" ") && text.startsWith("/")) {
@@ -497,15 +508,20 @@ export function Autocomplete(props: {
draft.input = props.input().plainText
})
}
command.keybinds(true)
setStore("visible", false)
dismiss()
}
// kilocode_change end
onMount(() => {
props.ref({
get visible() {
return store.visible
},
// kilocode_change start
dismiss() {
dismiss()
},
// kilocode_change end
onInput(value) {
if (store.visible) {
if (
@@ -5,10 +5,11 @@ import path from "path"
import { fileURLToPath } from "url"
import { Filesystem } from "@/util"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { tint, useTheme } from "@tui/context/theme"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { useEvent } from "@tui/context/event"
import { MessageID, PartID } from "@/session/schema"
@@ -38,6 +39,8 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
export type PromptProps = {
@@ -92,6 +95,7 @@ export function Prompt(props: PromptProps) {
const args = useArgs()
const sdk = useSDK()
const route = useRoute()
const project = useProject()
const sync = useSync()
const dialog = useDialog()
const toast = useToast()
@@ -243,9 +247,11 @@ export function Prompt(props: PromptProps) {
keybind: "input_submit",
category: "Prompt",
hidden: true,
onSelect: (dialog) => {
onSelect: async (dialog) => {
if (!input.focused) return
void submit()
const handled = await submit()
if (!handled) return
dialog.clear()
},
},
@@ -462,22 +468,36 @@ export function Prompt(props: PromptProps) {
props.ref?.(undefined)
})
// kilocode_change start - close autocomplete while blocking overlays hide the prompt
createEffect(() => {
if (props.visible === false || props.disabled) {
auto()?.dismiss()
}
})
// kilocode_change end
createEffect(() => {
if (!input || input.isDestroyed) return
if (props.visible === false || dialog.stack.length > 0) {
input.blur()
if (input.focused) input.blur()
return
}
// Slot/plugin updates can remount the background prompt while a dialog is open.
// Keep focus with the dialog and let the prompt reclaim it after the dialog closes.
input.focus()
if (!input.focused) input.focus()
})
createEffect(() => {
if (!input || input.isDestroyed) return
const capture =
store.mode === "normal"
? auto()?.visible
? (["escape", "navigate", "submit", "tab"] as const)
: (["tab"] as const)
: undefined
input.traits = {
capture: auto()?.visible ? ["escape", "navigate", "submit", "tab"] : undefined,
capture,
suspend: !!props.disabled || store.mode === "shell",
status: store.mode === "shell" ? "SHELL" : undefined,
}
@@ -624,20 +644,48 @@ export function Prompt(props: PromptProps) {
setStore("prompt", "input", input.plainText)
syncExtmarksWithPromptParts()
}
if (props.disabled) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
if (props.disabled) return false
if (autocomplete?.visible) return false
if (!store.prompt.input) return false
const agent = local.agent.current()
if (!agent) return
if (!agent) return false
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
void exit()
return
return true
}
const selectedModel = local.model.current()
if (!selectedModel) {
void promptModelWarning()
return
return false
}
const workspaceSession = props.sessionID ? sync.session.get(props.sessionID) : undefined
const workspaceID = workspaceSession?.workspaceID
const workspaceStatus = workspaceID ? (project.workspace.status(workspaceID) ?? "error") : undefined
if (props.sessionID && workspaceID && workspaceStatus !== "connected") {
dialog.replace(() => (
<DialogWorkspaceUnavailable
onRestore={() => {
dialog.replace(() => (
<DialogWorkspaceCreate
onSelect={(nextWorkspaceID) =>
restoreWorkspaceSession({
dialog,
sdk,
sync,
project,
toast,
workspaceID: nextWorkspaceID,
sessionID: props.sessionID!,
})
}
/>
))
}}
/>
))
return false
}
let sessionID = props.sessionID
@@ -652,7 +700,7 @@ export function Prompt(props: PromptProps) {
variant: "error",
})
return
return true
}
sessionID = res.data.id
@@ -767,6 +815,7 @@ export function Prompt(props: PromptProps) {
})
}, 50)
input.clear()
return true
}
const exit = useExit()
@@ -873,6 +922,7 @@ export function Prompt(props: PromptProps) {
() => !!local.agent.current() && store.mode === "normal" && showVariant(),
animationsEnabled,
)
const borderHighlight = createMemo(() => tint(theme.border, highlight(), agentMetaAlpha()))
const placeholderText = createMemo(() => {
if (props.showPlaceholder === false) return undefined
@@ -934,7 +984,7 @@ export function Prompt(props: PromptProps) {
<box ref={(r) => (anchor = r)} visible={props.visible !== false}>
<box
border={["left"]}
borderColor={highlight()}
borderColor={borderHighlight()}
customBorderChars={{
...SplitBorder.customBorderChars,
bottomLeft: "╹",
@@ -1179,6 +1229,7 @@ export function Prompt(props: PromptProps) {
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text fg={fadeColor(theme.textMuted, modelMetaAlpha())}>·</text>
<text
flexShrink={0}
fg={fadeColor(keybind.leader ? theme.textMuted : theme.text, modelMetaAlpha())}
@@ -1212,7 +1263,7 @@ export function Prompt(props: PromptProps) {
height={1}
flexShrink={0} // kilocode_change - prevent border box from shrinking in narrow terminals (#6309)
border={["left"]}
borderColor={highlight()}
borderColor={borderHighlight()}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
@@ -31,7 +31,7 @@ export const TuiInfo = z
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
plugin: ConfigPlugin.Spec.array().optional(),
plugin: ConfigPlugin.Spec.zod.array().optional(),
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
})
.extend(TuiOptions.shape)
@@ -11,14 +11,14 @@ import { Flag } from "@/flag/flag"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Npm } from "@opencode-ai/shared/npm"
import { CurrentWorkingDirectory } from "./cwd"
import { ConfigPlugin } from "@/config/plugin"
import { ConfigKeybinds } from "@/config/keybinds"
import { InstallationLocal, InstallationVersion } from "@/installation/version"
import { makeRuntime } from "@/cli/effect/runtime"
import { makeRuntime } from "@/effect/runtime"
import { Filesystem, Log } from "@/util"
import { ConfigVariable } from "@/config/variable"
import { Npm } from "@/npm"
const log = Log.create({ service: "tui.config" })
@@ -163,7 +163,12 @@ export const layer = Layer.effect(
(dir) =>
npm
.install(dir, {
add: ["@kilocode/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
add: [
{
name: "@kilocode/plugin",
version: InstallationLocal ? undefined : InstallationVersion,
},
],
})
.pipe(Effect.forkScoped),
{
@@ -81,7 +81,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
move(direction: 1 | -1) {
batch(() => {
let next = agents().findIndex((x) => x.name === agentStore.current) + direction
const current = this.current()
if (!current) return
let next = agents().findIndex((x) => x.name === current.name) + direction
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
@@ -2,6 +2,7 @@ import { createKiloClient } from "@kilocode/sdk/v2"
import type { GlobalEvent } from "@kilocode/sdk/v2"
import { createSimpleContext } from "./helper"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { Flag } from "@/flag/flag"
import { batch, onCleanup, onMount } from "solid-js"
export type EventSource = {
@@ -39,6 +40,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
let queue: GlobalEvent[] = []
let timer: Timer | undefined
let last = 0
const retryDelay = 1000
const maxRetryDelay = 30000
const flush = () => {
if (queue.length === 0) return
@@ -73,9 +76,20 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
const ctrl = new AbortController()
sse = ctrl
;(async () => {
let attempt = 0
while (true) {
if (abort.signal.aborted || ctrl.signal.aborted) break
const events = await sdk.global.event({ signal: ctrl.signal })
const events = await sdk.global.event({
signal: ctrl.signal,
sseMaxRetryAttempts: 0,
})
if (Flag.KILO_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
for await (const event of events.stream) {
if (ctrl.signal.aborted) break
@@ -84,6 +98,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (timer) clearTimeout(timer)
if (queue.length > 0) flush()
attempt += 1
if (abort.signal.aborted || ctrl.signal.aborted) break
// Exponential backoff
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), maxRetryDelay)
await new Promise((resolve) => setTimeout(resolve, backoff))
}
})().catch(() => {})
}
@@ -92,6 +112,12 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
if (props.events) {
const unsub = await props.events.subscribe(handleEvent)
onCleanup(unsub)
if (Flag.KILO_EXPERIMENTAL_WORKSPACES) {
// Start syncing workspaces, it's important to do this after
// we've started listening to events
await sdk.sync.start().catch(() => {})
}
} else {
startSSE()
}
@@ -630,7 +630,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return store.status
},
get ready() {
return true
// return true // kilocode_change - upstream #23037 left this debug path enabled; keep it commented so future merges do not restore eager ready state.
if (process.env.KILO_FAST_BOOT) return true
return store.status !== "loading"
},
+1 -1
View File
@@ -1,6 +1,6 @@
import { Layer } from "effect"
import { TuiConfig } from "./config/tui"
import { Npm } from "@opencode-ai/shared/npm"
import { Npm } from "@/npm"
import { Observability } from "@/effect/observability"
export const CliLayer = Observability.layer.pipe(Layer.merge(TuiConfig.layer), Layer.provide(Npm.defaultLayer))
@@ -5,6 +5,7 @@ import HomeNews from "@/kilocode/plugins/home-news"
import HomeOnboarding from "@/kilocode/plugins/home-onboarding"
import KiloHomeFooter from "@/kilocode/plugins/home-footer"
import KiloSidebarFooter from "@/kilocode/plugins/sidebar-footer"
import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr"
import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage"
// kilocode_change end
import SidebarContext from "../feature-plugins/sidebar/context"
@@ -26,6 +27,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
HomeOnboarding, // kilocode_change
KiloHomeFooter, // kilocode_change
KiloSidebarFooter, // kilocode_change
KiloSidebarPr, // kilocode_change
KiloSidebarUsage, // kilocode_change
HomeFooter,
HomeTips,
@@ -1,5 +1,6 @@
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@kilocode/plugin/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import { children, mergeProps } from "solid-js" // kilocode_change
import { isRecord } from "@/util/record"
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
@@ -21,7 +22,20 @@ function empty<Name extends string>(_props: TuiSlotProps<Name>) {
let view: Slot = empty
export const Slot: Slot = (props) => view(props)
// kilocode_change start - stabilize fallback children so replace-mode slots
// don't recreate stateful defaults like the session prompt on prop changes.
// mergeProps (instead of spread) preserves SolidJS prop reactivity so things
// like ref, visible, disabled, on_submit keep flowing through to the slot.
export const Slot = <Name extends string>(props: TuiSlotProps<Name>) => {
const value = children(() => props.children)
const merged = mergeProps(props, {
get children() {
return value()
},
})
return view(merged as TuiSlotProps<Name>)
}
// kilocode_change end
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
if (!isRecord(value)) return false
@@ -5,11 +5,11 @@ import type { TextPart } from "@kilocode/sdk/v2"
import { Locale } from "@/util"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useDialog } from "../../ui/dialog"
import { useDialog, type DialogContext } from "../../ui/dialog"
import type { PromptInfo } from "@tui/component/prompt/history"
import { strip } from "@tui/component/prompt/part"
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
const sync = useSync()
const dialog = useDialog()
const sdk = useSDK()
@@ -19,9 +19,21 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
dialog.setSize("large")
})
const options = createMemo((): DialogSelectOption<string>[] => {
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
const messages = sync.data.message[props.sessionID] ?? []
const result = [] as DialogSelectOption<string>[]
const fullSession = {
title: "Full session",
value: undefined,
onSelect: async (dialog: DialogContext) => {
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
route.navigate({
sessionID: forked.data!.id,
type: "session",
})
dialog.clear()
},
} satisfies DialogSelectOption<string | undefined>
const result = [] as DialogSelectOption<string | undefined>[]
for (const message of messages) {
if (message.role !== "user") continue
const part = (sync.data.part[message.id] ?? []).find(
@@ -57,9 +69,8 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
},
})
}
result.reverse()
return result
return [fullSession, ...result.reverse()]
})
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
}
@@ -535,7 +535,7 @@ export function Session() {
},
},
{
title: "Fork from message",
title: "Fork session",
value: "session.fork",
keybind: "session_fork",
category: "Session",
@@ -546,6 +546,7 @@ export function Session() {
dialog.replace(() => (
<DialogForkFromTimeline
onMove={(messageID) => {
if (!messageID) return
const child = scroll.getChildren().find((child) => {
return child.id === messageID
})
@@ -1277,7 +1278,8 @@ export function Session() {
<NetworkPrompt request={network()[0]} />
</Show>
{/* kilocode_change end */}
<Show when={visible()}>
{/* kilocode_change start */}
<Show when={!session()?.parentID}>
<TuiPluginRuntime.Slot
name="session_prompt"
mode="replace"
@@ -1299,6 +1301,7 @@ export function Session() {
/>
</TuiPluginRuntime.Slot>
</Show>
{/* kilocode_change end */}
</box>
</Show>
<Toast />
@@ -1,17 +1,31 @@
import { useProject } from "@tui/context/project"
import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { InstallationVersion } from "@/installation/version"
import { InstallationChannel, InstallationVersion } from "@/installation/version"
import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const project = useProject()
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const workspaceStatus = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "error"
return project.workspace.status(workspaceID) ?? "error"
}
const workspaceLabel = () => {
const workspaceID = session()?.workspaceID
if (!workspaceID) return "unknown"
const info = project.workspace.get(workspaceID)
if (!info) return "unknown"
return `${info.type}: ${info.name}`
}
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
@@ -48,6 +62,15 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<text fg={theme.text}>
<b>{session()!.title}</b>
</text>
<Show when={InstallationChannel !== "latest"}>
<text fg={theme.textMuted}>{props.sessionID}</text>
</Show>
<Show when={session()!.workspaceID}>
<text fg={theme.textMuted}>
<span style={{ fg: workspaceStatus() === "connected" ? theme.success : theme.error }}></span>{" "}
{workspaceLabel()}
</text>
</Show>
<Show when={session()!.share?.url}>
<text fg={theme.textMuted}>{session()!.share!.url}</text>
</Show>

Some files were not shown because too many files have changed in this diff Show More