diff --git a/.changeset/console-navbar-kilo-logo.md b/.changeset/console-navbar-kilo-logo.md new file mode 100644 index 0000000000..04f108b4ab --- /dev/null +++ b/.changeset/console-navbar-kilo-logo.md @@ -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. diff --git a/.changeset/console-profile-external-link-icons.md b/.changeset/console-profile-external-link-icons.md new file mode 100644 index 0000000000..1faeccf4da --- /dev/null +++ b/.changeset/console-profile-external-link-icons.md @@ -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. \ No newline at end of file diff --git a/.changeset/fix-bedrock-empty-output.md b/.changeset/fix-bedrock-empty-output.md new file mode 100644 index 0000000000..bfc268e684 --- /dev/null +++ b/.changeset/fix-bedrock-empty-output.md @@ -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. diff --git a/.changeset/harden-allow-everything-auth.md b/.changeset/harden-allow-everything-auth.md deleted file mode 100644 index 91a66e9a60..0000000000 --- a/.changeset/harden-allow-everything-auth.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Require authentication before enabling allow-everything permissions over HTTP. diff --git a/.changeset/kilo-console-cloud-fonts.md b/.changeset/kilo-console-cloud-fonts.md new file mode 100644 index 0000000000..da077665be --- /dev/null +++ b/.changeset/kilo-console-cloud-fonts.md @@ -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. \ No newline at end of file diff --git a/.changeset/remote-cli-provider-models.md b/.changeset/remote-cli-provider-models.md new file mode 100644 index 0000000000..3c2c2acdf1 --- /dev/null +++ b/.changeset/remote-cli-provider-models.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Support provider-aware model discovery and selection for remote Cloud sessions. diff --git a/.changeset/tui-live-spent-cost.md b/.changeset/tui-live-spent-cost.md new file mode 100644 index 0000000000..40d7f92bff --- /dev/null +++ b/.changeset/tui-live-spent-cost.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Show live session spend in the TUI sidebar while an assistant turn is still running. diff --git a/bun.lock b/bun.lock index 198467ad13..61c30f6abc 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/nix/hashes.json b/nix/hashes.json index 7b397d8a65..8df245c8b4 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } diff --git a/package.json b/package.json index 57f6f5f789..d2744d8b23 100644 --- a/package.json +++ b/package.json @@ -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": {} } diff --git a/packages/core/package.json b/packages/core/package.json index b7b33705b4..7808dbc6e9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 4b1c99161d..7465c91969 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -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", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 2099078a29..0fe80a0cce 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -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"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index db7c555d49..8737b124db 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -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", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 383b775fce..18832504de 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.4.0", + "version": "7.4.1", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-console/public/kilo-logo.svg b/packages/kilo-console/public/kilo-logo.svg new file mode 100644 index 0000000000..53469c85fe --- /dev/null +++ b/packages/kilo-console/public/kilo-logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-console/src/components/app-header/AppHeader.tsx b/packages/kilo-console/src/components/app-header/AppHeader.tsx index 7721ddc844..ceac360228 100644 --- a/packages/kilo-console/src/components/app-header/AppHeader.tsx +++ b/packages/kilo-console/src/components/app-header/AppHeader.tsx @@ -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 (
- - + + - Kilo Console - + diff --git a/packages/kilo-console/src/routes/profile/ProfileRoute.tsx b/packages/kilo-console/src/routes/profile/ProfileRoute.tsx index 71a74ac6aa..5f67667554 100644 --- a/packages/kilo-console/src/routes/profile/ProfileRoute.tsx +++ b/packages/kilo-console/src/routes/profile/ProfileRoute.tsx @@ -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() { Overview - + Usage + - + Buy Credits + @@ -169,6 +172,7 @@ export function ProfileRoute() { rel="noreferrer" > Open Dashboard +
diff --git a/packages/kilo-console/src/styles/base.css b/packages/kilo-console/src/styles/base.css index c0e70b7793..3a22dc6162 100644 --- a/packages/kilo-console/src/styles/base.css +++ b/packages/kilo-console/src/styles/base.css @@ -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; } diff --git a/packages/kilo-console/src/styles/profile.css b/packages/kilo-console/src/styles/profile.css index c5630cfa28..cf3e1a580f 100644 --- a/packages/kilo-console/src/styles/profile.css +++ b/packages/kilo-console/src/styles/profile.css @@ -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; diff --git a/packages/kilo-console/src/styles/resolved.css b/packages/kilo-console/src/styles/resolved.css index bea5a2cf8f..688c584340 100644 --- a/packages/kilo-console/src/styles/resolved.css +++ b/packages/kilo-console/src/styles/resolved.css @@ -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 { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 225096b2cc..543a3b17bf 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -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", diff --git a/packages/kilo-docs/pages/ai-providers/ollama.md b/packages/kilo-docs/pages/ai-providers/ollama.md index 4f169bb311..bf270c61bf 100644 --- a/packages/kilo-docs/pages/ai-providers/ollama.md +++ b/packages/kilo-docs/pages/ai-providers/ollama.md @@ -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/` 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 %} + @@ -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/` 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 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index c082f6fa91..c0959ee2ef 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -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", diff --git a/packages/kilo-gateway/src/api/notifications.ts b/packages/kilo-gateway/src/api/notifications.ts index 0112ccd14c..669139e1b2 100644 --- a/packages/kilo-gateway/src/api/notifications.ts +++ b/packages/kilo-gateway/src/api/notifications.ts @@ -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), }) diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 5abfd2abcc..3a3b5ef3b0 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -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", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 0ea6df4d7e..528b4dd8d2 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -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", diff --git a/packages/kilo-jetbrains/package.json b/packages/kilo-jetbrains/package.json index 360ac430c0..c0faf324f7 100644 --- a/packages/kilo-jetbrains/package.json +++ b/packages/kilo-jetbrains/package.json @@ -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": {} diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index d2cee75fa7..62e879496e 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -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", diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index 0d72cd127f..661adde7e9 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -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, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index f5636e25ad..4c119dbc17 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -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", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 4b06163046..4f5db91edd 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.4.0", + "version": "7.4.1", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index c11c31294f..8a3accd652 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -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 diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index e464d1bf74..f91fb383f1 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -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", diff --git a/packages/kilo-vscode/script/build.ts b/packages/kilo-vscode/script/build.ts index 74abe402ab..c6dd9b4b9a 100644 --- a/packages/kilo-vscode/script/build.ts +++ b/packages/kilo-vscode/script/build.ts @@ -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) diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index d988b5de65..4280b7094d 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -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), diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index ddec8779ab..1931e486e5 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.4.0", + "version": "7.4.1", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts new file mode 100644 index 0000000000..bffa6a1456 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-input-bidirectional.test.ts @@ -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(/
/)?.[0] + const input = src.match(//)?.[0] + + expect(overlay).toContain('dir="auto"') + expect(input).toContain('class="prompt-input"') + expect(input).toContain('dir="auto"') + }) +}) diff --git a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts index 5e2fefc569..cb06763a7d 100644 --- a/packages/kilo-vscode/tests/unit/use-file-mention.test.ts +++ b/packages/kilo-vscode/tests/unit/use-file-mention.test.ts @@ -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?.() + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index f3b39c2cdb..d3f59a0d42 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -474,7 +474,13 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
1}> - + void; defaultBaseBran variants={variants()} value={effectiveVariant()} onSelect={setVariant} + portal={false} deferDismiss /> diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index cad89ad618..d9c346cb4b 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -1186,7 +1186,7 @@ export const PromptInput: Component = (props) => {
- diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx index d078192110..4ca0f754cc 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModeSwitcher.tsx @@ -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 = (props) => { expanded={false} placement="top-start" minHeight={100} + portal={props.portal} deferDismiss={props.deferDismiss} open={open()} onOpenChange={onOpen} diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index 45f9b827ae..652bb70bb0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -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 = (props placement={props.placement ?? "top-start"} preferredWidth={180} minHeight={100} + portal={props.portal} deferDismiss={props.deferDismiss} open={open()} onOpenChange={onOpen} diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts index 342c603cd7..cc9ca92def 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useFileMention.ts @@ -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 } diff --git a/packages/kilo-web-ui/package.json b/packages/kilo-web-ui/package.json index 7f52860e86..8f910897c4 100644 --- a/packages/kilo-web-ui/package.json +++ b/packages/kilo-web-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-web-ui", - "version": "7.4.0", + "version": "7.4.1", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-web-ui/src/assets/fonts/ATTRIBUTION.md b/packages/kilo-web-ui/src/assets/fonts/ATTRIBUTION.md new file mode 100644 index 0000000000..9c1e7ae2a3 --- /dev/null +++ b/packages/kilo-web-ui/src/assets/fonts/ATTRIBUTION.md @@ -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. \ No newline at end of file diff --git a/packages/kilo-web-ui/src/assets/fonts/Inter-Variable-Italic.woff2 b/packages/kilo-web-ui/src/assets/fonts/Inter-Variable-Italic.woff2 new file mode 100644 index 0000000000..f22ec25549 Binary files /dev/null and b/packages/kilo-web-ui/src/assets/fonts/Inter-Variable-Italic.woff2 differ diff --git a/packages/kilo-web-ui/src/assets/fonts/Inter-Variable.woff2 b/packages/kilo-web-ui/src/assets/fonts/Inter-Variable.woff2 new file mode 100644 index 0000000000..22a12b04e1 Binary files /dev/null and b/packages/kilo-web-ui/src/assets/fonts/Inter-Variable.woff2 differ diff --git a/packages/kilo-web-ui/src/assets/fonts/JetBrainsMono-Variable.woff2 b/packages/kilo-web-ui/src/assets/fonts/JetBrainsMono-Variable.woff2 new file mode 100644 index 0000000000..0d8735149a Binary files /dev/null and b/packages/kilo-web-ui/src/assets/fonts/JetBrainsMono-Variable.woff2 differ diff --git a/packages/kilo-web-ui/src/assets/fonts/RobotoMono-Variable.woff2 b/packages/kilo-web-ui/src/assets/fonts/RobotoMono-Variable.woff2 new file mode 100644 index 0000000000..f8b74ce422 Binary files /dev/null and b/packages/kilo-web-ui/src/assets/fonts/RobotoMono-Variable.woff2 differ diff --git a/packages/kilo-web-ui/src/styles/console-shell.css b/packages/kilo-web-ui/src/styles/console-shell.css index 63a55f2558..e5773cc520 100644 --- a/packages/kilo-web-ui/src/styles/console-shell.css +++ b/packages/kilo-web-ui/src/styles/console-shell.css @@ -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; diff --git a/packages/kilo-web-ui/src/styles/theme.css b/packages/kilo-web-ui/src/styles/theme.css index 4e39883ba5..fa05405b36 100644 --- a/packages/kilo-web-ui/src/styles/theme.css +++ b/packages/kilo-web-ui/src/styles/theme.css @@ -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; diff --git a/packages/llm/package.json b/packages/llm/package.json index 6704a129c4..78df064555 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -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", diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index def36318a0..6fb4d70d98 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -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 diff --git a/packages/opencode/package.json b/packages/opencode/package.json index a2dbe1f1f2..5a69ddf17b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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", diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index e6ffede741..32d99030d5 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -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) { 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"]) { diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index fde2d0cb14..ed441168cc 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -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) diff --git a/packages/opencode/src/kilo-sessions/remote-model-catalog.ts b/packages/opencode/src/kilo-sessions/remote-model-catalog.ts new file mode 100644 index 0000000000..f6f567e192 --- /dev/null +++ b/packages/opencode/src/kilo-sessions/remote-model-catalog.ts @@ -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 + + export const ModelSelection = z + .object({ + model: ModelRef, + variant: z.string().optional(), + }) + .strict() + export type ModelSelection = z.infer + + export type Response = ProviderListResponse & { + protocolVersion: 1 + currentModel?: ModelSelection + defaultModel?: ModelRef + truncated: boolean + } + + type SourceModel = Omit & { + id: string + providerID: string + } + + type SourceProvider = Omit & { + id: string + source?: Provider.Info["source"] + env?: string[] + options?: Record + models: Record + } + + type SourceSelection = { + providerID: string + modelID: string + variant?: string + } + + type Input = { + providers: Record + 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 { + const result: Record = {} + 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 } : {}), + } + } +} diff --git a/packages/opencode/src/kilo-sessions/remote-protocol.ts b/packages/opencode/src/kilo-sessions/remote-protocol.ts index 2972fb0565..6077e86844 100644 --- a/packages/opencode/src/kilo-sessions/remote-protocol.ts +++ b/packages/opencode/src/kilo-sessions/remote-protocol.ts @@ -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 diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 274592901c..1e4dd6327b 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -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 & { + model?: string | RemoteModelCatalog.ModelRef +} let _remotePromptInput: z.ZodObject | 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 } prompt?: (input: SessionPrompt.PromptInput) => Promise + catalog?: { + readonly get: (sessionID: SessionID) => Promise + readonly messages: (sessionID: SessionID) => Promise + readonly providers: () => Promise> + readonly default: () => Promise + } } 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() + 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", diff --git a/packages/opencode/src/kilo-sessions/remote-ws.ts b/packages/opencode/src/kilo-sessions/remote-ws.ts index a6bc3e5d16..32c6b6688e 100644 --- a/packages/opencode/src/kilo-sessions/remote-ws.ts +++ b/packages/opencode/src/kilo-sessions/remote-ws.ts @@ -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, diff --git a/packages/opencode/src/kilocode/permission/headless.ts b/packages/opencode/src/kilocode/permission/headless.ts new file mode 100644 index 0000000000..baa931b773 --- /dev/null +++ b/packages/opencode/src/kilocode/permission/headless.ts @@ -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() + + 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 + } +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d57f9efff8..af14a724ba 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -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, diff --git a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts index 3f86f491f2..20d71868c7 100644 --- a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts @@ -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 }, + ), +) diff --git a/packages/opencode/test/kilocode/sessions/remote-model-catalog.test.ts b/packages/opencode/test/kilocode/sessions/remote-model-catalog.test.ts new file mode 100644 index 0000000000..6ef1e69a82 --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/remote-model-catalog.test.ts @@ -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 = {}) { + 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> = { + 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> = {} + 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, + ) + }) +}) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index c8defe28d0..599c7644f4 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -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 (input: { directory: string; init?: Effect.Effect; 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 (input: { directory: string; init?: Effect.Effect; 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 (input: { directory: string; init?: Effect.Effect; 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 (input: { directory: string; init?: Effect.Effect; 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 (input: { directory: string; init?: Effect.Effect; 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 (input: { directory: string; init?: Effect.Effect; 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 (input: { directory: string; init?: Effect.Effect; 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 (input: { directory: string; init?: Effect.Effect; 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 (input: { directory: string; init?: Effect.Effect; 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 () => { diff --git a/packages/opencode/test/kilocode/sessions/remote-ws.test.ts b/packages/opencode/test/kilocode/sessions/remote-ws.test.ts index e00b6ec0a3..a4fe6769a0 100644 --- a/packages/opencode/test/kilocode/sessions/remote-ws.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-ws.test.ts @@ -25,6 +25,7 @@ function capture() { function createServer() { const messages: string[] = [] const clients: ServerWebSocket[] = [] + const urls: URL[] = [] const pending: { connect: ((ws: ServerWebSocket) => 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>((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() diff --git a/packages/plugin-atomic-chat/package.json b/packages/plugin-atomic-chat/package.json index cbcaf75575..947ec24343 100644 --- a/packages/plugin-atomic-chat/package.json +++ b/packages/plugin-atomic-chat/package.json @@ -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", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 988e9247fa..b7226b335f 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -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": { diff --git a/packages/script/package.json b/packages/script/package.json index 51a0d10daa..ac6e11eb06 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.4.0", + "version": "7.4.1", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index fb452dea81..59c2f4a69d 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -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": { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 1db6793911..f2a2c40587 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.4.0", + "version": "7.4.1", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 1c45aa7d29..538b2a93cc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.4.0", + "version": "7.4.1", "type": "module", "license": "MIT", "exports": { diff --git a/script/upstream/package.json b/script/upstream/package.json index ea3f58c308..00fb0415e9 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -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",