Merge branch 'main' into fix/fable-model-usage

This commit is contained in:
Christiaan Arnoldus
2026-07-06 15:26:19 +02:00
committed by GitHub
74 changed files with 1852 additions and 120 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-console": patch
---
Replace the hardcoded "K" letter in the Kilo Console navbar with the real Kilo logo, and drop the redundant "Kilo" wordmark since the logo already carries the name.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-web-ui": patch
---
Show trailing external-link icons for Kilo Console profile links that open in a new tab.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix Amazon Bedrock models returning no output. A smithy dependency version-skew made the Bedrock event-stream decoder silently fail under the browser build condition, so every Bedrock request completed with an empty response.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Require authentication before enabling allow-everything permissions over HTTP.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-web-ui": patch
---
Use the same font stack as Kilo Cloud (Inter variable for sans, Roboto Mono variable for mono, JetBrains Mono variable as the alt-mono token) in Kilo Console. Fonts are now self-hosted as woff2 in `@kilocode/kilo-web-ui`, so Inter no longer relies on the OS having it installed.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": minor
---
Support provider-aware model discovery and selection for remote Cloud sessions.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Show live session spend in the TUI sidebar while an assistant turn is still running.
+30 -29
View File
@@ -28,7 +28,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "7.4.0",
"version": "7.4.1",
"bin": {
"opencode": "./bin/opencode",
},
@@ -93,7 +93,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -107,7 +107,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
@@ -120,7 +120,7 @@
},
"packages/kilo-console": {
"name": "@kilocode/kilo-console",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@kilocode/kilo-web-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
@@ -142,7 +142,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -172,7 +172,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
@@ -208,7 +208,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.4.0",
"version": "7.4.1",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -218,7 +218,7 @@
},
"packages/kilo-indexing": {
"name": "@kilocode/kilo-indexing",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
"@aws-sdk/credential-provider-ini": "3.972.31",
@@ -250,11 +250,11 @@
},
"packages/kilo-jetbrains": {
"name": "@kilocode/kilo-jetbrains",
"version": "7.4.0",
"version": "7.4.1",
},
"packages/kilo-memory": {
"name": "@kilocode/kilo-memory",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"effect": "catalog:",
"zod": "catalog:",
@@ -268,7 +268,7 @@
},
"packages/kilo-sandbox": {
"name": "@kilocode/sandbox",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"effect": "catalog:",
},
@@ -281,7 +281,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"posthog-node": "4.4.0",
@@ -295,7 +295,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "0.13.11",
@@ -332,7 +332,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-gateway": "workspace:*",
@@ -400,7 +400,7 @@
},
"packages/kilo-web-ui": {
"name": "@kilocode/kilo-web-ui",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@kilocode/kilo-ui": "workspace:*",
"@kobalte/core": "catalog:",
@@ -417,7 +417,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -435,7 +435,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.4.0",
"version": "7.4.1",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -585,7 +585,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"effect": "catalog:",
@@ -613,7 +613,7 @@
},
"packages/plugin-atomic-chat": {
"name": "@kilocode/plugin-atomic-chat",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@kilocode/plugin": "workspace:*",
},
@@ -627,7 +627,7 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"semver": "^7.6.3",
},
@@ -638,7 +638,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -653,7 +653,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.4.0",
"version": "7.4.1",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -676,7 +676,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -737,6 +737,7 @@
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@smithy/util-buffer-from": "4.2.2",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@xmldom/xmldom": ">=0.8.12",
@@ -1896,7 +1897,7 @@
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-jUH1Eth7Sgn4KPBX5OKYDRpNjzul7AzsIhxKXT1rHXPTSfY00/7Kb9RtNil5SDAlPPsxaUiesR/rql2wjackmw=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.4.5", "", { "dependencies": { "@smithy/core": "^3.29.0", "tslib": "^2.6.2" } }, "sha512-OTu6rqRA5jcUDVOf2atiMdh+UmZ8GB2gzSdZPElZJAG0hS8lqak0jlvTcrmaR/FNIlP68r5EDUZ+cq1ZLKjq6g=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-nfpYCrzSFAgfIXmIHFTjOGNeTV3DVF5E5rfi3ZuNfsOjKSpePBOJF3rjyXlWYND0anvxVoqioIwClWCNdKt4Og=="],
@@ -1932,7 +1933,7 @@
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-T15zTQJ/xKYdS0/3CFckhz1QBbhxmhk/xjL6FKvHKgkJPN4E985If2FI9CcV2kh2v0sfiWMfXVEOKFbqgw4m4w=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.3.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-sms/ty2CJwHOiGzEaAVWizTVK5KusXpAYqCUeXIa+hWtNKLwjimH4z11mc07d0Fe3DT3lmZJIZWOMcVQ/N4hBQ=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.4.6", "", { "dependencies": { "@smithy/core": "^3.24.6", "tslib": "^2.6.2" } }, "sha512-dRCZKu05AL7KQWrVuRJPotfjCRnvGkCjV56XNP067CRfyTtvgi/Ygu44qrBKb814Hsa52bWwDJ+Vt3pd04BjPA=="],
@@ -4580,6 +4581,8 @@
"@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="],
"@smithy/is-array-buffer/@smithy/core": ["@smithy/core@3.29.0", "", { "dependencies": { "@smithy/types": "^4.15.1", "tslib": "^2.6.2" } }, "sha512-sEvpvkBVoMxjoek35XyJFn2ZD3EJ1RpiZrT47WaZodxzAIWS44zkdvbqGE/ZlugtjiQp62cffYZ9ldyRkjAGnA=="],
"@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="],
"@storybook/addon-links/storybook": ["storybook@10.4.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.18.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-Nn0qFRxU5fyABa6dGRftfL3lz0Y+HkKOaAkfytF8S4Q2K6Szwwq7TwPAEs3Wsj8hBQbYhsobrKADcPsyXQpJaA=="],
@@ -4952,10 +4955,6 @@
"yargs-unparser/decamelize": ["decamelize@4.0.0", "", {}, "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
@@ -5056,6 +5055,8 @@
"@opencode-ai/ui/@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="],
"@smithy/is-array-buffer/@smithy/core/@smithy/types": ["@smithy/types@4.15.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q=="],
"@storybook/addon-links/storybook/@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=="],
"@storybook/addon-links/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-rYgS/DlyYz9Ll/3tJyNqNp7HhYlCoHQKMb6Z/youhXw=",
"aarch64-linux": "sha256-wLk3pF1djAH8zbvZSwPCH0CfnBwfAUwbUmZCAPNY14I=",
"aarch64-darwin": "sha256-M0uYr/AnDhaYNaIw4Lf7clvCS1fabALCbxC707BjKR4=",
"x86_64-darwin": "sha256-Rhjti+v4Of4fFoT3V85dYAla1T5T7+o4W8eTToPrN08="
"x86_64-linux": "sha256-i3iC6Qdy5cSdf2bdlsG0mVj6qwNtX8uwzefQzjYRHAM=",
"aarch64-linux": "sha256-zLNv19XTmuUmsUEIRcsp7e2GY3NQ6ciuAK4hGLNeLyw=",
"aarch64-darwin": "sha256-y40/d5iYs+9aeyXPOfO2+fAdasy3kc71h5d7jDB0rXM=",
"x86_64-darwin": "sha256-x81IQWmDmbAH3m47jYnBrBm9FxDJhpX3IhIN+RAT8Ro="
}
}
+3 -2
View File
@@ -140,7 +140,8 @@
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"solid-js": "catalog:",
"@opentui/keymap": "catalog:"
"@opentui/keymap": "catalog:",
"@smithy/util-buffer-from": "4.2.2"
},
"patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
@@ -149,6 +150,6 @@
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
"version": "7.4.0",
"version": "7.4.1",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.0",
"version": "7.4.1",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.0",
"version": "7.4.1",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.4.0"
version = "7.4.1"
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.4.0/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/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.4.0/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.0/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/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.4.0/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/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.4.0/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.1/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.0",
"version": "7.4.1",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-console",
"version": "7.4.0",
"version": "7.4.1",
"private": true,
"type": "module",
"scripts": {
@@ -0,0 +1,4 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M512 0H0V512H512V0Z" fill="black"/>
<path d="M322 377H377V421H307.857L278 391.143V322H322V377ZM421 307.857L391.143 278H322V322L377 322V377H421V307.857ZM234 278H190V322H234V278ZM91 391.143L120.857 421H234V377H135V278H91V391.143ZM371.172 189.999V120.856L341.315 90.9995H278V135H327.172V189.999H278V233.999H421V189.999H371.172ZM135 91H91V233.999H135V184.5H190V233.999H234V184.5L190 140.5H135V91ZM234 91H190V140.5H234V91Z" fill="#FAF74F"/>
</svg>

After

Width:  |  Height:  |  Size: 552 B

@@ -1,18 +1,16 @@
import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { A } from "@solidjs/router"
import { OmniSearch } from "./OmniSearch"
export function AppHeader() {
return (
<header class="app-header">
<a class="header-brand" href="/projects" aria-label="Kilo Console home">
<span class="header-mark" aria-hidden="true">
K
</span>
<A class="header-brand" href="/projects" aria-label="Kilo Console home">
<img class="header-mark header-mark-logo" src={`${import.meta.env.BASE_URL}kilo-logo.svg`} alt="" width="28" height="28" />
<span class="header-title">
<strong>Kilo</strong>
<span>Console</span>
</span>
</a>
</A>
<OmniSearch />
@@ -1,6 +1,7 @@
import { A, useLocation, useNavigate } from "@solidjs/router"
import { Button } from "@kilocode/kilo-web-ui/button"
import { Card } from "@kilocode/kilo-web-ui/card"
import { Icon } from "@kilocode/kilo-web-ui/icon"
import { createEffect, createMemo, createResource, createSignal, For, Show } from "solid-js"
import { LoadingScreen } from "../../components/LoadingScreen"
import { loadKiloProfile, logoutKilo, setKiloOrganization, type KiloProfileData, type ProjectQuery } from "../../client"
@@ -137,11 +138,13 @@ export function ProfileRoute() {
<A class="config-top-option active" href={overview()} aria-current="page">
<span>Overview</span>
</A>
<a class="config-top-option" href={usageUrl()} target="_blank" rel="noreferrer">
<a class="config-top-option config-top-option-external" href={usageUrl()} target="_blank" rel="noreferrer">
<span>Usage</span>
<Icon name="square-arrow-top-right" size="small" />
</a>
<a class="config-top-option" href={creditsUrl()} target="_blank" rel="noreferrer">
<a class="config-top-option config-top-option-external" href={creditsUrl()} target="_blank" rel="noreferrer">
<span>Buy Credits</span>
<Icon name="square-arrow-top-right" size="small" />
</a>
</nav>
</aside>
@@ -169,6 +172,7 @@ export function ProfileRoute() {
rel="noreferrer"
>
Open Dashboard
<Icon name="square-arrow-top-right" size="small" />
</a>
</div>
</header>
+11 -1
View File
@@ -1,9 +1,19 @@
:root {
font-family: var(--font-family-sans, Inter, system-ui, sans-serif);
font-family: var(--font-family-sans);
background: var(--background-base);
color: var(--text-base);
}
.kilo-console .header-mark-logo {
width: 1.75rem;
height: 1.75rem;
background: transparent;
color: inherit;
font-size: inherit;
font-weight: inherit;
letter-spacing: inherit;
}
* {
box-sizing: border-box;
}
+5 -1
View File
@@ -60,6 +60,10 @@
text-decoration: none;
}
.kilo-console .profile-primary-link[data-component="button"][data-variant="primary"] [data-slot="icon-svg"] {
color: var(--primary-foreground);
}
.kilo-console .profile-connect-card {
display: grid;
min-height: 18rem;
@@ -420,7 +424,7 @@
.kilo-console .profile-login-code small {
color: var(--muted-foreground);
font-family: var(--font-family-sans, system-ui, sans-serif);
font-family: var(--font-family-sans);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0;
@@ -118,7 +118,7 @@
}
.kilo-console .default-model-value strong {
font-family: var(--font-family-sans, Inter, system-ui, sans-serif);
font-family: var(--font-family-sans);
}
.kilo-console .default-model-value .default-model-id {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.4.0",
"version": "7.4.1",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -10,6 +10,12 @@ Kilo Code supports running models locally using Ollama. This provides privacy, o
**Website:** [https://ollama.com/](https://ollama.com/)
{% callout type="info" title="Local Ollama vs. Ollama Cloud" %}
Use the **Ollama** provider for a local Ollama daemon started with `ollama serve`. Local models use the `ollama/<model_name>` format, for example `ollama/qwen3-coder:30b`, and connect to your local base URL.
**Ollama Cloud** appears in Kilo Gateway and BYOK flows. It is a hosted provider path and does not connect to `http://localhost:11434`.
{% /callout %}
<!-- <image src="/docs/img/providers/ollama-devstral-snake.png" alt="Vibe coding a Snake game using devstral" width="500" />
*Vibe coding a Snake game using devstral* -->
@@ -68,7 +74,7 @@ You need to have at least 32k to get decent results, but increasing the context
To configure the context window, set "Context Window Size (num_ctx)" in the API Provider settings.
### Configure the Timout
### Configure the Timeout
By default, API requests time out after 10 minutes. Local models can be slow, if you hit this timeout you can consider increasing it here: VS Code Extensions panel > Kilo Code gear menu > Settings > API Request Timeout.
@@ -86,6 +92,8 @@ The extension stores this in your `kilo.json` config file. You can also edit the
Ollama runs locally, so no API key is needed. Configure the base URL if Ollama is running on a different host:
Use `ollama/<model_name>` for the selected model. If you see `ollama-cloud/...`, you are using the hosted Kilo Gateway/BYOK provider instead of your local Ollama server.
**Config file** (`~/.config/kilo/kilo.json` or `./kilo.json`):
```jsonc
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.4.0",
"version": "7.4.1",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
@@ -1,5 +1,6 @@
import { z } from "zod"
import { KILO_API_BASE } from "./constants.js"
import { getDefaultHeaders, buildKiloHeaders } from "../headers.js"
/**
* Kilo notification schema
@@ -44,8 +45,9 @@ export async function fetchKilocodeNotifications(options: {
try {
const response = await fetch(url, {
headers: {
...getDefaultHeaders(),
...buildKiloHeaders(undefined, { kilocodeOrganizationId: options.kilocodeOrganizationId }),
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
signal: AbortSignal.timeout(NOTIFICATIONS_TIMEOUT_MS),
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.4.0",
"version": "7.4.1",
"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-indexing",
"version": "7.4.0",
"version": "7.4.1",
"type": "module",
"license": "MIT",
"description": "Standalone indexing engine and host helpers for Kilo Code",
+1 -1
View File
@@ -8,7 +8,7 @@
"test": "./gradlew test",
"test:ci": "bun script/test-ci.ts"
},
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {},
"devDependencies": {},
"peerDependencies": {}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-memory",
"version": "7.4.0",
"version": "7.4.1",
"type": "module",
"license": "MIT",
"description": "Project memory storage, indexing, recall, and command helpers for Kilo Code",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/sandbox",
"version": "7.4.0",
"version": "7.4.1",
"type": "module",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.4.0",
"version": "7.4.1",
"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.4.0",
"version": "7.4.1",
"type": "module",
"license": "MIT",
"exports": {
+10
View File
@@ -1,5 +1,15 @@
# kilo-code
## 7.4.1
### Patch Changes
- [#11918](https://github.com/Kilo-Org/kilocode/pull/11918) [`4e4e41f`](https://github.com/Kilo-Org/kilocode/commit/4e4e41f6ce29b59730f3b66262bf4c6b13b7c80b) Thanks [@mjnaderi](https://github.com/mjnaderi)! - Support bidirectional text in the prompt input.
- [#11926](https://github.com/Kilo-Org/kilocode/pull/11926) [`e79b751`](https://github.com/Kilo-Org/kilocode/commit/e79b75126c3da34a6aa6ac83cbf24672497c0910) Thanks [@trim21](https://github.com/trim21)! - Fix missing kilo-sandbox-mutation-worker.js in VS Code extension bundle, which caused sandboxed file operations to fail with "Module not found".
- [#11924](https://github.com/Kilo-Org/kilocode/pull/11924) [`1755f56`](https://github.com/Kilo-Org/kilocode/commit/1755f567fc8cbf4e8b950a3f862ff4e72ab77d70) - Fix the reasoning-variant (and mode) picker in the New Worktree dialog so selecting a variant actually applies. The pickers portaled their popover to the page body, where the dialog's modal overlay intercepted pointer events and swallowed the click before the option handler ran. Render the popovers inline (`portal={false}`), matching the model picker already fixed for the same reason.
## 7.4.0
### 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.4.0",
"version": "7.4.1",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
+6 -1
View File
@@ -2,7 +2,11 @@
import { $ } from "bun"
import { join } from "node:path"
import { existsSync, mkdirSync, rmSync, chmodSync } from "node:fs"
import { copySandboxResources, copyTreeSitterResources } from "../src/services/cli-backend/cli-resources"
import {
copyKiloSandboxWorker,
copySandboxResources,
copyTreeSitterResources,
} from "../src/services/cli-backend/cli-resources"
import { ensureFfmpegForTarget } from "./ffmpeg-helper"
const packageJsonPath = join(import.meta.dir, "..", "package.json")
@@ -78,6 +82,7 @@ for (const config of targets) {
await $`cp ${sourceBinary} ${targetBinary}`
await copyTreeSitterResources(sourceBinary, targetBinary)
await copySandboxResources(sourceBinary, targetBinary)
await copyKiloSandboxWorker(sourceBinary, targetBinary)
if (config.binary !== "kilo.exe") {
chmodSync(targetBinary, 0o755)
@@ -142,6 +142,7 @@ export class ServerManager {
KILO_MACHINE_ID: vscode.env.machineId,
KILO_APP_VERSION: this.context.extension.packageJSON.version,
KILO_VSCODE_VERSION: vscode.version,
KILOCODE_VERSION: this.context.extension.packageJSON.version,
KILOCODE_EDITOR_NAME: `${vscode.env.appName} ${vscode.version}`,
...(!claudeCompat && { KILO_DISABLE_CLAUDE_CODE: "true" }),
...resolveTreeSitterEnv(this.context.extensionPath),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"type": "module",
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {},
"devDependencies": {},
"peerDependencies": {}
@@ -0,0 +1,17 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "chat", "PromptInput.tsx")
const src = readFileSync(path, "utf8")
describe("PromptInput bidirectional text support", () => {
it("lets the textarea and visible overlay resolve text direction automatically", () => {
const overlay = src.match(/<div class="prompt-input-highlight-overlay"[\s\S]*?>/)?.[0]
const input = src.match(/<textarea[\s\S]*?\n\s*\/>/)?.[0]
expect(overlay).toContain('dir="auto"')
expect(input).toContain('class="prompt-input"')
expect(input).toContain('dir="auto"')
})
})
@@ -5,6 +5,34 @@ import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/type
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
function textarea(value: string, cursor: number, dir: "ltr" | "rtl") {
const state = { cursor }
return {
value,
get selectionStart() {
return state.cursor
},
get selectionEnd() {
return state.cursor
},
matches: (selector: string) => selector === `:dir(${dir})`,
setSelectionRange: (start: number) => {
state.cursor = start
},
} as unknown as HTMLTextAreaElement
}
function key(key: "ArrowLeft" | "ArrowRight") {
const state = { prevented: 0 }
return {
state,
event: {
key,
preventDefault: () => state.prevented++,
} as unknown as KeyboardEvent,
}
}
describe("useFileMention", () => {
it("keeps previous file results visible while the next search is pending", async () => {
const posted: WebviewMessage[] = []
@@ -186,4 +214,62 @@ describe("useFileMention", () => {
dispose.fn?.()
})
it("keeps mention block arrow navigation aligned with left-to-right prompt direction", async () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
const text = "See @src/main.ts now"
mention.addPaths(["src/main.ts"], "/repo")
const right = key("ArrowRight")
const input = textarea(text, "See ".length, "ltr")
expect(mention.handleArrowKey(right.event, input)).toBe(true)
expect(input.selectionStart).toBe("See @src/main.ts".length)
expect(right.state.prevented).toBe(1)
const left = key("ArrowLeft")
expect(mention.handleArrowKey(left.event, input)).toBe(true)
expect(input.selectionStart).toBe("See ".length)
expect(left.state.prevented).toBe(1)
dispose.fn?.()
})
it("keeps mention block arrow navigation aligned for right-to-left languages", async () => {
const ctx = {
postMessage: () => {},
onMessage: () => () => {},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
const text = "فایل @src/main.ts را ببین"
mention.addPaths(["src/main.ts"], "/repo")
const left = key("ArrowLeft")
const input = textarea(text, "فایل ".length, "rtl")
expect(mention.handleArrowKey(left.event, input)).toBe(true)
expect(input.selectionStart).toBe("فایل @src/main.ts".length)
expect(left.state.prevented).toBe(1)
const right = key("ArrowRight")
expect(mention.handleArrowKey(right.event, input)).toBe(true)
expect(input.selectionStart).toBe("فایل ".length)
expect(right.state.prevented).toBe(1)
dispose.fn?.()
})
})
@@ -474,7 +474,13 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
<div class="prompt-input-hint">
<div class="prompt-input-hint-selectors">
<Show when={session.agents().length > 1}>
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} deferDismiss />
<ModeSwitcherBase
agents={session.agents()}
value={agent()}
onSelect={setAgent}
portal={false}
deferDismiss
/>
</Show>
<Show when={!compareMode()}>
<ModelSelectorBase
@@ -490,6 +496,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
variants={variants()}
value={effectiveVariant()}
onSelect={setVariant}
portal={false}
deferDismiss
/>
<Show when={overridden()}>
@@ -1186,7 +1186,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
<div class="prompt-input-wrapper">
<div class="prompt-input-ghost-wrapper">
<div class="prompt-input-highlight-overlay" ref={highlightRef} aria-hidden="true">
<div class="prompt-input-highlight-overlay" ref={highlightRef} aria-hidden="true" dir="auto">
<Index each={buildHighlightSegments(text(), highlightMentions())}>
{(seg) => (
<Show when={seg().highlight} fallback={<span>{seg().text}</span>}>
@@ -1236,6 +1236,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
aria-disabled={isDisabled()}
aria-describedby={props.blockedReason?.() ? blockedHelpId() : undefined}
rows={1}
dir="auto"
/>
</div>
</div>
@@ -35,6 +35,8 @@ export interface ModeSwitcherBaseProps {
value: string
/** Called when the user picks an agent */
onSelect: (name: string) => void
/** Render inline instead of through a portal when nested in a dialog. */
portal?: boolean
/** Delay outside dismissal while the popover opens inside a dialog. */
deferDismiss?: boolean
}
@@ -124,6 +126,7 @@ export const ModeSwitcherBase: Component<ModeSwitcherBaseProps> = (props) => {
expanded={false}
placement="top-start"
minHeight={100}
portal={props.portal}
deferDismiss={props.deferDismiss}
open={open()}
onOpenChange={onOpen}
@@ -32,6 +32,8 @@ export interface ThinkingSelectorBaseProps {
clearLabel?: string
/** Popover placement — defaults to top-start. */
placement?: "top-start" | "bottom-start" | "bottom-end" | "top-end"
/** Render inline instead of through a portal when nested in a dialog. */
portal?: boolean
/** Delay outside dismissal while the popover opens inside a dialog. */
deferDismiss?: boolean
/** Listen for the global prompt trigger event. Defaults to true. */
@@ -138,6 +140,7 @@ export const ThinkingSelectorBase: Component<ThinkingSelectorBaseProps> = (props
placement={props.placement ?? "top-start"}
preferredWidth={180}
minHeight={100}
portal={props.portal}
deferDismiss={props.deferDismiss}
open={open()}
onOpenChange={onOpen}
@@ -15,6 +15,12 @@ import {
const FILE_SEARCH_DEBOUNCE_MS = 150
const rtl = (textarea: HTMLTextAreaElement): boolean => {
if (textarea.matches(":dir(rtl)")) return true
if (textarea.matches(":dir(ltr)")) return false
return getComputedStyle(textarea).direction === "rtl"
}
interface VSCodeContext {
postMessage: (message: WebviewMessage) => void
onMessage: (handler: (message: ExtensionMessage) => void) => () => void
@@ -280,13 +286,14 @@ export function useFileMention(
// Only when there's no active selection
if (textarea.selectionStart !== textarea.selectionEnd) return false
const forward = e.key === (rtl(textarea) ? "ArrowLeft" : "ArrowRight")
// Check where the cursor WOULD land after the native move
const next = e.key === "ArrowRight" ? cursor + 1 : cursor - 1
const next = forward ? cursor + 1 : cursor - 1
const range = findMentionRange(textarea.value, next, mentionedPaths())
if (!range) return false
e.preventDefault()
const pos = e.key === "ArrowRight" ? range.end : range.start
const pos = forward ? range.end : range.start
textarea.setSelectionRange(pos, pos)
return true
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-web-ui",
"version": "7.4.0",
"version": "7.4.1",
"type": "module",
"license": "MIT",
"exports": {
@@ -0,0 +1,14 @@
# Font attribution
Self-hosted variable font files in this directory are licensed under the
[SIL Open Font License, version 1.1](https://scripts.sil.org/OFL).
| Family | Source | OFL copyright |
|---|---|---|
| Inter | https://github.com/rsms/inter | Copyright (c) 2016-2024 The Inter Project Authors |
| Roboto Mono | https://github.com/google/fonts/tree/main/ofl/robotomono | Copyright 2015 The Roboto Mono Project Authors |
| JetBrains Mono | https://github.com/google/fonts/tree/main/ofl/jetbrainsmono | Copyright 2020 The JetBrains Mono Project Authors |
The full OFL-1.1 license text is at `https://scripts.sil.org/OFL`. Redistribution
is permitted provided the font files remain under the OFL and this attribution
is preserved.
@@ -596,6 +596,18 @@
display: none;
}
.kilo-console .config-top-option-external {
justify-content: space-between;
gap: 0.5rem;
}
.kilo-console .config-top-option-external [data-component="icon"] {
display: inline-flex;
align-items: center;
color: var(--text-weaker);
flex: none;
}
.kilo-console .config-group-items {
display: flex;
flex-direction: column;
+34 -2
View File
@@ -1,3 +1,32 @@
@font-face {
font-family: "Inter Variable";
src: url("@kilocode/kilo-web-ui/fonts/Inter-Variable.woff2") format("woff2-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Inter Variable";
src: url("@kilocode/kilo-web-ui/fonts/Inter-Variable-Italic.woff2") format("woff2-variations");
font-weight: 100 900;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "Roboto Mono Variable";
src: url("@kilocode/kilo-web-ui/fonts/RobotoMono-Variable.woff2") format("woff2-variations");
font-weight: 100 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "JetBrains Mono Variable";
src: url("@kilocode/kilo-web-ui/fonts/JetBrainsMono-Variable.woff2") format("woff2-variations");
font-weight: 100 800;
font-style: normal;
font-display: swap;
}
html[data-theme="kilo"],
html[data-theme="kilo-console"],
.kilo-console {
@@ -7,8 +36,11 @@ html[data-theme="kilo-console"],
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--font-family-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--font-sans-loaded: "Inter Variable", "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono-loaded: "Roboto Mono Variable", "Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
--font-jetbrains: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, Monaco, Consolas, monospace;
--font-family-sans: var(--font-sans-loaded);
--font-family-mono: var(--font-mono-loaded);
--font-size-small: 0.6875rem;
--font-size-base: 0.75rem;
--font-size-large: 0.875rem;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.0",
"version": "7.4.1",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+8
View File
@@ -1,5 +1,13 @@
# @kilocode/cli
## 7.4.1
### Patch Changes
- [#11887](https://github.com/Kilo-Org/kilocode/pull/11887) [`51dc189`](https://github.com/Kilo-Org/kilocode/commit/51dc189682107615d6af3fc6306d64fa3d5dafd8) - Require authentication before enabling allow-everything permissions over HTTP.
- [#11923](https://github.com/Kilo-Org/kilocode/pull/11923) [`fda4e17`](https://github.com/Kilo-Org/kilocode/commit/fda4e1756b3de46da3ac2081d440969a32ae5a59) - Fail subagent permission prompts in headless `kilo run` immediately instead of hanging forever, and approve subagent permission prompts under `--dangerously-skip-permissions`
## 7.4.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.0",
"version": "7.4.1",
"name": "@kilocode/cli",
"type": "module",
"license": "MIT",
+32 -3
View File
@@ -33,6 +33,7 @@ import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.
import { event as normalizeEvent } from "./run/event"
import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change
import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change
import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change
import { KiloRun, KiloRunDaemon } from "@/kilocode/cli/cmd/run" // kilocode_change
const runtimeTask = import("./run/runtime")
@@ -700,7 +701,10 @@ export const RunCommand = effectCmd({
process.exit(1)
}
const sessionID = sess.id
const auto = KiloRunAuto.create(sessionID) // kilocode_change
// kilocode_change start - track Task children; plain headless runs deny subagent asks instead of hanging (#11903)
const auto = KiloRunAuto.create(sessionID)
if (!args.attach && !args.auto && !args["dangerously-skip-permissions"]) KiloHeadless.mark(sessionID)
// kilocode_change end
function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") {
@@ -746,8 +750,8 @@ export const RunCommand = effectCmd({
if (event.type === "message.part.updated") {
const part = event.properties.part
// kilocode_change start - track Task child sessions for --auto permission replies
if (args.auto) KiloRunAuto.track(auto, part)
// kilocode_change start - track Task child sessions so permission replies can target them
KiloRunAuto.track(auto, part)
// kilocode_change end
if (part.sessionID !== sessionID) continue
@@ -851,6 +855,31 @@ export const RunCommand = effectCmd({
}
// kilocode_change end
// kilocode_change start - answer tracked Task child asks too, so subagents don't hang (#11903)
// Covers daemon/attach modes where the server evaluates permissions in another
// process and the in-process KiloHeadless deny cannot apply.
if (permission.sessionID !== sessionID) {
if (!KiloRunAuto.allowed(auto, permission.sessionID)) continue
if (args["dangerously-skip-permissions"]) {
await client.permission.reply({
requestID: permission.id,
reply: "once",
})
continue
}
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
`subagent permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
)
await client.permission.reply({
requestID: permission.id,
reply: "reject",
})
continue
}
// kilocode_change end
if (permission.sessionID !== sessionID) continue
if (args["dangerously-skip-permissions"]) {
@@ -14,7 +14,15 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
const session = createMemo(() => props.api.state.session.get(props.session_id))
const cost = createMemo(() => session()?.cost ?? 0)
// kilocode_change start
const cost = createMemo(() => {
const total = msg().reduce((sum, item) => {
if (item.role !== "assistant") return sum
return sum + (item.cost ?? 0)
}, 0)
return Math.max(session()?.cost ?? 0, total)
})
// kilocode_change end
const state = createMemo(() => {
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
@@ -0,0 +1,256 @@
import type { ProviderListResponse } from "@kilocode/sdk/v2/client"
import { ModelID, ProviderID } from "@/provider/schema"
import { Provider } from "@/provider/provider"
import z from "zod"
export namespace RemoteModelCatalog {
export const MAX_MODELS = 2_048
export const MAX_NAME_LENGTH = 256
export const MAX_VARIANTS = 32
export const MAX_VARIANT_KEY_LENGTH = 64
export const Request = z
.object({
protocolVersion: z.literal(1),
})
.strict()
export const ModelRef = z
.object({
providerID: z.string().min(1),
modelID: z.string().min(1),
})
.strict()
export type ModelRef = z.infer<typeof ModelRef>
export const ModelSelection = z
.object({
model: ModelRef,
variant: z.string().optional(),
})
.strict()
export type ModelSelection = z.infer<typeof ModelSelection>
export type Response = ProviderListResponse & {
protocolVersion: 1
currentModel?: ModelSelection
defaultModel?: ModelRef
truncated: boolean
}
type SourceModel = Omit<Provider.Model, "id" | "providerID"> & {
id: string
providerID: string
}
type SourceProvider = Omit<Provider.Info, "id" | "models" | "source" | "env" | "options"> & {
id: string
source?: Provider.Info["source"]
env?: string[]
options?: Record<string, unknown>
models: Record<string, SourceModel>
}
type SourceSelection = {
providerID: string
modelID: string
variant?: string
}
type Input = {
providers: Record<string, SourceProvider>
session: {
model?: {
id: string
providerID: string
variant?: string
}
}
messages: ReadonlyArray<{
info: {
role: string
model?: SourceSelection
}
}>
defaultModel?: ModelRef
}
function validIdentity(value: string) {
return value.length > 0
}
function current(input: Input): ModelSelection | undefined {
const session = input.session.model
if (session && validIdentity(session.providerID) && validIdentity(session.id)) {
return {
model: {
providerID: session.providerID,
modelID: session.id,
},
...(session.variant && session.variant !== "default" ? { variant: session.variant } : {}),
}
}
for (let idx = input.messages.length - 1; idx >= 0; idx--) {
const info = input.messages[idx]?.info
if (info?.role !== "user" || !info.model) continue
if (!validIdentity(info.model.providerID) || !validIdentity(info.model.modelID)) continue
return {
model: {
providerID: info.model.providerID,
modelID: info.model.modelID,
},
...(info.model.variant && info.model.variant !== "default" ? { variant: info.model.variant } : {}),
}
}
return undefined
}
function sanitizeModel(source: SourceModel, providerID: string): Provider.Model | undefined {
if (
!Number.isFinite(source.limit.context) ||
source.limit.context < 0 ||
!Number.isFinite(source.limit.output) ||
source.limit.output < 0 ||
(source.limit.input !== undefined && (!Number.isFinite(source.limit.input) || source.limit.input < 0))
) {
return undefined
}
if (!validIdentity(source.id)) return undefined
return {
id: ModelID.make(source.id),
providerID: ProviderID.make(providerID),
api: { id: source.id, url: "", npm: "" },
name: source.name.slice(0, MAX_NAME_LENGTH),
capabilities: {
temperature: source.capabilities.temperature,
reasoning: source.capabilities.reasoning,
attachment: source.capabilities.attachment,
toolcall: source.capabilities.toolcall,
input: {
text: source.capabilities.input.text,
audio: source.capabilities.input.audio,
image: source.capabilities.input.image,
video: source.capabilities.input.video,
pdf: source.capabilities.input.pdf,
},
output: {
text: source.capabilities.output.text,
audio: source.capabilities.output.audio,
image: source.capabilities.output.image,
video: source.capabilities.output.video,
pdf: source.capabilities.output.pdf,
},
interleaved:
typeof source.capabilities.interleaved === "boolean"
? source.capabilities.interleaved
: { field: source.capabilities.interleaved.field },
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: {
context: source.limit.context,
...(source.limit.input !== undefined ? { input: source.limit.input } : {}),
output: source.limit.output,
},
status: source.status,
...(typeof source.recommendedIndex === "number" && Number.isFinite(source.recommendedIndex)
? { recommendedIndex: source.recommendedIndex }
: {}),
...(typeof source.isFree === "boolean" ? { isFree: source.isFree } : {}),
...(typeof source.mayTrainOnYourPrompts === "boolean"
? { mayTrainOnYourPrompts: source.mayTrainOnYourPrompts }
: {}),
...(typeof source.hasUserByokAvailable === "boolean"
? { hasUserByokAvailable: source.hasUserByokAvailable }
: {}),
options: {},
headers: {},
release_date: "",
variants: Object.fromEntries(
Object.keys(source.variants ?? {})
.filter(validIdentity)
.filter((variant) => variant.length <= MAX_VARIANT_KEY_LENGTH)
.slice(0, MAX_VARIANTS)
.map((variant) => [variant, {}]),
),
}
}
function sanitizeProvider(source: SourceProvider): Provider.Info | undefined {
if (!validIdentity(source.id)) return undefined
const models = Provider.sort(
Object.values(source.models).flatMap((model) => {
const sanitized = sanitizeModel(model, source.id)
return sanitized ? [sanitized] : []
}),
)
if (models.length === 0) return undefined
return {
id: ProviderID.make(source.id),
name: source.name.slice(0, MAX_NAME_LENGTH),
source: source.source ?? "custom",
env: [],
options: {},
models: Object.fromEntries(models.map((model) => [model.id, model])),
}
}
function presentIn(providers: Provider.Info[], ref: { providerID: string; modelID: string }): boolean {
const provider = providers.find((candidate) => candidate.id === ref.providerID)
return provider ? Object.hasOwn(provider.models, ref.modelID) : false
}
function defaults(providers: Provider.Info[]): Record<string, string> {
const result: Record<string, string> = {}
for (const provider of providers) {
const models = Object.values(provider.models)
if (models.length === 0) continue
const preferred = Provider.sort(models)[0]?.id
if (preferred && Object.hasOwn(provider.models, preferred)) {
result[provider.id] = preferred
} else {
result[provider.id] = models[0].id
}
}
return result
}
export function build(input: Input): Response {
const all: Provider.Info[] = []
let modelCount = 0
let truncated = false
for (const source of Object.values(input.providers)) {
const provider = sanitizeProvider(source)
if (!provider) continue
const models = Object.values(provider.models)
if (modelCount + models.length > MAX_MODELS) {
truncated = true
const remaining = MAX_MODELS - modelCount
if (remaining <= 0) break
provider.models = Object.fromEntries(models.slice(0, remaining).map((model) => [model.id, model]))
}
modelCount += Object.keys(provider.models).length
all.push(provider)
}
const active = current(input)
const fallback = input.defaultModel
const currentModel = active && presentIn(all, active.model) ? active : undefined
const defaultModel = fallback && presentIn(all, fallback) ? fallback : undefined
return {
all,
default: defaults(all),
connected: all.map((provider) => provider.id),
failed: [],
protocolVersion: 1,
truncated,
...(currentModel ? { currentModel } : {}),
...(defaultModel ? { defaultModel } : {}),
}
}
}
@@ -20,6 +20,7 @@ export namespace RemoteProtocol {
sessions: z.array(SessionInfo),
focused: z.array(z.string()).optional(),
open: z.array(z.string()).optional(),
protocolVersion: z.string().optional(), // lets relay detect CLI capabilities without probing commands
})
export type Heartbeat = z.infer<typeof Heartbeat>
@@ -1,7 +1,9 @@
import { RemoteModelCatalog } from "@/kilo-sessions/remote-model-catalog"
import { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
import type { RemoteWS } from "@/kilo-sessions/remote-ws"
import { GlobalBus } from "@/bus/global"
import { Session } from "@/session/session"
import type { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
import { Question } from "@/question"
import { Suggestion } from "@/kilocode/suggestion" // kilocode_change
@@ -9,11 +11,11 @@ import { Permission } from "@/permission"
import { PermissionID } from "@/permission/schema"
import { SessionID } from "@/session/schema"
import { QuestionID } from "@/question/schema"
import { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import * as Log from "@opencode-ai/core/util/log"
import z from "zod"
import { zodObject } from "@opencode-ai/core/effect-zod"
import { Effect } from "effect"
import { Effect, Option, Schema } from "effect"
type Provide = typeof import("@/kilocode/instance").provide
@@ -38,24 +40,35 @@ const SuggestionData = z.object({
index: z.number().int().nonnegative(),
})
const decodeSessionID = Schema.decodeUnknownOption(SessionID)
// kilocode_change start — lazy init to avoid circular dependency
// (Server → RemoteRoutes → RemoteSender → SessionPrompt at module load time)
type RemotePromptInput = Omit<SessionPrompt.PromptInput, "model"> & {
model?: string | RemoteModelCatalog.ModelRef
}
let _remotePromptInput: z.ZodObject<any> | undefined
function getRemotePromptInput() {
return (_remotePromptInput ??= zodObject(SessionPrompt.PromptInput).extend({
model: z.string().optional(),
model: z.union([z.string(), RemoteModelCatalog.ModelRef]).optional(),
}))
}
// kilocode_change end
function normalizeModel(model: string | undefined) {
function normalizeModel(model: string | RemoteModelCatalog.ModelRef | undefined) {
if (!model) return undefined
if (typeof model !== "string") {
return {
providerID: ProviderID.make(model.providerID),
modelID: ModelID.make(model.modelID),
}
}
return {
providerID: ProviderID.make("kilo"),
modelID: ModelID.make(model.startsWith("kilocode/") ? model.slice("kilocode/".length) : model),
}
}
function normalizePrompt(input: SessionPrompt.PromptInput & { model?: string }): SessionPrompt.PromptInput {
function normalizePrompt(input: RemotePromptInput): SessionPrompt.PromptInput {
return {
...input,
model: normalizeModel(input.model),
@@ -83,6 +96,12 @@ export namespace RemoteSender {
readonly reject: (requestID: QuestionID) => Promise<void>
}
prompt?: (input: SessionPrompt.PromptInput) => Promise<unknown>
catalog?: {
readonly get: (sessionID: SessionID) => Promise<Session.Info>
readonly messages: (sessionID: SessionID) => Promise<MessageV2.WithParts[]>
readonly providers: () => Promise<Record<ProviderID, Provider.Info>>
readonly default: () => Promise<RemoteModelCatalog.ModelRef | undefined>
}
}
export type Sender = {
@@ -124,6 +143,30 @@ export namespace RemoteSender {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt(input)))
})
const catalog = options.catalog ?? {
get: async (sessionID: SessionID) => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(Session.Service.use((svc) => svc.get(sessionID)))
},
messages: async (sessionID: SessionID) => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(
Session.Service.use((svc) =>
svc
.findMessage(sessionID, (message) => message.info.role === "user" && !!message.info.model)
.pipe(Effect.map((message) => (Option.isSome(message) ? [message.value] : []))),
),
)
},
providers: async () => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(Provider.Service.use((svc) => svc.list()))
},
default: async () => {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(Provider.Service.use((svc) => svc.defaultModel()))
},
}
const sub =
options.subscribe ??
@@ -305,6 +348,48 @@ export namespace RemoteSender {
}
function dispatch(msg: RemoteProtocol.Command) {
if (msg.command === "list_models") {
const parsed = RemoteModelCatalog.Request.safeParse(msg.data)
const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none<SessionID>()
if (!parsed.success || Option.isNone(session)) {
options.conn.send({
type: "response",
id: msg.id,
error: "invalid list_models command",
})
return
}
const run = options.provide ?? provide
void (async () => {
try {
const info = await catalog.get(session.value)
const result = await run({
directory: info.directory,
fn: async () => {
const [providers, messages, fallback] = await Promise.all([
catalog.providers(),
catalog.messages(info.id),
catalog.default().catch((err) => {
options.log.warn("default model lookup failed", { error: String(err) })
return undefined
}),
])
return RemoteModelCatalog.build({
providers,
session: info,
messages,
defaultModel: fallback,
})
},
})
options.conn.send({ type: "response", id: msg.id, result })
} catch {
options.log.error("list models command failed", { id: msg.id })
options.conn.send({ type: "response", id: msg.id, error: "failed to list models" })
}
})()
return
}
if (msg.command === "send_message") {
const parsed = getRemotePromptInput().safeParse(msg.data)
if (!parsed.success) {
@@ -315,9 +400,7 @@ export namespace RemoteSender {
})
return
}
const input = SessionPrompt.PromptInput.zod.safeParse(
normalizePrompt(parsed.data as SessionPrompt.PromptInput & { model?: string }),
)
const input = SessionPrompt.PromptInput.zod.safeParse(normalizePrompt(parsed.data as RemotePromptInput))
if (!input.success) {
options.conn.send({
type: "response",
@@ -1,4 +1,5 @@
import { RemoteProtocol } from "@/kilo-sessions/remote-protocol"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
export namespace RemoteWS {
export type SessionInfo = RemoteProtocol.SessionInfo
@@ -53,11 +54,12 @@ export namespace RemoteWS {
const current = Promise.resolve(
withContext(async () => {
while (queued && !closed) {
while (queued) {
if (closed) return
queued = false
const sessions = await options.getSessions()
if (closed) return
send({ type: "heartbeat", ...sessions })
send({ type: "heartbeat", protocolVersion: InstallationVersion, ...sessions })
}
}),
).finally(() => {
@@ -119,20 +121,26 @@ export namespace RemoteWS {
}
const endpoint = `${options.url}/api/user/cli?token=${encodeURIComponent(token)}&connectionId=${connectionId}`
options.log.info("remote-ws connecting", { connectionId, endpoint: endpoint.replace(/token=[^&]+/, "token=***") })
ws = new WebSocket(endpoint)
const socket = new WebSocket(endpoint)
ws = socket
ws.onopen = () => {
socket.onopen = () => {
if (ws !== socket || closed) {
socket.close()
return
}
options.log.info("remote-ws connected", { buffered: buffer.length })
void withContext(() => options.onOpen?.())
backoff = 1000
for (const msg of buffer) ws!.send(msg)
for (const msg of buffer) socket.send(msg)
buffer.length = 0
activity = Date.now()
startHeartbeat()
startWatchdog()
}
ws.onmessage = (event) => {
socket.onmessage = (event) => {
if (ws !== socket || closed) return
activity = Date.now()
const raw = String(event.data)
let json: unknown
@@ -152,7 +160,8 @@ export namespace RemoteWS {
options.onMessage?.(parsed.data)
}
ws.onclose = (event) => {
socket.onclose = (event) => {
if (ws !== socket) return
options.log.info("remote-ws closed", { code: event.code, reason: event.reason })
ws = undefined
stopHeartbeat()
@@ -170,7 +179,8 @@ export namespace RemoteWS {
schedule()
}
ws.onerror = (event) => {
socket.onerror = (event) => {
if (ws !== socket || closed) return
options.log.error("remote-ws error", { error: event })
}
}
@@ -200,10 +210,12 @@ export namespace RemoteWS {
if (ws) ws.close()
}
open()
void open()
return {
connectionId,
get connectionId() {
return connectionId
},
send,
heartbeat,
close,
@@ -0,0 +1,45 @@
import { Database, eq } from "@/storage/db"
import { SessionTable } from "@/session/session.sql"
import type { SessionID } from "@/session/schema"
/**
* Headless roots (#11903).
*
* Root sessions driven by a client that cannot answer subagent permission
* prompts (plain `kilo run`). Permission asks originating from their child
* sessions must fail with DeniedError instead of blocking forever on a reply
* that never comes. Interactive clients (TUI, extension) never mark sessions
* here, so their subagent prompts stay answerable.
*/
export namespace KiloHeadless {
const roots = new Set<string>()
export function mark(id: string) {
roots.add(id)
}
export function clear(id: string) {
roots.delete(id)
}
/** True when `id` is a subagent session whose root run has no attached human. */
export function denies(id: string): boolean {
if (roots.size === 0) return false
if (roots.has(id)) return false
for (let parent = lookup(id); parent; parent = lookup(parent)) {
if (roots.has(parent)) return true
}
return false
}
function lookup(id: string) {
const row = Database.use((db) =>
db
.select({ parent: SessionTable.parent_id })
.from(SessionTable)
.where(eq(SessionTable.id, id as SessionID))
.get(),
)
return row?.parent ?? undefined
}
}
@@ -18,6 +18,7 @@ import { PermissionV2 } from "@opencode-ai/core/permission"
import { PermissionID } from "./schema"
// kilocode_change start
import { ConfigProtection } from "@/kilocode/permission/config-paths"
import { KiloHeadless } from "@/kilocode/permission/headless"
import { drainCovered } from "@/kilocode/permission/drain"
import { ReadPermission } from "@/kilocode/permission/read"
import { ExternalDirectoryPermission } from "@/kilocode/permission/external-directory"
@@ -276,6 +277,12 @@ export const layer = Layer.effect(
if (!needsAsk) return
// kilocode_change start - headless subagent asks fail instead of queuing for a reply that never comes (#11903)
if (KiloHeadless.denies(request.sessionID)) {
return yield* new DeniedError({ ruleset: subset(request.permission, ruleset) })
}
// kilocode_change end
const id = request.id ?? PermissionID.ascending()
const info: Request = {
id,
@@ -44,8 +44,10 @@ import { SyncEvent } from "../../src/sync"
import { Ripgrep } from "../../src/file/ripgrep"
import { ToolRegistry } from "../../src/tool/registry"
import { Truncate } from "../../src/tool/truncate"
import { KiloHeadless } from "../../src/kilocode/permission/headless"
import { KiloSessionPrompt } from "../../src/kilocode/session/prompt"
import { provideTmpdirServer } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect"
import { reply, TestLLMServer } from "../lib/llm-server"
void Log.init({ print: false })
@@ -290,3 +292,123 @@ it.live("active tool calls use permissions changed after model streaming starts"
},
),
)
const worker = (mode: "subagent" | "all"): AgentSvc.Info => ({
name: "worker",
mode,
permission: Permission.fromConfig({ bash: "ask" }),
options: {},
})
const bash = (sessionID: Session.Info["id"]) => ({
sessionID,
permission: "bash",
patterns: ["echo 1"],
always: ["echo 1"],
metadata: {},
})
// Reproduces #11903: a sync subagent hitting an "ask" rule in a headless run
// used to block forever on a permission prompt no client would ever answer.
it.live("headless run: subagent permission asks fail instead of waiting forever", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const permission = yield* Permission.Service
const sessions = yield* Session.Service
const root = yield* sessions.create({ title: "Root" })
const child = yield* sessions.create({ parentID: root.id, title: "Subagent" })
KiloHeadless.mark(root.id)
// mode "all" agents are valid subagents too; the deny must not key off agent mode
const agent = worker("all")
const err = yield* awaitWithTimeout(
KiloSessionPrompt.askPermission({
permission,
agents: { get: () => Effect.succeed(agent) },
sessions,
agent,
session: child,
request: bash(child.id),
}).pipe(Effect.flip),
"subagent permission ask queued waiting for a human reply instead of failing",
)
expect(err).toBeInstanceOf(Permission.DeniedError)
expect(yield* permission.list()).toEqual([])
expect(KiloHeadless.denies(child.id)).toBe(true)
expect(KiloHeadless.denies(root.id)).toBe(false)
KiloHeadless.clear(root.id)
}),
{ git: true },
),
)
it.live("interactive run: subagent permission asks still queue for a human reply", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const permission = yield* Permission.Service
const sessions = yield* Session.Service
const root = yield* sessions.create({ title: "Root" })
const child = yield* sessions.create({ parentID: root.id, title: "Subagent" })
const agent = worker("subagent")
const fiber = yield* KiloSessionPrompt.askPermission({
permission,
agents: { get: () => Effect.succeed(agent) },
sessions,
agent,
session: child,
request: bash(child.id),
}).pipe(Effect.forkScoped)
const pending = yield* pollWithTimeout(
Effect.gen(function* () {
const list = yield* permission.list()
return list.find((item) => item.sessionID === child.id)
}),
"subagent permission ask was never surfaced",
)
yield* permission.reply({ requestID: pending.id, reply: "reject" })
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
}),
{ git: true },
),
)
it.live("headless run: root session permission asks still queue (only subagents fail)", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {
const permission = yield* Permission.Service
const sessions = yield* Session.Service
const root = yield* sessions.create({ title: "Root" })
KiloHeadless.mark(root.id)
const agent = { ...worker("subagent"), mode: "primary" as const }
const fiber = yield* KiloSessionPrompt.askPermission({
permission,
agents: { get: () => Effect.succeed(agent) },
sessions,
agent,
session: root,
request: bash(root.id),
}).pipe(Effect.forkScoped)
const pending = yield* pollWithTimeout(
Effect.gen(function* () {
const list = yield* permission.list()
return list.find((item) => item.sessionID === root.id)
}),
"root permission ask was never surfaced",
)
yield* permission.reply({ requestID: pending.id, reply: "reject" })
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
KiloHeadless.clear(root.id)
}),
{ git: true },
),
)
@@ -0,0 +1,361 @@
import { describe, expect, test } from "bun:test"
import { RemoteModelCatalog } from "../../../src/kilo-sessions/remote-model-catalog"
function sanitizedModel(providerID: string, id: string, name: string, extra: Record<string, unknown> = {}) {
return {
id,
providerID,
api: { id, url: "", npm: "" },
name,
capabilities: {
temperature: true,
attachment: true,
reasoning: false,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
limit: { context: 200_000, output: 8_192 },
status: "active" as const,
variants: { fast: {}, precise: {} },
options: {},
headers: {},
release_date: "",
...extra,
}
}
function model(providerID: string, id: string, name: string) {
const variants: Record<string, Record<string, unknown>> = {
fast: { apiKey: "must-not-leak" },
precise: { baseURL: "https://private.example.com" },
}
return {
id,
providerID,
api: {
id: "private-deployment-id",
url: "https://private.example.com",
npm: "file:///private/provider-package",
},
name,
capabilities: {
temperature: true,
attachment: true,
reasoning: false,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 1,
output: 2,
cache: { read: 3, write: 4 },
},
limit: {
context: 200_000,
output: 8_192,
},
status: "active" as const,
variants,
options: { apiKey: "must-not-leak" },
headers: { authorization: "must-not-leak" },
release_date: "2026-01-01",
}
}
describe("RemoteModelCatalog", () => {
test("transforms providers to an allowlisted catalog with exact model identities", () => {
const privateModel = model("custom:edge", "model.with/slash-and:colon", "Model One")
Object.assign(privateModel, {
recommendedIndex: 3,
isFree: true,
mayTrainOnYourPrompts: false,
hasUserByokAvailable: true,
})
Object.assign(privateModel.capabilities, { privateCapabilityConfig: "must-not-leak" })
Object.assign(privateModel.limit, { privateLimitConfig: "must-not-leak" })
const catalog = RemoteModelCatalog.build({
providers: {
custom: {
id: "custom:edge",
name: "Zeta Provider",
source: "config" as const,
key: "must-not-leak",
env: ["PRIVATE_API_KEY"],
options: { baseURL: "https://private.example.com" },
models: {
model: privateModel,
},
},
anthropic: {
id: "anthropic",
name: "Anthropic",
source: "env" as const,
env: ["ANTHROPIC_API_KEY"],
options: { apiKey: "must-not-leak" },
models: {
claude: model("anthropic", "claude-sonnet", "Claude Sonnet"),
},
},
},
session: {
model: {
id: "model.with/slash-and:colon",
providerID: "custom:edge",
variant: "default",
},
},
messages: [
{
info: {
role: "user",
model: { providerID: "anthropic", modelID: "claude-sonnet" },
},
},
],
defaultModel: {
providerID: "anthropic",
modelID: "claude-sonnet",
},
})
expect(catalog).toEqual({
protocolVersion: 1,
all: [
{
id: "custom:edge",
name: "Zeta Provider",
source: "config",
env: [],
options: {},
models: {
"model.with/slash-and:colon": sanitizedModel("custom:edge", "model.with/slash-and:colon", "Model One", {
recommendedIndex: 3,
isFree: true,
mayTrainOnYourPrompts: false,
hasUserByokAvailable: true,
}),
},
},
{
id: "anthropic",
name: "Anthropic",
source: "env",
env: [],
options: {},
models: {
"claude-sonnet": sanitizedModel("anthropic", "claude-sonnet", "Claude Sonnet"),
},
},
],
default: {
"custom:edge": "model.with/slash-and:colon",
anthropic: "claude-sonnet",
},
connected: ["custom:edge", "anthropic"],
failed: [],
truncated: false,
currentModel: {
model: {
providerID: "custom:edge",
modelID: "model.with/slash-and:colon",
},
},
defaultModel: {
providerID: "anthropic",
modelID: "claude-sonnet",
},
})
expect(JSON.stringify(catalog)).not.toContain("must-not-leak")
expect(JSON.stringify(catalog)).not.toContain("PRIVATE_API_KEY")
expect(JSON.stringify(catalog)).not.toContain("private.example.com")
})
test("keeps duplicate model IDs distinct across providers", () => {
const catalog = RemoteModelCatalog.build({
providers: {
first: { id: "first", name: "First", models: { shared: model("first", "shared/model", "Shared") } },
second: { id: "second", name: "Second", models: { shared: model("second", "shared/model", "Shared") } },
},
session: {},
messages: [],
})
expect(catalog.all.map((provider) => [provider.id, Object.values(provider.models)[0]?.id])).toEqual([
["first", "shared/model"],
["second", "shared/model"],
])
})
test("uses the latest user message when the session has no current model", () => {
const catalog = RemoteModelCatalog.build({
providers: {
latest: { id: "latest", name: "Latest", models: { "latest/model": model("latest", "latest/model", "Latest") } },
},
session: {},
messages: [
{
info: {
role: "user",
model: { providerID: "older", modelID: "older/model", variant: "slow" },
},
},
{ info: { role: "assistant" } },
{
info: {
role: "user",
model: { providerID: "latest", modelID: "latest/model", variant: "default" },
},
},
{ info: { role: "user" } },
],
})
expect(catalog.currentModel).toEqual({
model: { providerID: "latest", modelID: "latest/model" },
})
})
test("drops empty identities and truncates overlong names", () => {
const empty = ""
const overlong = "x".repeat(500)
const kept = model("custom", "kept/model", overlong)
kept.variants = {
exact: {},
[empty]: {},
}
const catalog = RemoteModelCatalog.build({
providers: {
custom: {
id: "custom",
name: overlong,
models: {
kept,
removed: model("custom", empty, "Removed"),
},
},
},
session: {},
messages: [],
})
expect(catalog.all).toHaveLength(1)
expect(catalog.all[0]?.name).toBe(overlong.slice(0, RemoteModelCatalog.MAX_NAME_LENGTH))
expect(Object.keys(catalog.all[0]?.models ?? {})).toEqual(["kept/model"])
expect(catalog.all[0]?.models["kept/model"]?.name).toBe(overlong.slice(0, RemoteModelCatalog.MAX_NAME_LENGTH))
expect(catalog.all[0]?.models["kept/model"]?.variants).toEqual({ exact: {} })
expect(catalog.default).toEqual({ custom: "kept/model" })
expect(catalog.connected).toEqual(["custom"])
})
test("caps the total number of models and reports truncation", () => {
const providers = Object.fromEntries(
Array.from({ length: 3 }, (_, providerIndex) => {
const id = `provider-${providerIndex}`
return [
id,
{
id,
name: id,
models: Object.fromEntries(
Array.from({ length: RemoteModelCatalog.MAX_MODELS + 10 }, (_, modelIndex) => {
const modelId = `model-${providerIndex}-${modelIndex}`
return [modelId, model(id, modelId, modelId)]
}),
),
},
]
}),
)
const catalog = RemoteModelCatalog.build({ providers, session: {}, messages: [] })
const modelCount = catalog.all.reduce((total, provider) => total + Object.keys(provider.models).length, 0)
expect(modelCount).toBe(RemoteModelCatalog.MAX_MODELS)
expect(catalog.truncated).toBe(true)
})
test("truncation keeps the provider's preferred default model", () => {
const providers = {
big: {
id: "big",
name: "Big",
models: Object.fromEntries([
...Array.from({ length: RemoteModelCatalog.MAX_MODELS }, (_, i) => {
const id = `model-${i}`
return [id, model("big", id, `Model ${i}`)]
}),
["gpt-5-preferred", model("big", "gpt-5-preferred", "Preferred")],
]),
},
}
const catalog = RemoteModelCatalog.build({ providers, session: {}, messages: [] })
expect(catalog.truncated).toBe(true)
expect(catalog.default).toEqual({ big: "gpt-5-preferred" })
expect(Object.keys(catalog.all[0]?.models ?? {})).toContain("gpt-5-preferred")
})
test("omits currentModel and defaultModel when truncation drops them", () => {
const providers = {
big: {
id: "big",
name: "Big",
models: Object.fromEntries([
...Array.from({ length: RemoteModelCatalog.MAX_MODELS }, (_, i) => {
const id = `kept-${i}`
return [id, model("big", id, `Kept ${i}`)]
}),
["dropped-model", model("big", "dropped-model", "Dropped")],
]),
},
}
const catalog = RemoteModelCatalog.build({
providers,
session: { model: { id: "dropped-model", providerID: "big", variant: "default" } },
messages: [],
defaultModel: { providerID: "big", modelID: "dropped-model" },
})
expect(catalog.truncated).toBe(true)
expect(Object.keys(catalog.all[0]?.models ?? {})).not.toContain("dropped-model")
expect(catalog.currentModel).toBeUndefined()
expect(catalog.defaultModel).toBeUndefined()
})
test("caps overlong model names and variant maps", () => {
const longName = "x".repeat(500)
const manyVariants: Record<string, Record<string, unknown>> = {}
for (let i = 0; i < 50; i++) {
manyVariants[`variant-${i}`] = { secret: i }
}
manyVariants["y".repeat(100)] = { secret: "key" }
const kept = model("custom", "kept/model", longName)
kept.variants = manyVariants
const catalog = RemoteModelCatalog.build({
providers: {
custom: {
id: "custom",
name: longName,
models: { kept },
},
},
session: {},
messages: [],
})
expect(catalog.all[0]?.name.length).toBeLessThanOrEqual(RemoteModelCatalog.MAX_NAME_LENGTH)
expect(catalog.all[0]?.models["kept/model"]?.name.length).toBeLessThanOrEqual(RemoteModelCatalog.MAX_NAME_LENGTH)
const variants = catalog.all[0]?.models["kept/model"]?.variants
expect(Object.keys(variants ?? {}).length).toBeLessThanOrEqual(RemoteModelCatalog.MAX_VARIANTS)
expect(Object.keys(variants ?? {}).every((key) => key.length <= RemoteModelCatalog.MAX_VARIANT_KEY_LENGTH)).toBe(
true,
)
})
})
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { afterEach, mock, spyOn } from "bun:test"
import { Effect } from "effect"
import { RemoteModelCatalog } from "../../../src/kilo-sessions/remote-model-catalog"
import { RemoteSender } from "../../../src/kilo-sessions/remote-sender"
import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws"
import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol"
@@ -72,6 +73,31 @@ function prompts(calls: SessionPrompt.PromptInput[]) {
}
}
function catalogModel(providerID: string, modelID: string, name: string, reasoning = false) {
return {
id: ModelID.make(modelID),
providerID: ProviderID.make(providerID),
api: { id: "private-deployment", url: "https://private.example.com", npm: "file:///private/provider" },
name,
capabilities: {
temperature: true,
attachment: true,
reasoning,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 1, output: 2, cache: { read: 3, write: 4 } },
limit: { context: 100_000, output: 4_096 },
status: "active" as const,
options: { apiKey: "must-not-leak" },
headers: { authorization: "must-not-leak" },
release_date: "2026-01-01",
variants: { precise: { apiKey: "must-not-leak" } },
}
}
// kilocode_change start
afterEach(() => {
mock.restore()
@@ -299,6 +325,342 @@ describe("RemoteSender", () => {
})
})
test("list_models returns the effective catalog from the exact session directory", async () => {
const { conn, sent } = fakeConn()
const dirs: string[] = []
const sender = RemoteSender.create({
conn,
directory: "/tmp/process-default",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => {
dirs.push(input.directory)
return input.fn()
},
catalog: {
get: async () =>
({
id: SessionID.make("ses_models"),
directory: "/workspace/project-a",
model: {
id: ModelID.make("deployment/model"),
providerID: ProviderID.make("custom"),
variant: "precise",
},
}) as any,
messages: async () => [],
providers: async () =>
({
custom: {
id: ProviderID.make("custom"),
name: "Custom Provider",
source: "config",
env: ["PRIVATE_API_KEY"],
key: "must-not-leak",
options: { apiKey: "must-not-leak" },
models: {
"deployment/model": catalogModel("custom", "deployment/model", "Deployment Model", true),
},
},
}) as any,
default: async () => ({ providerID: ProviderID.make("custom"), modelID: ModelID.make("deployment/model") }),
},
})
sender.handle({
type: "command",
id: "req_models",
command: "list_models",
sessionId: "ses_models",
data: { protocolVersion: 1 },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(dirs).toEqual(["/workspace/project-a"])
expect(sent).toHaveLength(1)
expect(sent[0]?.type).toBe("response")
expect(sent[0]?.id).toBe("req_models")
const result = sent[0]?.result as RemoteModelCatalog.Response
expect(result.all).toHaveLength(1)
expect(result.all[0]?.id).toBe("custom")
expect(result.all[0]?.env).toEqual([])
expect(result.all[0]?.options).toEqual({})
expect(result.all[0]?.models["deployment/model"]?.variants).toEqual({ precise: {} })
expect(result.default).toEqual({ custom: "deployment/model" })
expect(result.connected).toEqual(["custom"])
expect(result.failed).toEqual([])
expect(result.currentModel).toEqual({
model: { providerID: "custom", modelID: "deployment/model" },
variant: "precise",
})
expect(result.defaultModel).toEqual({ providerID: "custom", modelID: "deployment/model" })
expect(result.truncated).toBe(false)
expect(JSON.stringify(result)).not.toContain("must-not-leak")
expect(JSON.stringify(result)).not.toContain("private.example.com")
})
test("list_models scopes provider discovery to each session directory", async () => {
const { conn, sent } = fakeConn()
const state = { directory: "" }
const messages: string[] = []
const sender = RemoteSender.create({
conn,
directory: "/tmp/process-default",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => {
state.directory = input.directory
const result = await input.fn()
state.directory = ""
return result
},
catalog: {
get: async (sessionID) =>
({
id: sessionID,
directory: sessionID === SessionID.make("ses_first") ? "/workspace/first" : "/workspace/second",
}) as any,
messages: async (sessionID) => {
messages.push(sessionID)
return []
},
providers: async () => {
const id = state.directory === "/workspace/first" ? "first-provider" : "second-provider"
return {
[id]: {
id: ProviderID.make(id),
name: id,
source: "custom",
env: [],
options: {},
models: {
model: catalogModel(id, "model", "Model"),
},
},
} as any
},
default: async () => undefined,
},
})
sender.handle({
type: "command",
id: "req_models_first",
command: "list_models",
sessionId: "ses_first",
data: { protocolVersion: 1 },
})
await new Promise((resolve) => setTimeout(resolve, 0))
sender.handle({
type: "command",
id: "req_models_second",
command: "list_models",
sessionId: "ses_second",
data: { protocolVersion: 1 },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sent.map((message) => message.result?.all[0]?.id)).toEqual(["first-provider", "second-provider"])
expect(messages).toEqual([SessionID.make("ses_first"), SessionID.make("ses_second")])
})
test("list_models tolerates unavailable provider default resolution", async () => {
const { conn, sent } = fakeConn()
const sender = RemoteSender.create({
conn,
directory: "/tmp/process-default",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
catalog: {
get: async () => ({ id: SessionID.make("ses_models"), directory: "/workspace/project-a" }) as any,
messages: async () => [],
providers: async () => ({}),
default: async () => {
throw new Error("no provider default")
},
},
})
sender.handle({
type: "command",
id: "req_models_no_default",
command: "list_models",
sessionId: "ses_models",
data: { protocolVersion: 1 },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sent).toEqual([
{
type: "response",
id: "req_models_no_default",
result: {
all: [],
default: {},
connected: [],
failed: [],
protocolVersion: 1,
truncated: false,
},
},
])
})
test("list_models logs a warning when provider default resolution fails", async () => {
const { conn, sent } = fakeConn()
const warnings: any[] = []
const sender = RemoteSender.create({
conn,
directory: "/tmp/process-default",
log: { ...nolog, warn: (...args: any[]) => warnings.push(args) },
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
catalog: {
get: async () => ({ id: SessionID.make("ses_models"), directory: "/workspace/project-a" }) as any,
messages: async () => [],
providers: async () => ({}),
default: async () => {
throw new Error("no provider default")
},
},
})
sender.handle({
type: "command",
id: "req_models_warn_default",
command: "list_models",
sessionId: "ses_models",
data: { protocolVersion: 1 },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sent[0]?.result?.defaultModel).toBeUndefined()
expect(warnings).toHaveLength(1)
expect(warnings[0]?.[0]).toBe("default model lookup failed")
expect(String(warnings[0]?.[1]?.error)).toContain("no provider default")
})
test("list_models never falls back to the process directory for an unknown session", async () => {
const { conn, sent } = fakeConn()
const dirs: string[] = []
const sender = RemoteSender.create({
conn,
directory: "/tmp/process-default",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => {
dirs.push(input.directory)
return input.fn()
},
catalog: {
get: async () => {
throw new Error("session not found")
},
messages: async () => [],
providers: async () => ({}),
default: async () => undefined,
},
})
sender.handle({
type: "command",
id: "req_models_missing",
command: "list_models",
sessionId: "ses_missing",
data: { protocolVersion: 1 },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(dirs).toEqual([])
expect(sent).toEqual([
{
type: "response",
id: "req_models_missing",
error: "failed to list models",
},
])
})
test("list_models returns one generic error when provider discovery fails", async () => {
const { conn, sent } = fakeConn()
const sender = RemoteSender.create({
conn,
directory: "/tmp/process-default",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
catalog: {
get: async () => ({ id: SessionID.make("ses_models"), directory: "/workspace/project-a" }) as any,
messages: async () => [],
providers: async () => {
throw new Error("private provider failure with api-key")
},
default: async () => undefined,
},
})
sender.handle({
type: "command",
id: "req_models_failed",
command: "list_models",
sessionId: "ses_models",
data: { protocolVersion: 1 },
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sent).toEqual([
{
type: "response",
id: "req_models_failed",
error: "failed to list models",
},
])
expect(JSON.stringify(sent)).not.toContain("api-key")
})
test("list_models rejects unsupported versions and missing session IDs", () => {
const { conn, sent } = fakeConn()
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
})
sender.handle({
type: "command",
id: "req_models_v2",
command: "list_models",
sessionId: "ses_models",
data: { protocolVersion: 2 },
})
sender.handle({
type: "command",
id: "req_models_missing_session",
command: "list_models",
data: { protocolVersion: 1 },
})
sender.handle({
type: "command",
id: "req_models_invalid_session",
command: "list_models",
sessionId: "not-a-session-id",
data: { protocolVersion: 1 },
})
expect(sent).toEqual([
{ type: "response", id: "req_models_v2", error: "invalid list_models command" },
{ type: "response", id: "req_models_missing_session", error: "invalid list_models command" },
{ type: "response", id: "req_models_invalid_session", error: "invalid list_models command" },
])
})
test("send_message with agent is accepted", async () => {
const { conn, sent } = fakeConn()
let resolveProvide: () => void
@@ -405,31 +767,103 @@ describe("RemoteSender", () => {
])
})
test("send_message rejects structured model on remote path", () => {
test("send_message preserves a structured provider and model", async () => {
const { conn, sent } = fakeConn()
const calls: SessionPrompt.PromptInput[] = []
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
prompt: prompts(calls),
})
sender.handle({
type: "command",
id: "req_model_alias",
id: "req_model_structured",
command: "send_message",
data: {
sessionID: "ses_x",
parts: [{ type: "text", text: "hello" }],
model: { providerID: "kilocode", modelID: "gpt-5-mini" },
model: { providerID: "custom:edge", modelID: "deployment/model-v1" },
variant: "precise",
},
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sent[0]).toEqual({ type: "response", id: "req_model_structured", result: {} })
expect(calls).toEqual([
{
sessionID: SessionID.make("ses_x"),
parts: [{ type: "text", text: "hello" }],
model: {
providerID: ProviderID.make("custom:edge"),
modelID: ModelID.make("deployment/model-v1"),
},
variant: "precise",
},
])
})
test("send_message rejects invalid structured model identities before ACK", () => {
const { conn, sent } = fakeConn()
const calls: SessionPrompt.PromptInput[] = []
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
prompt: prompts(calls),
})
sender.handle({
type: "command",
id: "req_model_invalid",
command: "send_message",
data: {
sessionID: "ses_x",
parts: [{ type: "text", text: "hello" }],
model: { providerID: "", modelID: "deployment/model-v1" },
},
})
expect(sent).toHaveLength(1)
expect(sent[0].type).toBe("response")
expect(sent[0].id).toBe("req_model_alias")
expect(sent[0].error).toContain("invalid send_message data")
expect(sent[0]?.error).toContain("invalid send_message data")
expect(calls).toHaveLength(0)
})
test("send_message leaves model and variant omitted for CLI precedence", async () => {
const { conn, sent } = fakeConn()
const calls: SessionPrompt.PromptInput[] = []
const sender = RemoteSender.create({
conn,
directory: "/tmp/test",
log: nolog,
subscribe: fakeBus().subscribe,
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
prompt: prompts(calls),
})
sender.handle({
type: "command",
id: "req_model_omitted",
command: "send_message",
data: {
sessionID: "ses_x",
parts: [{ type: "text", text: "hello" }],
agent: "configured-agent",
},
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sent[0]).toEqual({ type: "response", id: "req_model_omitted", result: {} })
expect(calls).toHaveLength(1)
expect(calls[0]?.model).toBeUndefined()
expect(calls[0]?.variant).toBeUndefined()
})
test("send_message does not special-case kilo-prefixed model", async () => {
@@ -25,6 +25,7 @@ function capture() {
function createServer() {
const messages: string[] = []
const clients: ServerWebSocket<unknown>[] = []
const urls: URL[] = []
const pending: {
connect: ((ws: ServerWebSocket<unknown>) => void)[]
message: ((msg: string) => void)[]
@@ -33,6 +34,7 @@ function createServer() {
const server = Bun.serve({
port: 0,
fetch(req, server) {
urls.push(new URL(req.url))
const upgraded = server.upgrade(req)
if (!upgraded) return new Response("Not found", { status: 404 })
return undefined
@@ -60,6 +62,7 @@ function createServer() {
url: `ws://localhost:${server.port}`,
messages,
clients,
urls,
stop: () => server.stop(true),
waitForConnect: () =>
new Promise<ServerWebSocket<unknown>>((resolve) => {
@@ -72,6 +75,14 @@ function createServer() {
}
}
async function until(predicate: () => boolean, timeout = 5000) {
const start = Date.now()
while (!predicate()) {
if (Date.now() - start > timeout) throw new Error("condition never became true")
await Bun.sleep(20)
}
}
async function settled() {
await Bun.sleep(20)
}
@@ -211,6 +222,106 @@ describe("RemoteWS", () => {
expect(conn.connected).toBe(true)
})
test("keeps a stable connection identity across reconnects", async () => {
server = createServer()
conn = RemoteWS.connect({
url: server.url,
getToken: async () => "tok",
getSessions: async () => ({ sessions: [] }),
log: nolog(),
heartbeat: 60_000,
})
const first = await server.waitForConnect()
await settled()
const initial = server.urls[0]?.searchParams.get("connectionId")
expect(initial).toBe(conn.connectionId)
const reconnecting = server.waitForConnect()
first.close()
await reconnecting
await settled()
const replacement = server.urls[1]?.searchParams.get("connectionId")
expect(replacement).toBe(initial)
expect(replacement).toBe(conn.connectionId)
})
test("ignores callbacks from a stale WebSocket generation", async () => {
const OriginalWebSocket = globalThis.WebSocket
const sockets: FakeWebSocket[] = []
const received: unknown[] = []
class FakeWebSocket {
static readonly OPEN = 1
readonly sent: string[] = []
readyState = 0
onopen: (() => void) | null = null
onmessage: ((event: { data: string }) => void) | null = null
onclose: ((event: { code: number; reason: string }) => void) | null = null
onerror: ((event: unknown) => void) | null = null
constructor(readonly url: string) {
sockets.push(this)
}
send(message: string) {
this.sent.push(message)
}
close() {
this.readyState = 3
}
open() {
this.readyState = FakeWebSocket.OPEN
this.onopen?.()
}
disconnect(code = 1000, reason = "closed") {
this.readyState = 3
this.onclose?.({ code, reason })
}
}
Object.defineProperty(globalThis, "WebSocket", { value: FakeWebSocket, configurable: true, writable: true })
try {
conn = RemoteWS.connect({
url: "ws://example.test",
getToken: async () => "tok",
getSessions: async () => ({ sessions: [] }),
log: nolog(),
heartbeat: 60_000,
onMessage: (message) => received.push(message),
})
await settled()
const first = sockets[0]
expect(first).toBeDefined()
first?.open()
first?.disconnect()
await until(() => sockets.length >= 2)
const second = sockets[1]
expect(second).toBeDefined()
second?.open()
first?.onmessage?.({ data: JSON.stringify({ type: "subscribe", sessionId: "stale" }) })
first?.onclose?.({ code: 1000, reason: "late close" })
conn.send({ type: "event", sessionId: "active", event: "test", data: {} })
expect(received).toEqual([])
expect(conn.connected).toBe(true)
expect(second?.sent).toEqual([JSON.stringify({ type: "event", sessionId: "active", event: "test", data: {} })])
conn.close()
conn = undefined
} finally {
Object.defineProperty(globalThis, "WebSocket", { value: OriginalWebSocket, configurable: true, writable: true })
}
})
test("stops reconnecting on 4401", async () => {
server = createServer()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/plugin-atomic-chat",
"version": "7.4.0",
"version": "7.4.1",
"description": "Kilo Code plugin for Atomic Chat: auto-detection and dynamic model discovery (OpenAI-compatible local API)",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/plugin",
"version": "7.4.0",
"version": "7.4.1",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -12,6 +12,6 @@
"exports": {
".": "./src/index.ts"
},
"version": "7.4.0",
"version": "7.4.1",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/sdk",
"version": "7.4.0",
"version": "7.4.1",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -26,7 +26,7 @@
"typescript": "catalog:",
"vite": "catalog:"
},
"version": "7.4.0",
"version": "7.4.1",
"dependencies": {},
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "7.4.0",
"version": "7.4.1",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/upstream-merge",
"version": "7.4.0",
"version": "7.4.1",
"private": true,
"type": "module",
"description": "Scripts for automating upstream opencode merges into Kilo",