2
0
Эх сурвалжийг харах

Merge branch 'main' into feature/agent-manager-image-paste

Marius 3 долоо хоног өмнө
parent
commit
3a2f2e9ac7
43 өөрчлөгдсөн 1332 нэмэгдсэн , 123 устгасан
  1. 10 0
      .changeset/approval-feedback-fix.md
  2. 5 0
      .changeset/cuddly-candles-protect.md
  3. 5 0
      .changeset/fix-zod-function-api.md
  4. 5 0
      .changeset/friendly-jars-yawn.md
  5. 5 0
      .changeset/metal-sheep-fry.md
  6. 5 0
      .changeset/remove-clipboard-reading.md
  7. 1 1
      .devcontainer/Dockerfile
  8. 1 1
      .github/workflows/build-cli.yml
  9. 1 1
      .github/workflows/changeset-release.yml
  10. 1 1
      .github/workflows/cli-publish.yml
  11. 1 1
      .github/workflows/code-qa.yml
  12. 1 1
      .github/workflows/docusaurus-build.yml
  13. 1 1
      .github/workflows/marketplace-publish.yml
  14. 1 1
      .github/workflows/storybook-playwright-snapshot.yml
  15. 58 38
      .kilocodemodes
  16. 1 1
      .nvmrc
  17. 1 1
      .tool-versions
  18. 1 1
      DEVELOPMENT.md
  19. 3 1
      apps/kilocode-docs/docs/agent-behavior/skills.md
  20. 136 0
      apps/kilocode-docs/docs/cli.md
  21. 0 1
      apps/kilocode-docs/docs/providers/cerebras.md
  22. 2 2
      cli/Dockerfile
  23. 1 1
      cli/npm-shrinkwrap.dist.json
  24. 1 1
      cli/package.dist.json
  25. 1 1
      cli/package.json
  26. 667 0
      cli/src/commands/__tests__/custom.test.ts
  27. 189 0
      cli/src/commands/custom.ts
  28. 1 1
      cli/src/commands/index.ts
  29. 6 2
      cli/src/ui/UI.tsx
  30. 1 1
      package.json
  31. 2 2
      packages/core-schemas/src/auth/kilocode.ts
  32. 1 1
      packages/evals/README.md
  33. 3 3
      packages/evals/scripts/setup.sh
  34. 0 11
      packages/types/src/providers/cerebras.ts
  35. 6 3
      src/core/assistant-message/NativeToolCallParser.ts
  36. 73 10
      src/core/assistant-message/presentAssistantMessage.ts
  37. 4 0
      src/core/task/Task.ts
  38. 90 0
      src/core/task/__tests__/validateToolResultIds.spec.ts
  39. 36 12
      src/core/task/validateToolResultIds.ts
  40. 3 0
      src/core/tools/ExecuteCommandTool.ts
  41. 1 1
      src/package.json
  42. 0 19
      src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts
  43. 1 1
      src/test-llm-autocompletion/package.json

+ 10 - 0
.changeset/approval-feedback-fix.md

@@ -0,0 +1,10 @@
+---
+"kilo-code": patch
+---
+
+Fix duplicate tool_result blocks when users approve tool execution with feedback text
+
+Cherry-picked from upstream Roo-Code:
+
+- [#10466](https://github.com/RooCodeInc/Roo-Code/pull/10466) - Add explicit deduplication (thanks @daniel-lxs)
+- [#10519](https://github.com/RooCodeInc/Roo-Code/pull/10519) - Merge approval feedback into tool result (thanks @daniel-lxs)

+ 5 - 0
.changeset/cuddly-candles-protect.md

@@ -0,0 +1,5 @@
+---
+"kilocode-docs": patch
+---
+
+Remove deprecated zai-glm-4.6 model from Cerebras provider due to deprecation

+ 5 - 0
.changeset/fix-zod-function-api.md

@@ -0,0 +1,5 @@
+---
+"@kilocode/core-schemas": patch
+---
+
+Fix Zod function API usage in pollingOptionsSchema

+ 5 - 0
.changeset/friendly-jars-yawn.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Improved the reliability of the read_file tool when using Claude models

+ 5 - 0
.changeset/metal-sheep-fry.md

@@ -0,0 +1,5 @@
+---
+"@kilocode/cli": minor
+---
+
+add custom commands support

+ 5 - 0
.changeset/remove-clipboard-reading.md

@@ -0,0 +1,5 @@
+---
+"kilo-code": patch
+---
+
+Remove clipboard reading from chat autocomplete

+ 1 - 1
.devcontainer/Dockerfile

@@ -2,7 +2,7 @@
 # Based on flake.nix dependencies for standardized development environment
 
 # Use official Node.js image matching .nvmrc version
-FROM node:20.19.2-bullseye
+FROM node:20.20.0-bullseye
 
 # Install system dependencies (matching flake.nix packages)
 RUN apt-get update && apt-get install -y \

+ 1 - 1
.github/workflows/build-cli.yml

@@ -7,7 +7,7 @@ on:
     workflow_dispatch:
 env:
     GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
     TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

+ 1 - 1
.github/workflows/changeset-release.yml

@@ -9,7 +9,7 @@ on:
 env:
     REPO_PATH: ${{ github.repository }}
     GIT_REF: main
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
 
 jobs:

+ 1 - 1
.github/workflows/cli-publish.yml

@@ -8,7 +8,7 @@ env:
     GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
     TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     DOCKER_IMAGE_NAME: kiloai/cli
     DOCKER_PLATFORMS: linux/amd64,linux/arm64

+ 1 - 1
.github/workflows/code-qa.yml

@@ -9,7 +9,7 @@ on:
         branches: [main]
 
 env:
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
     TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

+ 1 - 1
.github/workflows/docusaurus-build.yml

@@ -1,7 +1,7 @@
 name: Docusaurus Build Check
 
 env:
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
 
 on:

+ 1 - 1
.github/workflows/marketplace-publish.yml

@@ -6,7 +6,7 @@ on:
 
 env:
     GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
     TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

+ 1 - 1
.github/workflows/storybook-playwright-snapshot.yml

@@ -21,7 +21,7 @@ concurrency:
 env:
     DOCKER_BUILDKIT: 1
     COMPOSE_DOCKER_CLI_BUILD: 1
-    NODE_VERSION: 20.19.2
+    NODE_VERSION: 20.20.0
     PNPM_VERSION: 10.8.1
     SECRETS_SET: ${{ secrets.OPENROUTER_API_KEY != '' }}
     TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}

+ 58 - 38
.kilocodemodes

@@ -1,38 +1,58 @@
-{
-	"customModes": [
-		{
-			"slug": "translate",
-			"name": "Translate",
-			"roleDefinition": "You are Kilo Code, a linguistic specialist focused on translating and managing localization files. Your responsibility is to help maintain and update translation files for the application, ensuring consistency and accuracy across all language resources.",
-			"groups": [
-				"read",
-				[
-					"edit",
-					{
-						"fileRegex": "((src/i18n/locales/)|(src/package\\.nls(\\.\\w+)?\\.json))",
-						"description": "Translation files only"
-					}
-				]
-			],
-			"customInstructions": "When translating content:\n- Maintain consistent terminology across all translations\n- Respect the JSON structure of translation files\n- Consider context when translating UI strings\n- Watch for placeholders (like {{variable}}) and preserve them in translations\n- Be mindful of text length in UI elements when translating to languages that might require more characters\n- If you need context for a translation, use read_file to examine the components using these strings\n- Specifically \"Kilo\", \"Kilo Code\" and similar terms are project names and proper nouns and must remain unchanged in translations"
-		},
-		{
-			"slug": "test",
-			"name": "Test",
-			"roleDefinition": "You are Kilo Code, a Jest testing specialist with deep expertise in:\n- Writing and maintaining Jest test suites\n- Test-driven development (TDD) practices\n- Mocking and stubbing with Jest\n- Integration testing strategies\n- TypeScript testing patterns\n- Code coverage analysis\n- Test performance optimization\n\nYour focus is on maintaining high test quality and coverage across the codebase, working primarily with:\n- Test files in __tests__ directories\n- Mock implementations in __mocks__\n- Test utilities and helpers\n- Jest configuration and setup\n\nYou ensure tests are:\n- Well-structured and maintainable\n- Following Jest best practices\n- Properly typed with TypeScript\n- Providing meaningful coverage\n- Using appropriate mocking strategies",
-			"groups": [
-				"read",
-				"browser",
-				"command",
-				[
-					"edit",
-					{
-						"fileRegex": "(__tests__/.*|__mocks__/.*|\\.test\\.(ts|tsx|js|jsx)$|/test/.*|jest\\.config\\.(js|ts)$)",
-						"description": "Test files, mocks, and Jest configuration"
-					}
-				]
-			],
-			"customInstructions": "When writing tests:\n- Always use describe/it blocks for clear test organization\n- Include meaningful test descriptions\n- Use beforeEach/afterEach for proper test isolation\n- Implement proper error cases\n- Add JSDoc comments for complex test scenarios\n- Ensure mocks are properly typed\n- Verify both positive and negative test cases"
-		}
-	]
-}
+customModes:
+  - slug: translate
+    name: Translate
+    roleDefinition: You are Kilo Code, a linguistic specialist focused on translating and managing localization files. Your responsibility is to help maintain and update translation files for the application, ensuring consistency and accuracy across all language resources.
+    groups:
+      - read
+      - - edit
+        - fileRegex: ((src/i18n/locales/)|(src/package\.nls(\.\w+)?\.json))
+          description: Translation files only
+    customInstructions: |-
+      When translating content:
+      - Maintain consistent terminology across all translations
+      - Respect the JSON structure of translation files
+      - Consider context when translating UI strings
+      - Watch for placeholders (like {{variable}}) and preserve them in translations
+      - Be mindful of text length in UI elements when translating to languages that might require more characters
+      - If you need context for a translation, use read_file to examine the components using these strings
+      - Specifically "Kilo", "Kilo Code" and similar terms are project names and proper nouns and must remain unchanged in translations
+  - slug: test
+    name: Test
+    roleDefinition: |-
+      You are Kilo Code, a Jest testing specialist with deep expertise in:
+      - Writing and maintaining Jest test suites
+      - Test-driven development (TDD) practices
+      - Mocking and stubbing with Jest
+      - Integration testing strategies
+      - TypeScript testing patterns
+      - Code coverage analysis
+      - Test performance optimization
+
+      Your focus is on maintaining high test quality and coverage across the codebase, working primarily with:
+      - Test files in __tests__ directories
+      - Mock implementations in __mocks__
+      - Test utilities and helpers
+      - Jest configuration and setup
+
+      You ensure tests are:
+      - Well-structured and maintainable
+      - Following Jest best practices
+      - Properly typed with TypeScript
+      - Providing meaningful coverage
+      - Using appropriate mocking strategies
+    groups:
+      - read
+      - browser
+      - command
+      - - edit
+        - fileRegex: (__tests__/.*|__mocks__/.*|\.test\.(ts|tsx|js|jsx)$|/test/.*|jest\.config\.(js|ts)$)
+          description: Test files, mocks, and Jest configuration
+    customInstructions: |-
+      When writing tests:
+      - Always use describe/it blocks for clear test organization
+      - Include meaningful test descriptions
+      - Use beforeEach/afterEach for proper test isolation
+      - Implement proper error cases
+      - Add JSDoc comments for complex test scenarios
+      - Ensure mocks are properly typed
+      - Verify both positive and negative test cases

+ 1 - 1
.nvmrc

@@ -1 +1 @@
-v20.19.2
+v20.20.0

+ 1 - 1
.tool-versions

@@ -1 +1 @@
-nodejs 20.19.2
+nodejs 20.20.0

+ 1 - 1
DEVELOPMENT.md

@@ -10,7 +10,7 @@ Before you begin, choose one of the following development environment options:
 
 1. **Git** - For version control
 2. **Git LFS** - For large file storage (https://git-lfs.com/) - Required for handling GIF, MP4, and other binary assets
-3. **Node.js** (version [v20.19.2](https://github.com/Kilo-Org/kilocode/blob/main/.nvmrc) recommended)
+3. **Node.js** (version [v20.20.0](https://github.com/Kilo-Org/kilocode/blob/main/.nvmrc) recommended)
 4. **pnpm** - Package manager (https://pnpm.io/)
 5. **Visual Studio Code** - Our recommended IDE for development
 

+ 3 - 1
apps/kilocode-docs/docs/agent-behavior/skills.md

@@ -34,7 +34,9 @@ Skills are loaded from multiple locations, allowing both personal skills and pro
 
 ### Global Skills (User-Level)
 
-Located in `~/.kilocode/skills/`:
+Global skills are located in the `.kilocode` directory within your Home directory. 
+* Mac and Linux: `~/.kilocode/skills/`
+* Windows: `\Users\<yourUser>\.kilocode\`
 
 ```
 ~/.kilocode/

+ 136 - 0
apps/kilocode-docs/docs/cli.md

@@ -141,6 +141,142 @@ There are community efforts to build and share agent skills. Some resources incl
 - [Skills Marketplace](https://skillsmp.com/) - Community marketplace of skills
 - [Skill Specification](https://agentskills.io/home) - Agent Skills specification
 
+## Custom Commands
+
+Custom commands allow you to create reusable slash commands that execute predefined prompts with argument substitution. They provide a convenient way to streamline repetitive tasks and standardize workflows.
+
+Custom commands are discovered from:
+
+- **Global commands**: `~/.kilocode/commands/` (available in all projects)
+- **Project commands**: `.kilocode/commands/` (project-specific)
+
+Commands are simple markdown files with YAML frontmatter for configuration.
+
+### Creating a Custom Command
+
+1. Create the commands directory:
+
+    ```bash
+    mkdir -p ~/.kilocode/commands # mkdir %USERPROFILE%\.kilocode\commands on windows
+    ```
+
+2. Create a markdown file (e.g., `component.md`):
+
+    ```markdown
+    ---
+    description: Create a new React component
+    arguments:
+        - ComponentName
+    ---
+
+    Create a new React component named $1.
+    Include:
+
+    - Proper TypeScript typing
+    - Basic component structure
+    - Export statement
+    - A simple props interface if appropriate
+
+    Place it in the appropriate directory based on the project structure.
+    ```
+
+3. Use the command in your CLI session:
+
+    ```bash
+    /component Button
+    ```
+
+### Frontmatter Options
+
+Custom commands support the following frontmatter fields:
+
+- **`description`** (optional): Short description shown in `/help`
+- **`arguments`** (optional): List of argument names for documentation
+- **`mode`** (optional): Automatically switch to this mode when running the command
+- **`model`** (optional): Automatically switch to this model when running the command
+
+### Argument Substitution
+
+Commands support powerful argument substitution:
+
+- **`$ARGUMENTS`**: All arguments joined with spaces
+- **`$1`, `$2`, `$3`, etc.**: Individual positional arguments
+
+**Example:**
+
+```markdown
+---
+description: Create a file with content
+arguments:
+    - filename
+    - content
+---
+
+Create a new file named $1 with the following content:
+
+$2
+```
+
+Usage: `/createfile app.ts "console.log('Hello')"`
+
+### Mode and Model Switching
+
+Commands can automatically switch modes and models:
+
+```markdown
+---
+description: Run tests with coverage
+mode: code
+model: anthropic/claude-3-5-sonnet-20241022
+---
+
+Run the full test suite with coverage report and show any failures.
+Focus on the failing tests and suggest fixes.
+```
+
+When you run `/test`, it will automatically switch to code mode and use the specified model.
+
+### Example Commands
+
+**Initialize project documentation:**
+
+```markdown
+---
+description: Analyze codebase and create AGENTS.md
+mode: code
+---
+
+Please analyze this codebase and create an AGENTS.md file containing:
+
+1. Build/lint/test commands - especially for running a single test
+2. Code style guidelines including imports, formatting, types, naming conventions
+
+Focus on project-specific, non-obvious information discovered by reading files.
+```
+
+**Refactor code:**
+
+```markdown
+---
+description: Refactor code for better quality
+arguments:
+    - filepath
+---
+
+Refactor $1 to improve:
+
+- Code readability
+- Performance
+- Maintainability
+- Type safety
+
+Explain the changes you make and why they improve the code.
+```
+
+### Command Priority
+
+Project-specific commands override global commands with the same name, allowing you to customize behavior per project while maintaining sensible defaults globally.
+
 ## Checkpoint Management
 
 Kilo Code automatically creates checkpoints as you work, allowing you to revert to previous states in your project's history.

+ 0 - 1
apps/kilocode-docs/docs/providers/cerebras.md

@@ -20,7 +20,6 @@ Cerebras is known for their ultra-fast AI inference powered by the Cerebras CS-3
 Kilo Code supports the following Cerebras models:
 
 - `gpt-oss-120b` (Default) – High-performance open-source model optimized for fast inference
-- `zai-glm-4.6` – Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.
 - `zai-glm-4.7` – Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.
 
 Refer to the [Cerebras documentation](https://docs.cerebras.ai/) for detailed information on model capabilities and performance characteristics.

+ 2 - 2
cli/Dockerfile

@@ -6,7 +6,7 @@ ARG VERSION
 # ==========================================
 # 1. Alpine Base 
 # ==========================================
-FROM node:20.19.2-alpine AS alpine_base
+FROM node:20.20.0-alpine AS alpine_base
 
 # Install dependencies
 RUN apk add --no-cache \
@@ -32,7 +32,7 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
 # ==========================================
 # 2. Debian Base
 # ==========================================
-FROM node:20.19.2-bookworm-slim AS debian_base
+FROM node:20.20.0-bookworm-slim AS debian_base
 
 # Install system dependencies
 RUN apt-get update && apt-get install -y \

+ 1 - 1
cli/npm-shrinkwrap.dist.json

@@ -119,7 +119,7 @@
 				"kilocode": "index.js"
 			},
 			"engines": {
-				"node": ">=20.19.2"
+				"node": ">=20.20.0"
 			}
 		},
 		"node_modules/@alcalzone/ansi-tokenize": {

+ 1 - 1
cli/package.dist.json

@@ -110,7 +110,7 @@
 		}
 	},
 	"engines": {
-		"node": ">=20.19.2"
+		"node": ">=20.20.0"
 	},
 	"keywords": ["cli", "tui", "terminal", "ai", "assistant", "kilocode", "kilo", "ink"],
 	"author": {

+ 1 - 1
cli/package.json

@@ -166,7 +166,7 @@
 		"vitest": "^4.0.16"
 	},
 	"engines": {
-		"node": ">=20.19.2"
+		"node": ">=20.20.0"
 	},
 	"keywords": [
 		"cli",

+ 667 - 0
cli/src/commands/__tests__/custom.test.ts

@@ -0,0 +1,667 @@
+/**
+ * Tests for custom commands
+ */
+
+import { describe, it, expect, beforeEach, vi } from "vitest"
+import { substituteArguments, getCustomCommands, initializeCustomCommands } from "../custom.js"
+import * as path from "path"
+import { createMockContext } from "./helpers/mockContext.js"
+import type { Command } from "../core/types.js"
+import type { Dirent } from "fs"
+
+/** Minimal mock for fs.Dirent used in readdir results */
+type MockDirent = Pick<Dirent, "name" | "isFile" | "isDirectory">
+
+// Hoist mock functions so they're available during module mocking
+const { mockReaddir, mockReadFile, mockHomedir, mockRegister } = vi.hoisted(() => ({
+	mockReaddir: vi.fn<(path: string) => Promise<MockDirent[]>>(),
+	mockReadFile: vi.fn<(path: string) => Promise<string>>(),
+	mockHomedir: vi.fn<() => string>(),
+	mockRegister: vi.fn(),
+}))
+
+vi.mock("fs/promises", () => ({
+	default: {
+		readdir: mockReaddir,
+		readFile: mockReadFile,
+	},
+	readdir: mockReaddir,
+	readFile: mockReadFile,
+}))
+
+vi.mock("os", () => ({
+	default: {
+		homedir: mockHomedir,
+	},
+	homedir: mockHomedir,
+}))
+
+vi.mock("../core/registry.js", () => ({
+	commandRegistry: {
+		register: mockRegister,
+		get: vi.fn(() => undefined), // Return undefined to indicate command doesn't exist
+	},
+}))
+
+vi.mock("../services/logs.js", () => ({
+	logs: {
+		debug: vi.fn(),
+		warn: vi.fn(),
+	},
+}))
+
+describe("Custom Commands", () => {
+	describe("substituteArguments", () => {
+		it("should replace $ARGUMENTS with all arguments", () => {
+			const content = "Process $ARGUMENTS"
+			const args = ["file1.txt", "file2.txt", "file3.txt"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Process file1.txt file2.txt file3.txt")
+		})
+
+		it("should replace positional arguments $1, $2, $3", () => {
+			const content = "Copy $1 to $2 with mode $3"
+			const args = ["source.txt", "dest.txt", "overwrite"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Copy source.txt to dest.txt with mode overwrite")
+		})
+
+		it("should handle both $ARGUMENTS and positional arguments", () => {
+			const content = "First arg is $1, all args are: $ARGUMENTS"
+			const args = ["alpha", "beta", "gamma"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("First arg is alpha, all args are: alpha beta gamma")
+		})
+
+		it("should handle empty arguments", () => {
+			const content = "No args: $ARGUMENTS and $1"
+			const args: string[] = []
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("No args:  and $1")
+		})
+
+		it("should not replace undefined positional arguments", () => {
+			const content = "Args: $1 $2 $3"
+			const args = ["first"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Args: first $2 $3")
+		})
+
+		it("should handle content with no placeholders", () => {
+			const content = "Plain text content"
+			const args = ["arg1", "arg2"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Plain text content")
+		})
+
+		it("should not replace currency amounts with decimals like $1.50", () => {
+			const content = "The price is $1.50 for item $1"
+			const args = ["widget"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("The price is $1.50 for item widget")
+		})
+
+		it("should not replace $1 when it's part of $100", () => {
+			const content = "Price is $100 and description is $1"
+			const args = ["expensive item"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Price is $100 and description is expensive item")
+		})
+
+		it("should not replace $2 when it's part of $25.99", () => {
+			const content = "Cost: $25.99, item: $2, quantity: $1"
+			const args = ["5", "hammer"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Cost: $25.99, item: hammer, quantity: 5")
+		})
+
+		it("should replace $1 at end of sentence with period", () => {
+			const content = "Process file $1."
+			const args = ["test.txt"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Process file test.txt.")
+		})
+
+		it("should handle multiple currency amounts and placeholders", () => {
+			const content = "Budget is $1000.50, allocate $1 to $2 with $3 priority"
+			const args = ["$500", "project-a", "high"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Budget is $1000.50, allocate $500 to project-a with high priority")
+		})
+
+		it("should not replace positional args in larger numbers", () => {
+			const content = "Total: $123.45, items: $1, $2, $3"
+			const args = ["apple", "banana", "cherry"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Total: $123.45, items: apple, banana, cherry")
+		})
+
+		it("should handle edge case with $10 when args has one element", () => {
+			const content = "Price is $10 and name is $1"
+			const args = ["product"]
+
+			const result = substituteArguments(content, args)
+
+			expect(result).toBe("Price is $10 and name is product")
+		})
+	})
+
+	describe("getCustomCommands", () => {
+		const mockCwd = "/mock/project"
+		const mockHomeDir = "/mock/home"
+
+		beforeEach(() => {
+			vi.clearAllMocks()
+			mockHomedir.mockReturnValue(mockHomeDir)
+		})
+
+		it("should load commands from global directory", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockImplementation(async (dirPath: string) => {
+				if (dirPath === path.join(mockHomeDir, ".kilocode", "commands")) {
+					return mockFiles
+				}
+				throw new Error("ENOENT")
+			})
+
+			mockReadFile.mockResolvedValue(`---
+description: Test command
+arguments: [arg1, arg2]
+---
+Test content with $1 and $2`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("test-command")
+			expect(commands[0].description).toBe("Test command")
+			expect(commands[0].arguments).toEqual(["arg1", "arg2"])
+			expect(commands[0].content).toBe("Test content with $1 and $2")
+		})
+
+		it("should load commands from project directory with priority", async () => {
+			const globalFiles: MockDirent[] = [
+				{
+					name: "shared-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			const projectFiles: MockDirent[] = [
+				{
+					name: "shared-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "project-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockImplementation(async (dirPath: string) => {
+				if (dirPath === path.join(mockHomeDir, ".kilocode", "commands")) {
+					return globalFiles
+				}
+				if (dirPath === path.join(mockCwd, ".kilocode", "commands")) {
+					return projectFiles
+				}
+				throw new Error("ENOENT")
+			})
+
+			mockReadFile.mockImplementation(async (filePath: string) => {
+				if (filePath.includes("project")) {
+					return `---
+description: Project version
+---
+Project content`
+				}
+				return `---
+description: Global version
+---
+Global content`
+			})
+
+			const commands = await getCustomCommands(mockCwd)
+
+			// Should have 2 commands total (shared-command from project overrides global)
+			expect(commands).toHaveLength(2)
+
+			const sharedCommand = commands.find((c) => c.name === "shared-command")
+			expect(sharedCommand?.description).toBe("Project version")
+			expect(sharedCommand?.content).toBe("Project content")
+		})
+
+		it("should skip non-markdown files", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "readme.txt",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "config.json",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Valid command
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("command")
+		})
+
+		it("should handle missing directories gracefully", async () => {
+			mockReaddir.mockRejectedValue(new Error("ENOENT"))
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(0)
+		})
+
+		it("should parse mode and model from frontmatter", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test command
+mode: plan
+model: opus
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands[0].mode).toBe("plan")
+			expect(commands[0].model).toBe("opus")
+		})
+
+		it("should skip files with invalid command names starting with --", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "--test.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "valid.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("valid")
+		})
+
+		it("should skip files with invalid command names starting with -", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "-test.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "valid-name.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("valid-name")
+		})
+
+		it("should skip files with special characters in names", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test!.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "[email protected]",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "test$var.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "valid123.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(1)
+			expect(commands[0].name).toBe("valid123")
+		})
+
+		it("should accept valid command names with alphanumeric and hyphens", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test-command.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "my-command-123.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+				{
+					name: "ABC-xyz-999.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test
+---
+Content`)
+
+			const commands = await getCustomCommands(mockCwd)
+
+			expect(commands).toHaveLength(3)
+			expect(commands.map((c) => c.name).sort()).toEqual(["ABC-xyz-999", "my-command-123", "test-command"])
+		})
+	})
+
+	describe("initializeCustomCommands", () => {
+		const mockCwd = "/mock/project"
+
+		beforeEach(() => {
+			vi.clearAllMocks()
+			mockHomedir.mockReturnValue("/mock/home")
+		})
+
+		it("should register custom commands", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test command
+---
+Test content`)
+
+			await initializeCustomCommands(mockCwd)
+
+			expect(mockRegister).toHaveBeenCalledTimes(1)
+			expect(mockRegister).toHaveBeenCalledWith(
+				expect.objectContaining({
+					name: "test",
+					description: "Test command",
+					category: "chat",
+					priority: 3,
+				}),
+			)
+		})
+
+		it("should handle errors gracefully", async () => {
+			mockReaddir.mockRejectedValue(new Error("Permission denied"))
+
+			// Should not throw
+			await expect(initializeCustomCommands(mockCwd)).resolves.not.toThrow()
+		})
+
+		it("should not register commands if none found", async () => {
+			mockReaddir.mockRejectedValue(new Error("ENOENT"))
+
+			await initializeCustomCommands(mockCwd)
+
+			expect(mockRegister).not.toHaveBeenCalled()
+		})
+	})
+
+	describe("custom command handler", () => {
+		const mockCwd = "/mock/project"
+
+		beforeEach(() => {
+			vi.clearAllMocks()
+			mockHomedir.mockReturnValue("/mock/home")
+		})
+
+		async function getRegisteredHandler(): Promise<Command["handler"]> {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "test-cmd.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Test command
+mode: architect
+model: opus
+arguments: [file, destination]
+---
+Process $1 to $2 with $ARGUMENTS`)
+
+			await initializeCustomCommands(mockCwd)
+
+			expect(mockRegister).toHaveBeenCalledTimes(1)
+			const registeredCommand = mockRegister.mock.calls[0][0] as Command
+			return registeredCommand.handler
+		}
+
+		it("should call setMode when custom command has mode", async () => {
+			const handler = await getRegisteredHandler()
+			const mockContext = createMockContext({
+				args: ["input.txt", "output.txt"],
+			})
+
+			await handler(mockContext)
+
+			expect(mockContext.setMode).toHaveBeenCalledWith("architect")
+		})
+
+		it("should call updateProviderModel when custom command has model", async () => {
+			const handler = await getRegisteredHandler()
+			const mockContext = createMockContext({
+				args: ["input.txt", "output.txt"],
+			})
+
+			await handler(mockContext)
+
+			expect(mockContext.updateProviderModel).toHaveBeenCalledWith("opus")
+		})
+
+		it("should handle updateProviderModel errors gracefully", async () => {
+			const handler = await getRegisteredHandler()
+			const mockUpdateProviderModel = vi.fn().mockRejectedValue(new Error("Model not available"))
+			const mockContext = createMockContext({
+				args: ["input.txt", "output.txt"],
+				updateProviderModel: mockUpdateProviderModel,
+			})
+
+			// Should not throw
+			await expect(handler(mockContext)).resolves.not.toThrow()
+
+			expect(mockUpdateProviderModel).toHaveBeenCalledWith("opus")
+			// Should still send the message even if model switch fails
+			expect(mockContext.sendWebviewMessage).toHaveBeenCalled()
+		})
+
+		it("should call sendWebviewMessage with processed content", async () => {
+			const handler = await getRegisteredHandler()
+			const mockContext = createMockContext({
+				args: ["input.txt", "output.txt"],
+			})
+
+			await handler(mockContext)
+
+			expect(mockContext.sendWebviewMessage).toHaveBeenCalledWith({
+				type: "newTask",
+				text: "Process input.txt to output.txt with input.txt output.txt",
+			})
+		})
+
+		it("should not call setMode when custom command has no mode", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "no-mode.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Command without mode
+---
+Simple content`)
+
+			await initializeCustomCommands(mockCwd)
+
+			const registeredCommand = mockRegister.mock.calls[0][0] as Command
+			const mockContext = createMockContext({ args: [] })
+
+			await registeredCommand.handler(mockContext)
+
+			expect(mockContext.setMode).not.toHaveBeenCalled()
+		})
+
+		it("should not call updateProviderModel when custom command has no model", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "no-model.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Command without model
+---
+Simple content`)
+
+			await initializeCustomCommands(mockCwd)
+
+			const registeredCommand = mockRegister.mock.calls[0][0] as Command
+			const mockContext = createMockContext({ args: [] })
+
+			await registeredCommand.handler(mockContext)
+
+			expect(mockContext.updateProviderModel).not.toHaveBeenCalled()
+		})
+
+		it("should substitute arguments in content before sending", async () => {
+			const mockFiles: MockDirent[] = [
+				{
+					name: "substitute.md",
+					isFile: () => true,
+					isDirectory: () => false,
+				},
+			]
+
+			mockReaddir.mockResolvedValue(mockFiles)
+			mockReadFile.mockResolvedValue(`---
+description: Substitution test
+---
+First: $1, Second: $2, All: $ARGUMENTS`)
+
+			await initializeCustomCommands(mockCwd)
+
+			const registeredCommand = mockRegister.mock.calls[0][0] as Command
+			const mockContext = createMockContext({
+				args: ["alpha", "beta", "gamma"],
+			})
+
+			await registeredCommand.handler(mockContext)
+
+			expect(mockContext.sendWebviewMessage).toHaveBeenCalledWith({
+				type: "newTask",
+				text: "First: alpha, Second: beta, All: alpha beta gamma",
+			})
+		})
+	})
+})

+ 189 - 0
cli/src/commands/custom.ts

@@ -0,0 +1,189 @@
+/**
+ * Custom commands - loads markdown-based commands from ~/.kilocode/commands/ and .kilocode/commands/
+ */
+
+import fs from "fs/promises"
+import * as path from "path"
+import matter from "gray-matter"
+import * as os from "os"
+import { commandRegistry } from "./core/registry.js"
+import type { Command, CommandHandler } from "./core/types.js"
+import { logs } from "../services/logs.js"
+
+/**
+ * Custom command definition loaded from markdown files
+ */
+export interface CustomCommand {
+	name: string
+	content: string
+	filePath: string
+	description?: string
+	arguments?: string[]
+	mode?: string
+	model?: string
+}
+
+/**
+ * Validates that a command name contains only alphanumeric characters and hyphens,
+ * and starts with an alphanumeric character
+ */
+function isValidCommandName(name: string): boolean {
+	return /^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(name)
+}
+
+async function scanCommandDirectory(dirPath: string, commands: Map<string, CustomCommand>): Promise<void> {
+	try {
+		const entries = await fs.readdir(dirPath, { withFileTypes: true })
+
+		for (const entry of entries) {
+			if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) continue
+
+			const commandName = entry.name.slice(0, -3)
+			const filePath = path.join(dirPath, entry.name)
+
+			// Validate command name format.
+			if (!isValidCommandName(commandName)) {
+				logs.warn(
+					`Skipping invalid command name "${commandName}" - must start with alphanumeric and contain only alphanumeric characters and hyphens`,
+					"CustomCommand",
+				)
+				continue
+			}
+
+			try {
+				const content = await fs.readFile(filePath, "utf-8")
+				const parsed = matter(content)
+
+				const description = typeof parsed.data.description === "string" ? parsed.data.description.trim() : ""
+				const mode = typeof parsed.data.mode === "string" ? parsed.data.mode.trim() : ""
+				const model = typeof parsed.data.model === "string" ? parsed.data.model.trim() : ""
+
+				// Parse arguments list
+				let args: string[] | undefined
+				if (Array.isArray(parsed.data.arguments)) {
+					args = parsed.data.arguments
+						.filter((arg) => typeof arg === "string" && arg.trim())
+						.map((arg) => arg.trim())
+				}
+
+				const command: CustomCommand = {
+					name: commandName,
+					content: parsed.content.trim(),
+					filePath,
+				}
+
+				if (description) command.description = description
+				if (args && args.length > 0) command.arguments = args
+				if (mode) command.mode = mode
+				if (model) command.model = model
+
+				commands.set(commandName, command)
+			} catch (error) {
+				logs.warn(`Failed to parse custom command file: ${filePath}`, "CustomCommand", { error })
+			}
+		}
+	} catch (error) {
+		const code = (error as NodeJS.ErrnoException)?.code
+		if (code !== "ENOENT") {
+			logs.warn(`Failed to scan command directory: ${dirPath}`, "CustomCommand", { error })
+		}
+	}
+}
+
+/**
+ * Substitute arguments in command content
+ * Supports: $ARGUMENTS (all args), $1, $2, $3, etc. (positional args)
+ */
+export function substituteArguments(content: string, args: string[]): string {
+	return content.replace(/\$ARGUMENTS\b/g, args.join(" ")).replace(/\$(\d+)\b(?!\.?\d)/g, (match, num): string => {
+		const index = parseInt(num, 10) - 1
+		return index >= 0 && index < args.length ? args[index]! : match
+	})
+}
+
+function createCustomCommandHandler(customCommand: CustomCommand): CommandHandler {
+	return async (context) => {
+		const { args, setMode, updateProviderModel, sendWebviewMessage } = context
+
+		if (customCommand.mode) {
+			setMode(customCommand.mode)
+		}
+
+		if (customCommand.model) {
+			try {
+				await updateProviderModel(customCommand.model)
+			} catch (error) {
+				logs.warn(`Failed to switch to model ${customCommand.model}`, "CustomCommand", { error })
+			}
+		}
+
+		const processedContent = substituteArguments(customCommand.content, args)
+
+		await sendWebviewMessage({
+			type: "newTask",
+			text: processedContent,
+		})
+	}
+}
+
+function customCommandToCliCommand(customCommand: CustomCommand): Command {
+	return {
+		name: customCommand.name,
+		aliases: [],
+		description: customCommand.description || `Custom command: ${customCommand.name}`,
+		usage: customCommand.arguments
+			? `/${customCommand.name} ${customCommand.arguments.map((arg) => `<${arg}>`).join(" ")}`
+			: `/${customCommand.name}`,
+		examples: [`/${customCommand.name}`],
+		category: "chat",
+		handler: createCustomCommandHandler(customCommand),
+		priority: 3,
+		...(customCommand.arguments && {
+			arguments: customCommand.arguments.map((argument) => ({
+				name: argument,
+				description: "",
+				required: false,
+			})),
+		}),
+	}
+}
+
+/**
+ * Load custom commands from ~/.kilocode/commands/ and .kilocode/commands/
+ * Priority: project > global
+ */
+export async function getCustomCommands(cwd: string): Promise<CustomCommand[]> {
+	const commands = new Map<string, CustomCommand>()
+
+	const globalDir = path.join(os.homedir(), ".kilocode", "commands")
+	await scanCommandDirectory(globalDir, commands)
+
+	const projectDir = path.join(cwd, ".kilocode", "commands")
+	await scanCommandDirectory(projectDir, commands)
+
+	return Array.from(commands.values())
+}
+
+/**
+ * Initialize custom commands from markdown files
+ * Call this after built-in commands are initialized
+ */
+export async function initializeCustomCommands(cwd: string): Promise<void> {
+	try {
+		const customCommands = await getCustomCommands(cwd)
+
+		for (const customCommand of customCommands) {
+			if (commandRegistry.get(customCommand.name)) {
+				logs.warn(`Custom command "${customCommand.name}" conflicts with an existing command`, "CustomCommand")
+				continue
+			}
+			commandRegistry.register(customCommandToCliCommand(customCommand))
+		}
+
+		if (customCommands.length > 0) {
+			logs.debug(`Loaded ${customCommands.length} custom command(s)`, "CustomCommand")
+		}
+	} catch (error) {
+		logs.warn("Failed to load custom commands", "CustomCommand", { error })
+	}
+}

+ 1 - 1
cli/src/commands/index.ts

@@ -24,7 +24,7 @@ import { sessionCommand } from "./session.js"
 import { condenseCommand } from "./condense.js"
 
 /**
- * Initialize all commands
+ * Initialize all built-in commands
  */
 export function initializeCommands(): void {
 	// Register all commands

+ 6 - 2
cli/src/ui/UI.tsx

@@ -20,6 +20,7 @@ import { CommandInput } from "./components/CommandInput.js"
 import { StatusBar } from "./components/StatusBar.js"
 import { StatusIndicator } from "./components/StatusIndicator.js"
 import { initializeCommands } from "../commands/index.js"
+import { initializeCustomCommands } from "../commands/custom.js"
 import { isCommandInput } from "../services/autocomplete.js"
 import { useCommandHandler } from "../state/hooks/useCommandHandler.js"
 import { useMessageHandler } from "../state/hooks/useMessageHandler.js"
@@ -40,7 +41,7 @@ import { useTerminal } from "../state/hooks/useTerminal.js"
 import { exitRequestCounterAtom } from "../state/atoms/keyboard.js"
 import { useWebviewMessage } from "../state/hooks/useWebviewMessage.js"
 
-// Initialize commands on module load
+// Initialize built-in commands on module load
 initializeCommands()
 
 interface UIAppProps {
@@ -143,11 +144,14 @@ export const UI: React.FC<UIAppProps> = ({ options, onExit }) => {
 		}
 	}, [options.parallel, setIsParallelMode])
 
-	// Initialize workspace path for shell commands
+	// Initialize workspace path for shell commands and load custom commands
 	useEffect(() => {
+		const workspace = options.workspace || process.cwd()
 		if (options.workspace) {
 			setWorkspacePath(options.workspace)
 		}
+		// Load custom commands from ~/.kilocode/commands/ and .kilocode/commands/
+		void initializeCustomCommands(workspace)
 	}, [options.workspace, setWorkspacePath])
 
 	// Handle CI mode exit

+ 1 - 1
package.json

@@ -2,7 +2,7 @@
 	"name": "kilo-code",
 	"packageManager": "[email protected]",
 	"engines": {
-		"node": "20.19.2"
+		"node": "20.20.0"
 	},
 	"scripts": {
 		"preinstall": "node scripts/bootstrap.mjs",

+ 2 - 2
packages/core-schemas/src/auth/kilocode.ts

@@ -33,9 +33,9 @@ export const pollingOptionsSchema = z.object({
 	/** Maximum number of attempts before timeout */
 	maxAttempts: z.number(),
 	/** Function to execute on each poll */
-	pollFn: z.function({ input: z.tuple([]), output: z.promise(z.unknown()) }),
+	pollFn: z.custom<() => Promise<unknown>>((val) => typeof val === "function"),
 	/** Optional callback for progress updates */
-	onProgress: z.function({ input: z.tuple([z.number(), z.number()]), output: z.void() }).optional(),
+	onProgress: z.custom<(current: number, total: number) => void>((val) => typeof val === "function").optional(),
 })
 
 /**

+ 1 - 1
packages/evals/README.md

@@ -81,7 +81,7 @@ cd packages/evals && ./scripts/setup.sh
 The setup script does the following:
 
 - Installs development tools: Homebrew, asdf, GitHub CLI, pnpm
-- Installs programming languages: Node.js 20.19.2, Python 3.13.2, Go 1.24.2, Rust 1.85.1, Java 17
+- Installs programming languages: Node.js 20.20.0, Python 3.13.2, Go 1.24.2, Rust 1.85.1, Java 17
 - Sets up VS Code with required extensions
 - Configures Docker services (PostgreSQL, Redis)
 - Clones/updates the evals repository

+ 3 - 3
packages/evals/scripts/setup.sh

@@ -185,8 +185,8 @@ fi
 # Install language runtimes via mise
 if ! command -v node &>/dev/null; then
   echo "📦 Installing Node.js via mise..."
-  mise install node@20.19.2 || exit 1
-  mise use --global node@20.19.2 || exit 1
+  mise install [email protected]0.0 || exit 1
+  mise use --global [email protected]0.0 || exit 1
   eval "$(mise activate bash)"
   NODE_VERSION=$(node --version)
   echo "✅ Node.js is installed ($NODE_VERSION)"
@@ -195,7 +195,7 @@ else
   echo "✅ Node.js is installed ($NODE_VERSION)"
 fi
 
-if [[ $(node --version) != "v20.19.2" ]]; then
+if [[ $(node --version) != "v20.20.0" ]]; then
   NODE_VERSION=$(node --version)
   echo "🚨 You have the wrong version of node installed ($NODE_VERSION)."
   echo "💡 If you are using nvm then run 'nvm install' to install the version specified by the repo's .nvmrc."

+ 0 - 11
packages/types/src/providers/cerebras.ts

@@ -6,17 +6,6 @@ export type CerebrasModelId = keyof typeof cerebrasModels
 export const cerebrasDefaultModelId: CerebrasModelId = "gpt-oss-120b"
 
 export const cerebrasModels = {
-	"zai-glm-4.6": {
-		maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront)
-		contextWindow: 131072,
-		supportsImages: false,
-		supportsPromptCache: false,
-		supportsNativeTools: true,
-		defaultToolProtocol: "native",
-		inputPrice: 0,
-		outputPrice: 0,
-		description: "Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.",
-	},
 	"zai-glm-4.7": {
 		maxTokens: 16384, // Conservative default to avoid premature rate limiting (Cerebras reserves quota upfront)
 		contextWindow: 131072,

+ 6 - 3
src/core/assistant-message/NativeToolCallParser.ts

@@ -310,8 +310,11 @@ export class NativeToolCallParser {
 	private static convertFileEntries(files: any[]): FileEntry[] {
 		return files.map((file: any) => {
 			const entry: FileEntry = { path: file.path }
-			if (file.line_ranges && Array.isArray(file.line_ranges)) {
-				entry.lineRanges = file.line_ranges
+			// kilocode_change: support lineRanges spelling, often preferred by Claude
+			const lineRanges = file.line_ranges ?? file.lineRanges
+			if (lineRanges && Array.isArray(lineRanges)) {
+				entry.lineRanges = lineRanges
+					// kilocode_change end
 					.map((range: any) => {
 						// Handle tuple format: [start, end]
 						if (Array.isArray(range) && range.length >= 2) {
@@ -330,7 +333,7 @@ export class NativeToolCallParser {
 						}
 						return null
 					})
-					.filter(Boolean)
+					.filter((range) => range !== null) // kilocode_change
 			}
 			return entry
 		})

+ 73 - 10
src/core/assistant-message/presentAssistantMessage.ts

@@ -154,7 +154,10 @@ export async function presentAssistantMessage(cline: Task) {
 			const toolCallId = mcpBlock.id
 			const toolProtocol = TOOL_PROTOCOL.NATIVE // MCP tools in native mode always use native protocol
 
-			const pushToolResult = (content: ToolResponse) => {
+			// Store approval feedback to merge into tool result (GitHub #10465)
+			let approvalFeedback: { text: string; images?: string[] } | undefined
+
+			const pushToolResult = (content: ToolResponse, feedbackImages?: string[]) => {
 				if (hasToolResult) {
 					console.warn(
 						`[presentAssistantMessage] Skipping duplicate tool_result for mcp_tool_use: ${toolCallId}`,
@@ -175,6 +178,18 @@ export async function presentAssistantMessage(cline: Task) {
 						"(tool did not return anything)"
 				}
 
+				// Merge approval feedback into tool result (GitHub #10465)
+				if (approvalFeedback) {
+					const feedbackText = formatResponse.toolApprovedWithFeedback(approvalFeedback.text, toolProtocol)
+					resultContent = `${feedbackText}\n\n${resultContent}`
+
+					// Add feedback images to the image blocks
+					if (approvalFeedback.images) {
+						const feedbackImageBlocks = formatResponse.imageBlocks(approvalFeedback.images)
+						imageBlocks = [...feedbackImageBlocks, ...imageBlocks]
+					}
+				}
+
 				if (toolCallId) {
 					cline.pushToolResultToUserContent({
 						type: "tool_result",
@@ -223,11 +238,12 @@ export async function presentAssistantMessage(cline: Task) {
 					return false
 				}
 
+				// Store approval feedback to be merged into tool result (GitHub #10465)
+				// Don't push it as a separate tool_result here - that would create duplicates.
+				// The tool will call pushToolResult, which will merge the feedback into the actual result.
 				if (text) {
 					await cline.say("user_feedback", text, images)
-					pushToolResult(
-						formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text, toolProtocol), images),
-					)
+					approvalFeedback = { text, images }
 				}
 
 				return true
@@ -548,6 +564,9 @@ export async function presentAssistantMessage(cline: Task) {
 			// Previously resolved from experiments.isEnabled(..., EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS)
 			const isMultipleNativeToolCallsEnabled = false
 
+			// Store approval feedback to merge into tool result (GitHub #10465)
+			let approvalFeedback: { text: string; images?: string[] } | undefined
+
 			const pushToolResult = (content: ToolResponse) => {
 				if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
 					// For native protocol, only allow ONE tool_result per tool call
@@ -576,6 +595,21 @@ export async function presentAssistantMessage(cline: Task) {
 							"(tool did not return anything)"
 					}
 
+					// Merge approval feedback into tool result (GitHub #10465)
+					if (approvalFeedback) {
+						const feedbackText = formatResponse.toolApprovedWithFeedback(
+							approvalFeedback.text,
+							toolProtocol,
+						)
+						resultContent = `${feedbackText}\n\n${resultContent}`
+
+						// Add feedback images to the image blocks
+						if (approvalFeedback.images) {
+							const feedbackImageBlocks = formatResponse.imageBlocks(approvalFeedback.images)
+							imageBlocks = [...feedbackImageBlocks, ...imageBlocks]
+						}
+					}
+
 					// Add tool_result with text content only
 					cline.pushToolResultToUserContent({
 						type: "tool_result",
@@ -591,15 +625,44 @@ export async function presentAssistantMessage(cline: Task) {
 					hasToolResult = true
 				} else {
 					// For XML protocol, add as text blocks (legacy behavior)
+					let resultContent: string
+
+					if (typeof content === "string") {
+						resultContent = content || "(tool did not return anything)"
+					} else {
+						const textBlocks = content.filter((item) => item.type === "text")
+						resultContent =
+							textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") ||
+							"(tool did not return anything)"
+					}
+
+					// Merge approval feedback into tool result (GitHub #10465)
+					if (approvalFeedback) {
+						const feedbackText = formatResponse.toolApprovedWithFeedback(
+							approvalFeedback.text,
+							toolProtocol,
+						)
+						resultContent = `${feedbackText}\n\n${resultContent}`
+					}
+
 					cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` })
 
 					if (typeof content === "string") {
 						cline.userMessageContent.push({
 							type: "text",
-							text: content || "(tool did not return anything)",
+							text: resultContent,
 						})
 					} else {
-						cline.userMessageContent.push(...content)
+						// Add text content with merged feedback
+						cline.userMessageContent.push({
+							type: "text",
+							text: resultContent,
+						})
+						// Add any images from the tool result
+						const imageBlocks = content.filter((item) => item.type === "image")
+						if (imageBlocks.length > 0) {
+							cline.userMessageContent.push(...imageBlocks)
+						}
 					}
 				}
 
@@ -668,12 +731,12 @@ export async function presentAssistantMessage(cline: Task) {
 					return false
 				}
 
-				// Handle yesButtonClicked with text.
+				// Store approval feedback to be merged into tool result (GitHub #10465)
+				// Don't push it as a separate tool_result here - that would create duplicates.
+				// The tool will call pushToolResult, which will merge the feedback into the actual result.
 				if (text) {
 					await cline.say("user_feedback", text, images)
-					pushToolResult(
-						formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text, toolProtocol), images),
-					)
+					approvalFeedback = { text, images }
 				}
 
 				captureAskApproval(block.name, true) // kilocode_change

+ 4 - 0
src/core/task/Task.ts

@@ -1531,6 +1531,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		this.handleWebviewAskResponse("noButtonClicked", text, images)
 	}
 
+	public supersedePendingAsk(): void {
+		this.lastMessageTs = Date.now()
+	}
+
 	/**
 	 * Updates the API configuration but preserves the locked tool protocol.
 	 * The task's tool protocol is locked at creation time and should NOT change

+ 90 - 0
src/core/task/__tests__/validateToolResultIds.spec.ts

@@ -482,6 +482,96 @@ describe("validateAndFixToolResultIds", () => {
 			expect(resultContent[1].type).toBe("text")
 			expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Some additional context")
 		})
+
+		// Verifies fix for GitHub #10465: Terminal fallback race condition can generate
+		// duplicate tool_results with the same valid tool_use_id, causing API protocol violations.
+		it("should filter out duplicate tool_results with identical valid tool_use_ids (terminal fallback scenario)", () => {
+			const assistantMessage: Anthropic.MessageParam = {
+				role: "assistant",
+				content: [
+					{
+						type: "tool_use",
+						id: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g",
+						name: "execute_command",
+						input: { command: "ps aux | grep test", cwd: "/path/to/project" },
+					},
+				],
+			}
+
+			// Two tool_results with the SAME valid tool_use_id from terminal fallback race condition
+			const userMessage: Anthropic.MessageParam = {
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", // First result from command execution
+						content: "No test processes found",
+					},
+					{
+						type: "tool_result",
+						tool_use_id: "tooluse_QZ-pU8v2QKO8L8fHoJRI2g", // Duplicate from user approval during fallback
+						content: '{"status":"approved","message":"The user approved this operation"}',
+					},
+				],
+			}
+
+			const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
+
+			expect(Array.isArray(result.content)).toBe(true)
+			const resultContent = result.content as Anthropic.ToolResultBlockParam[]
+
+			// Only ONE tool_result should remain to prevent API protocol violation
+			expect(resultContent.length).toBe(1)
+			expect(resultContent[0].tool_use_id).toBe("tooluse_QZ-pU8v2QKO8L8fHoJRI2g")
+			expect(resultContent[0].content).toBe("No test processes found")
+		})
+
+		it("should preserve text blocks while deduplicating tool_results with same valid ID", () => {
+			const assistantMessage: Anthropic.MessageParam = {
+				role: "assistant",
+				content: [
+					{
+						type: "tool_use",
+						id: "tool-123",
+						name: "read_file",
+						input: { path: "test.txt" },
+					},
+				],
+			}
+
+			const userMessage: Anthropic.MessageParam = {
+				role: "user",
+				content: [
+					{
+						type: "tool_result",
+						tool_use_id: "tool-123",
+						content: "First result",
+					},
+					{
+						type: "text",
+						text: "Environment details here",
+					},
+					{
+						type: "tool_result",
+						tool_use_id: "tool-123", // Duplicate with same valid ID
+						content: "Duplicate result from fallback",
+					},
+				],
+			}
+
+			const result = validateAndFixToolResultIds(userMessage, [assistantMessage])
+
+			expect(Array.isArray(result.content)).toBe(true)
+			const resultContent = result.content as Array<Anthropic.ToolResultBlockParam | Anthropic.TextBlockParam>
+
+			// Should have: 1 tool_result + 1 text block (duplicate filtered out)
+			expect(resultContent.length).toBe(2)
+			expect(resultContent[0].type).toBe("tool_result")
+			expect((resultContent[0] as Anthropic.ToolResultBlockParam).tool_use_id).toBe("tool-123")
+			expect((resultContent[0] as Anthropic.ToolResultBlockParam).content).toBe("First result")
+			expect(resultContent[1].type).toBe("text")
+			expect((resultContent[1] as Anthropic.TextBlockParam).text).toBe("Environment details here")
+		})
 	})
 
 	describe("when there are more tool_uses than tool_results", () => {

+ 36 - 12
src/core/task/validateToolResultIds.ts

@@ -78,7 +78,33 @@ export function validateAndFixToolResultIds(
 	}
 
 	// Find tool_result blocks in the user message
-	const toolResults = userMessage.content.filter(
+	let toolResults = userMessage.content.filter(
+		(block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result",
+	)
+
+	// Deduplicate tool_result blocks to prevent API protocol violations (GitHub #10465)
+	// This serves as a safety net for any potential race conditions that could generate
+	// duplicate tool_results with the same tool_use_id. The root cause (approval feedback
+	// creating duplicate results) has been fixed in presentAssistantMessage.ts, but this
+	// deduplication remains as a defensive measure for unknown edge cases.
+	const seenToolResultIds = new Set<string>()
+	const deduplicatedContent = userMessage.content.filter((block) => {
+		if (block.type !== "tool_result") {
+			return true
+		}
+		if (seenToolResultIds.has(block.tool_use_id)) {
+			return false // Duplicate - filter out
+		}
+		seenToolResultIds.add(block.tool_use_id)
+		return true
+	})
+
+	userMessage = {
+		...userMessage,
+		content: deduplicatedContent,
+	}
+
+	toolResults = deduplicatedContent.filter(
 		(block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result",
 	)
 
@@ -139,15 +165,12 @@ export function validateAndFixToolResultIds(
 		)
 	}
 
-	// Create a mapping of tool_result IDs to corrected IDs
-	// Strategy: Match by position (first tool_result -> first tool_use, etc.)
-	// This handles most cases where the mismatch is due to ID confusion
-	//
-	// Track which tool_use IDs have been used to prevent duplicates
+	// Match tool_results to tool_uses by position and fix incorrect IDs
 	const usedToolUseIds = new Set<string>()
+	const contentArray = userMessage.content as Anthropic.Messages.ContentBlockParam[]
 
-	const correctedContent = userMessage.content
-		.map((block) => {
+	const correctedContent = contentArray
+		.map((block: Anthropic.Messages.ContentBlockParam) => {
 			if (block.type !== "tool_result") {
 				return block
 			}
@@ -177,17 +200,18 @@ export function validateAndFixToolResultIds(
 			}
 
 			// No corresponding tool_use for this tool_result, or the ID is already used
-			// Filter out this orphaned tool_result by returning null
 			return null
 		})
 		.filter((block): block is NonNullable<typeof block> => block !== null)
 
 	// Add missing tool_result blocks for any tool_use that doesn't have one
-	// After the ID correction above, recalculate which tool_use IDs are now covered
 	const coveredToolUseIds = new Set(
 		correctedContent
-			.filter((b): b is Anthropic.ToolResultBlockParam => b.type === "tool_result")
-			.map((r) => r.tool_use_id),
+			.filter(
+				(b: Anthropic.Messages.ContentBlockParam): b is Anthropic.ToolResultBlockParam =>
+					b.type === "tool_result",
+			)
+			.map((r: Anthropic.ToolResultBlockParam) => r.tool_use_id),
 	)
 
 	const stillMissingToolUseIds = toolUseBlocks.filter((toolUse) => !coveredToolUseIds.has(toolUse.id))

+ 3 - 0
src/core/tools/ExecuteCommandTool.ts

@@ -116,6 +116,9 @@ export class ExecuteCommandTool extends BaseTool<"execute_command"> {
 				provider?.postMessageToWebview({ type: "commandExecutionStatus", text: JSON.stringify(status) })
 				await task.say("shell_integration_warning")
 
+				// Invalidate pending ask from first execution to prevent race condition
+				task.supersedePendingAsk()
+
 				if (error instanceof ShellIntegrationError) {
 					const [rejected, result] = await executeCommandInTerminal(task, {
 						...options,

+ 1 - 1
src/package.json

@@ -11,7 +11,7 @@
 	},
 	"engines": {
 		"vscode": "^1.84.0",
-		"node": "20.19.2"
+		"node": "20.20.0"
 	},
 	"extensionKind": [
 		"workspace"

+ 0 - 19
src/services/ghost/chat-autocomplete/ChatTextAreaAutocomplete.ts

@@ -153,31 +153,12 @@ TASK: Complete the user's message naturally.
 			}
 		}
 
-		const clipboardContent = await this.getClipboardContext()
-		if (clipboardContent) {
-			contextParts.push("\n// Clipboard content:")
-			contextParts.push(clipboardContent)
-		}
-
 		contextParts.push("\n// User's message:")
 		contextParts.push(userText)
 
 		return contextParts.join("\n")
 	}
 
-	private async getClipboardContext(): Promise<string | null> {
-		try {
-			const text = await vscode.env.clipboard.readText()
-			// Only include if it's reasonable size and looks like code
-			if (text && text.length > 5 && text.length < 500) {
-				return text
-			}
-		} catch {
-			// Silently ignore clipboard errors
-		}
-		return null
-	}
-
 	public cleanSuggestion(suggestion: string, userText: string): string {
 		let cleaned = postprocessGhostSuggestion({
 			suggestion: removePrefixOverlap(suggestion, userText),

+ 1 - 1
src/test-llm-autocompletion/package.json

@@ -22,6 +22,6 @@
 		"typescript": "^5.8.3"
 	},
 	"volta": {
-		"node": "20.19.2"
+		"node": "20.20.0"
 	}
 }