diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index aa6bcfa45c3..8b64bf4ed5d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,5 +1,5 @@ name: publish -run-name: "${{ format('release {0}', inputs.bump) }}" +run-name: "${{ format('{0} {1}', inputs.pre_release && 'pre-release' || 'release', inputs.bump) }}" on: # push: @@ -22,6 +22,11 @@ on: description: "Override version (optional)" required: false type: string + pre_release: + description: "Publish as pre-release (VS Code marketplace)" + required: false + type: boolean + default: false concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }} @@ -117,6 +122,7 @@ jobs: env: CLI_DIST_DIR: ../../packages/opencode/dist KILO_VERSION: ${{ needs.build-cli.outputs.version }} + KILO_PRE_RELEASE: ${{ inputs.pre_release }} GH_REPO: ${{ github.repository }} - uses: actions/upload-artifact@v4 @@ -394,6 +400,7 @@ jobs: env: KILO_VERSION: ${{ needs.version.outputs.version }} KILO_RELEASE: ${{ needs.version.outputs.release }} + KILO_PRE_RELEASE: ${{ inputs.pre_release }} GH_REPO: ${{ github.repository }} AUR_KEY: ${{ secrets.AUR_KEY }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }} diff --git a/bun.lock b/bun.lock index 902345b9125..73b3ec84f25 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -80,7 +80,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -113,7 +113,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -144,7 +144,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -173,7 +173,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/openai": "2.0.101", @@ -208,7 +208,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.1.22", + "version": "7.1.23", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -221,7 +221,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -241,7 +241,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/util": "workspace:*", @@ -276,7 +276,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -329,7 +329,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.1.22", + "version": "7.1.23", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -457,7 +457,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kilocode/sdk": "workspace:*", "zod": "catalog:", @@ -471,7 +471,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "semver": "^7.6.3", }, @@ -482,7 +482,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.1.22", + "version": "7.1.23", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -493,7 +493,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.1.22", + "version": "7.1.23", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -516,7 +516,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -563,7 +563,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.1.22", + "version": "7.1.23", "dependencies": { "zod": "catalog:", }, diff --git a/package.json b/package.json index 49a3917e883..2e6df113b93 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,6 @@ "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" }, - "version": "7.1.22", + "version": "7.1.23", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 6ef4fcab960..5a2a4b50221 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.1.22", + "version": "7.1.23", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 6f2c610ec97..55fa1f71534 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ece81c56afa..83a34dc8782 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 676bc0335b6..e2b800dc95a 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.1.22" +version = "7.1.23" 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.1.22/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/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.1.22/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/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.1.22/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/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.1.22/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.23/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index fab9fae0df6..f3b83f7590c 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.1.22", + "version": "7.1.23", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index ef42c918e3e..db8dc74c645 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -18,27 +18,6 @@ Kilo Code ships with a curated list of models for each provider, but you can use Add custom models under the `provider..models` key in your config file. The model key becomes the model ID you reference elsewhere. {% tabs %} -{% tab label="CLI" %} - -**Config file** (`~/.config/kilo/kilo.jsonc` or `./kilo.jsonc`): - -```jsonc -{ - "$schema": "https://app.kilo.ai/config.json", - "model": "lmstudio/my-custom-model", - "provider": { - "lmstudio": { - "models": { - "my-custom-model": { - "name": "My Custom Model", - }, - }, - }, - }, -} -``` - -{% /tab %} {% tab label="VSCode" %} 1. Open **Settings** (gear icon) and go to the **Providers** tab. @@ -64,6 +43,27 @@ To edit an existing custom provider, click the **Edit provider** button next to For additional model configuration (token limits, tool calling, reasoning, variants), edit the `kilo.jsonc` config file directly — see the **CLI** tab for the format. +{% /tab %} +{% tab label="CLI" %} + +**Config file** (`~/.config/kilo/kilo.jsonc` or `./kilo.jsonc`): + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "model": "lmstudio/my-custom-model", + "provider": { + "lmstudio": { + "models": { + "my-custom-model": { + "name": "My Custom Model", + }, + }, + }, + }, +} +``` + {% /tab %} {% /tabs %} @@ -91,14 +91,14 @@ All fields are optional. When a model ID matches one already in the built-in cat | `provider` | `object` | Override `{ npm?, api? }` — the AI SDK package or base API URL for this model | | `variants` | `object` | Named variant configurations (e.g., different reasoning efforts) | -### Token Limits (`limit`) +### Token Limits (limit) The `limit` object controls how Kilo manages the model's context window and output length. These values are specified in **tokens**. | Sub-field | Type | Required | Description | | --------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `context` | `number` | No | The model's total context window size (e.g., `131072` for a 128K model). Used to determine when conversation history should be compacted to stay within the window. | -| `output` | `number` | No | The maximum number of tokens the model can generate in a single response. Sent to the provider as `max_tokens` or equivalent. Capped at 32,000 by default. | +| `context` | `number` | No | The model's total context window size (e.g., `131072` for a 128K model). Used to determine when conversation history should be compacted to stay within the window. | +| `output` | `number` | No | The maximum number of tokens the model can generate in a single response. Sent to the provider as `max_tokens` or equivalent. Capped at 32,000 by default. | | `input` | `number` | No | An optional stricter input limit. Some providers enforce an input token ceiling that is lower than the full context window. When set, compaction triggers against this value instead of `context`. | ```jsonc @@ -263,7 +263,7 @@ Override options or define reasoning variants for a built-in model: } ``` -### Using the `id` field to map model names +### Using the id field to map model names If the model key in your config differs from what the provider expects, use the `id` field: diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/whats-new.md b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/whats-new.md index c8bc727a332..408d4e69652 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/whats-new.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/whats-new.md @@ -62,6 +62,14 @@ The Agent Manager also includes a built-in diff reviewer that shows every change You can now trigger local AI-powered code reviews directly by using two commands: **`/local-review`** to review all changes on your current branch vs the base branch, and **`/local-review-uncommitted`** to review staged and unstaged changes. See the [Code Reviews](/docs/automate/code-reviews/overview) documentation for the full setup and options. +### How can I see the cost of each model? + +In the model picker dropdown, click the expand button in the upper-right corner to switch to the full model picker view. From there, click on any model to see its details — including input and output pricing per million tokens, the context window size, and which capabilities the model supports (reasoning, text, images, etc.). This makes it easy to compare costs before selecting a model. + +### How do I set context limits or other parameters for custom models? + +If you're using a custom model (e.g. via your own API key or a self-hosted provider), you can configure the context window size, max output tokens, and other parameters in your model settings. See the [Custom Models](/docs/code-with-ai/agents/custom-models) documentation for the full guide on adding and configuring custom models. + ### Where did my custom profiles go? In the new extension we simplified the model selection by removing the profile layer. To keep models easily reachable you don't need a profile — you can just star them in the model selector to mark them as favorites. diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 1eb86997edf..ffca4a9415f 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.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 02faf1a7a8f..83091e58381 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.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index ba725ee81c4..6bed2eac5d5 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.1.22", + "version": "7.1.23", "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 c09afb6476f..327a1083c71 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 12ada5d5a7b..44ef0219c8e 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.1.22", + "version": "7.1.23", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -602,12 +602,14 @@ { "command": "kilo-code.new.cycleAgentMode", "key": "ctrl+.", - "mac": "cmd+." + "mac": "cmd+.", + "when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" }, { "command": "kilo-code.new.cyclePreviousAgentMode", "key": "ctrl+shift+.", - "mac": "cmd+shift+." + "mac": "cmd+shift+.", + "when": "sideBarFocus && kilo-code.new.sidebarVisible || activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel' || activeWebviewPanelId == 'kilo-code.new.TabPanel'" }, { "command": "kilo-code.new.autocomplete.cancelSuggestions", diff --git a/packages/kilo-vscode/script/build.ts b/packages/kilo-vscode/script/build.ts index ee00dbb778e..cb285b327b4 100644 --- a/packages/kilo-vscode/script/build.ts +++ b/packages/kilo-vscode/script/build.ts @@ -6,8 +6,9 @@ import { existsSync, mkdirSync, rmSync, chmodSync } from "node:fs" const packageJsonPath = join(import.meta.dir, "..", "package.json") const packageJson = await Bun.file(packageJsonPath).json() const version = process.env.KILO_VERSION ? process.env.KILO_VERSION : packageJson.version +const prerelease = process.env.KILO_PRE_RELEASE === "true" -console.log(`Building VSCode extension version: ${version}`) +console.log(`Building VSCode extension version: ${version}${prerelease ? " (pre-release)" : ""}`) if (packageJson.version !== version) { console.log(`Updating package.json version from ${packageJson.version} to ${version}`) @@ -80,9 +81,11 @@ for (const config of targets) { console.log(` ✅ Binary ready at ${targetBinary}`) - console.log(` 📦 Packaging .vsix for ${config.target}...`) + console.log(` 📦 Packaging .vsix for ${config.target}${prerelease ? " (pre-release)" : ""}...`) const vsixPath = join(outDir, `kilo-vscode-${config.target}.vsix`) - await $`vsce package --no-dependencies --skip-license --target ${config.target} -o ${vsixPath}`.env({ + const args = ["--no-dependencies", "--skip-license", "--target", config.target, "-o", vsixPath] + if (prerelease) args.push("--pre-release") + await $`vsce package ${args}`.env({ ...process.env, npm_config_ignore_scripts: "true", }) diff --git a/packages/kilo-vscode/script/publish.ts b/packages/kilo-vscode/script/publish.ts index ef891606266..8741734e5a1 100755 --- a/packages/kilo-vscode/script/publish.ts +++ b/packages/kilo-vscode/script/publish.ts @@ -4,7 +4,9 @@ import { join } from "node:path" import { existsSync } from "node:fs" import { Script } from "@opencode-ai/script" -console.log(`Publishing VSCode extension for release: v${Script.version}`) +const prerelease = process.env.KILO_PRE_RELEASE === "true" + +console.log(`Publishing VSCode extension for ${prerelease ? "pre-release" : "release"}: v${Script.version}`) const outDir = process.env.VSIX_DIR || join(import.meta.dir, "..", "out") @@ -36,14 +38,16 @@ for (const target of targets) { console.log(`\nFound ${vsixFiles.length} VSIX files`) +const flag = prerelease ? ["--pre-release"] : [] + for (const target of targets) { const vsixPath = join(outDir, `kilo-vscode-${target}.vsix`) - console.log(`\n🚀 Publishing ${target} to VS Code Marketplace...`) - await $`vsce publish --packagePath ${vsixPath}` + console.log(`\n🚀 Publishing ${target} to VS Code Marketplace${prerelease ? " (pre-release)" : ""}...`) + await $`vsce publish ${flag} --packagePath ${vsixPath}` console.log(` ✅ Published ${target} to VS Code Marketplace`) - console.log(`\n📤 Publishing ${target} to Open VSX...`) - await $`npx ovsx publish --pat ${process.env.OPENVSX_TOKEN} --packagePath ${vsixPath}` + console.log(`\n📤 Publishing ${target} to Open VSX${prerelease ? " (pre-release)" : ""}...`) + await $`npx ovsx publish ${flag} --pat ${process.env.OPENVSX_TOKEN} --packagePath ${vsixPath}` console.log(` ✅ Published ${target} to Open VSX`) } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index cd935153f9a..2b153315bb4 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -336,8 +336,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Handle messages from webview (shared handler) this.setupWebviewMessageHandler(webviewView.webview) - // Pause stats polling when sidebar is hidden, resume when visible + // Track sidebar visibility for keybinding when-clauses and stats polling + vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible) webviewView.onDidChangeVisibility(() => { + vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible) this.statsPoller?.setEnabled(webviewView.visible) }) diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 6679ac84453..438b26c3a9c 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -188,11 +188,15 @@ export function activate(context: vscode.ExtensionContext) { else provider.postMessage({ type: "action", action: "historyButtonClicked" }) }), vscode.commands.registerCommand("kilo-code.new.cycleAgentMode", () => { - provider.postMessage({ type: "action", action: "cycleAgentMode" }) + const tab = activeTabProvider() + if (tab) tab.postMessage({ type: "action", action: "cycleAgentMode" }) + else provider.postMessage({ type: "action", action: "cycleAgentMode" }) agentManagerProvider.postMessage({ type: "action", action: "cycleAgentMode" }) }), vscode.commands.registerCommand("kilo-code.new.cyclePreviousAgentMode", () => { - provider.postMessage({ type: "action", action: "cyclePreviousAgentMode" }) + const tab = activeTabProvider() + if (tab) tab.postMessage({ type: "action", action: "cyclePreviousAgentMode" }) + else provider.postMessage({ type: "action", action: "cyclePreviousAgentMode" }) agentManagerProvider.postMessage({ type: "action", action: "cyclePreviousAgentMode" }) }), vscode.commands.registerCommand("kilo-code.new.profileButtonClicked", () => { diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0061a6e62d8..b6a55fd6979 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.1.22", + "version": "7.1.23", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/file/ignore.ts b/packages/opencode/src/file/ignore.ts index 225983131c3..b9731040c7d 100644 --- a/packages/opencode/src/file/ignore.ts +++ b/packages/opencode/src/file/ignore.ts @@ -31,8 +31,6 @@ export namespace FileIgnore { "mypy_cache", ".history", ".gradle", - ".kilocode", // kilocode_change — ignore legacy local storage (#8379) - ".opencode", // kilocode_change — ignore legacy local storage (#8379) ]) const FILES = [ diff --git a/packages/opencode/src/kilocode/plan-followup.ts b/packages/opencode/src/kilocode/plan-followup.ts index 6c50da6b17b..335d72ebf1d 100644 --- a/packages/opencode/src/kilocode/plan-followup.ts +++ b/packages/opencode/src/kilocode/plan-followup.ts @@ -274,20 +274,24 @@ export namespace PlanFollowup { Todo.get(input.sessionID), ]) - const sections = [`Implement the following plan:\n\n${input.plan}`] - - if (handover) { - sections.push(`## Handover from Planning Session\n\n${handover}`) - } - - const todoList = formatTodos(todos) - if (todoList) { - sections.push(`## Todo List\n\n${todoList}`) - } - await Instance.provide({ directory: session.directory, fn: async () => { + const file = Session.plan(session) + const sections = [ + `Plan file: ${file}\nRead this file first and treat it as the source of truth for implementation.`, + `Implement the following plan:\n\n${input.plan}`, + ] + + if (handover) { + sections.push(`## Handover from Planning Session\n\n${handover}`) + } + + const todoList = formatTodos(todos) + if (todoList) { + sections.push(`## Todo List\n\n${todoList}`) + } + const next = await Session.create({}) await inject({ sessionID: next.id, diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 44ffec1dbe8..0a38ddb70b9 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,4 +1,4 @@ -import { $ } from "bun" // kilocode_change +import { $ } from "bun" import path from "path" import fs from "fs/promises" import { Filesystem } from "../util/filesystem" @@ -10,17 +10,14 @@ import { Config } from "../config/config" import { Instance } from "../project/instance" import { Scheduler } from "../scheduler" import * as KiloSnapshot from "../kilocode/snapshot" // kilocode_change -import { Process } from "@/util/process" +import { Lock } from "../util/lock" // kilocode_change export namespace Snapshot { const log = Log.create({ service: "snapshot" }) const hour = 60 * 60 * 1000 const prune = "7.days" export const MAX_DIFF_SIZE = 256 * 1024 // kilocode_change - - function args(git: string, cmd: string[]) { - return ["--git-dir", git, "--work-tree", Instance.worktree, ...cmd] - } + const MAX_SNAPSHOT_FILE_SIZE = 2 * 1024 * 1024 // kilocode_change — skip files >2MB during snapshot add export function init() { Scheduler.register({ @@ -41,13 +38,15 @@ export namespace Snapshot { .then(() => true) .catch(() => false) if (!exists) return - const result = await Process.run(["git", ...args(git, ["gc", `--prune=${prune}`])], { - cwd: Instance.directory, - nothrow: true, - }) - if (result.code !== 0) { + using _lock = await Lock.write(git) // kilocode_change + const result = + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} gc --prune=${prune}` + .quiet() + .cwd(Instance.directory) + .nothrow() + if (result.exitCode !== 0) { log.warn("cleanup failed", { - exitCode: result.code, + exitCode: result.exitCode, stderr: result.stderr.toString(), stdout: result.stdout.toString(), }) @@ -61,11 +60,13 @@ export namespace Snapshot { const cfg = await Config.get() if (cfg.snapshot === false) return const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change await add(git) - const hash = await Process.text(["git", ...args(git, ["write-tree"])], { - cwd: Instance.directory, - nothrow: true, - }).then((x) => x.text) + const hash = await $`git --git-dir ${git} --work-tree ${Instance.worktree} write-tree` + .quiet() + .cwd(Instance.directory) + .nothrow() + .text() log.info("tracking", { hash, cwd: Instance.directory, git }) return hash.trim() } @@ -78,33 +79,21 @@ export namespace Snapshot { export async function patch(hash: string): Promise { const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change await add(git) - const result = await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", "--name-only", hash, "--", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - ) + const result = + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-only ${hash} -- .` + .quiet() + .cwd(Instance.directory) + .nothrow() // If git diff fails, return empty patch - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }) + if (result.exitCode !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.exitCode }) return { hash, files: [] } } - const files = result.text + const files = result.text() return { hash, files: files @@ -119,123 +108,171 @@ export namespace Snapshot { export async function restore(snapshot: string) { log.info("restore", { commit: snapshot }) const git = await KiloSnapshot.prepare() // kilocode_change - const result = await Process.run( - ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["read-tree", snapshot])], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (result.code === 0) { - const checkout = await Process.run( - ["git", "-c", "core.longpaths=true", "-c", "core.symlinks=true", ...args(git, ["checkout-index", "-a", "-f"])], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (checkout.code === 0) return + using _lock = await Lock.write(git) // kilocode_change + const result = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} read-tree ${snapshot} && git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} checkout-index -a -f` + .quiet() + .cwd(Instance.worktree) + .nothrow() + + if (result.exitCode !== 0) { log.error("failed to restore snapshot", { snapshot, - exitCode: checkout.code, - stderr: checkout.stderr.toString(), - stdout: checkout.stdout.toString(), + exitCode: result.exitCode, + stderr: result.stderr.toString(), + stdout: result.stdout.toString(), }) + } + } + + // kilocode_change start — batched revert: group up to 100 files per git checkout (port of upstream #20564) + type RevertOp = { hash: string; file: string; rel: string } + + /** Revert a single file: checkout from snapshot or delete if it didn't exist. */ + async function revertSingle(git: string, worktree: string, op: RevertOp) { + log.info("reverting", { file: op.file, hash: op.hash }) + const result = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} checkout ${op.hash} -- ${op.file}` + .quiet() + .cwd(worktree) + .nothrow() + if (result.exitCode === 0) return + const tree = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} ls-tree ${op.hash} -- ${op.rel}` + .quiet() + .cwd(worktree) + .nothrow() + if (tree.exitCode === 0 && tree.text().trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file: op.file, hash: op.hash }) + return + } + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + await fs.unlink(op.file).catch(() => {}) + } + + /** Revert a batch of files sharing the same hash. Falls back to single-file on failure. */ + async function revertBatch(git: string, worktree: string, batch: RevertOp[]) { + const hash = batch[0]!.hash + + // Check which files exist in the snapshot + const tree = + await $`git -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} ls-tree --name-only ${hash} -- ${batch.map((op) => op.rel)}` + .quiet() + .cwd(worktree) + .nothrow() + + if (tree.exitCode !== 0) { + log.info("batched ls-tree failed, falling back to single-file revert", { hash, files: batch.length }) + for (const op of batch) await revertSingle(git, worktree, op) return } - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr.toString(), - stdout: result.stdout.toString(), - }) - } + const existing = new Set(tree.text().trim().split("\n").map((l) => l.trim()).filter(Boolean)) - export async function revert(patches: Patch[]) { - const files = new Set() - for (const item of patches) { - const git = await KiloSnapshot.prepare() // kilocode_change - for (const file of item.files) { - if (files.has(file)) continue - log.info("reverting", { file, hash: item.hash }) - const result = await Process.run( - [ - "git", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["checkout", item.hash, "--", file]), - ], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (result.code !== 0) { - const relativePath = path.relative(Instance.worktree, file) - const checkTree = await Process.text( - [ - "git", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["ls-tree", item.hash, "--", relativePath]), - ], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) - if (checkTree.code === 0 && checkTree.text.trim()) { - log.info("file existed in snapshot but checkout failed, keeping", { - file, - }) - } else { - log.info("file did not exist in snapshot, deleting", { file }) - await fs.unlink(file).catch(() => {}) - } - } - files.add(file) + // Checkout files that exist in the snapshot + const toCheckout = batch.filter((op) => existing.has(op.rel)) + if (toCheckout.length) { + log.info("reverting", { hash, files: toCheckout.length }) + const result = + await $`git -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} checkout ${hash} -- ${toCheckout.map((op) => op.file)}` + .quiet() + .cwd(worktree) + .nothrow() + if (result.exitCode !== 0) { + log.info("batched checkout failed, falling back to single-file revert", { hash, files: toCheckout.length }) + for (const op of batch) await revertSingle(git, worktree, op) + return } } + + // Delete files that didn't exist in the snapshot + for (const op of batch) { + if (existing.has(op.rel)) continue + log.info("file did not exist in snapshot, deleting", { file: op.file, hash: op.hash }) + await fs.unlink(op.file).catch(() => {}) + } } + /** True when one path is a parent of the other (e.g. "a/b" and "a/b/c"). */ + function pathsClash(a: string, b: string) { + return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) + } + + /** Can this op be added to the current batch? */ + function canBatch(batch: RevertOp[], op: RevertOp): boolean { + if (batch.length >= 100) return false + if (op.hash !== batch[0]!.hash) return false + if (batch.some((existing) => pathsClash(existing.rel, op.rel))) return false + return true + } + + /** + * Group consecutive ops into batches that share the same hash, + * have no path conflicts, and contain at most 100 files each. + */ + function groupIntoBatches(ops: RevertOp[]): RevertOp[][] { + const batches: RevertOp[][] = [] + let batch: RevertOp[] = [] + + for (const op of ops) { + if (batch.length > 0 && !canBatch(batch, op)) { + batches.push(batch) + batch = [] + } + batch.push(op) + } + if (batch.length > 0) batches.push(batch) + + return batches + } + + export async function revert(patches: Patch[]) { + const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change + const worktree = Instance.worktree + + // Deduplicate files preserving patch order + const ops: RevertOp[] = [] + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + ops.push({ hash: item.hash, file, rel: path.relative(worktree, file).replaceAll("\\", "/") }) + } + } + + for (const batch of groupIntoBatches(ops)) { + if (batch.length === 1) { + await revertSingle(git, worktree, batch[0]!) + } else { + await revertBatch(git, worktree, batch) + } + } + } + // kilocode_change end + export async function diff(hash: string) { const git = await KiloSnapshot.prepare() // kilocode_change + using _lock = await Lock.write(git) // kilocode_change await add(git) - const result = await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", hash, "--", "."]), - ], - { - cwd: Instance.worktree, - nothrow: true, - }, - ) + const result = + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff ${hash} -- .` + .quiet() + .cwd(Instance.worktree) + .nothrow() - if (result.code !== 0) { + if (result.exitCode !== 0) { log.warn("failed to get diff", { hash, - exitCode: result.code, + exitCode: result.exitCode, stderr: result.stderr.toString(), stdout: result.stdout.toString(), }) return "" } - return result.text.trim() + return result.text().trim() } export const FileDiff = z @@ -272,31 +309,20 @@ export namespace Snapshot { diffCache.set(key, pending) return pending } + // kilocode_change end async function diffFullUncached(from: string, to: string): Promise { const git = await KiloSnapshot.prepare() // kilocode_change - // kilocode_change end + using _lock = await Lock.write(git) // kilocode_change const result: FileDiff[] = [] const status = new Map() - const statuses = await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - ).then((x) => x.text) + const statuses = + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` + .quiet() + .cwd(Instance.directory) + .nothrow() + .text() for (const line of statuses.trim().split("\n")) { if (!line) continue @@ -306,24 +332,11 @@ export namespace Snapshot { status.set(file, kind) } - for (const line of await Process.lines( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - "-c", - "core.quotepath=false", - ...args(git, ["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - )) { + for await (const line of $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` + .quiet() + .cwd(Instance.directory) + .nothrow() + .lines()) { if (!line) continue const [additions, deletions, file] = line.split("\t") const isBinaryFile = additions === "-" && deletions === "-" @@ -338,34 +351,16 @@ export namespace Snapshot { // kilocode_change end const before = skip ? "" - : await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["show", `${from}:${file}`]), - ], - { nothrow: true }, - ).then((x) => x.text) + : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${from}:${file}` + .quiet() + .nothrow() + .text() const after = skip ? "" - : await Process.text( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["show", `${to}:${file}`]), - ], - { nothrow: true }, - ).then((x) => x.text) + : await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${Instance.worktree} show ${to}:${file}` + .quiet() + .nothrow() + .text() const added = isBinaryFile ? 0 : parseInt(additions) const deleted = isBinaryFile ? 0 : parseInt(deletions) result.push({ @@ -384,44 +379,79 @@ export namespace Snapshot { return KiloSnapshot.gitdir() // kilocode_change } + // kilocode_change start — incremental add: diff-files + ls-files + size filter (port of upstream #17878) async function add(git: string) { - await syncExclude(git) - await Process.run( - [ - "git", - "-c", - "core.autocrlf=false", - "-c", - "core.longpaths=true", - "-c", - "core.symlinks=true", - ...args(git, ["add", "."]), - ], - { - cwd: Instance.directory, - nothrow: true, - }, - ) + const cwd = Instance.directory + const worktree = Instance.worktree + + // Run diff-files and ls-files concurrently to find changed + untracked files + const [diffResult, otherResult] = await Promise.all([ + $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} diff-files --name-only -z -- .` + .quiet() + .cwd(cwd) + .nothrow(), + $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true -c core.quotepath=false --git-dir ${git} --work-tree ${worktree} ls-files --others --exclude-standard -z -- .` + .quiet() + .cwd(cwd) + .nothrow(), + ]) + + if (diffResult.exitCode !== 0 || otherResult.exitCode !== 0) { + log.warn("failed to list snapshot files", { + diffCode: diffResult.exitCode, + diffStderr: diffResult.stderr.toString(), + otherCode: otherResult.exitCode, + otherStderr: otherResult.stderr.toString(), + }) + return + } + + const tracked = diffResult.text().split("\0").filter(Boolean) + const all = Array.from(new Set([...tracked, ...otherResult.text().split("\0").filter(Boolean)])) + if (!all.length) { + await syncExclude(git) + return + } + + // Filter out oversized files (>2MB) + const large = ( + await Promise.all( + all.map(async (item) => { + const stat = await fs.stat(path.join(cwd, item)).catch(() => null) + return stat?.isFile() && stat.size > MAX_SNAPSHOT_FILE_SIZE ? item : undefined + }), + ) + ).filter(Boolean) as string[] + + await syncExclude(git, large) + await $`git -c core.autocrlf=false -c core.longpaths=true -c core.symlinks=true --git-dir ${git} --work-tree ${worktree} add --sparse .` + .quiet() + .cwd(cwd) + .nothrow() } - async function syncExclude(git: string) { + async function syncExclude(git: string, largeFiles: string[] = []) { const file = await excludes() const target = path.join(git, "info", "exclude") await fs.mkdir(path.join(git, "info"), { recursive: true }) - if (!file) { - await Filesystem.write(target, "") - return + const parts: string[] = [] + if (file) { + const text = await Filesystem.readText(file).catch(() => "") + if (text.trim()) parts.push(text.trimEnd()) } - const text = await Filesystem.readText(file).catch(() => "") - - await Filesystem.write(target, text) + for (const item of largeFiles) { + parts.push(`/${item.replaceAll("\\", "/")}`) + } + await Filesystem.write(target, parts.length ? parts.join("\n") + "\n" : "") } + // kilocode_change end async function excludes() { - const file = await Process.text(["git", "rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { - cwd: Instance.worktree, - nothrow: true, - }).then((x) => x.text) + const file = await $`git rev-parse --path-format=absolute --git-path info/exclude` + .quiet() + .cwd(Instance.worktree) + .nothrow() + .text() if (!file.trim()) return const exists = await fs .stat(file.trim()) diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 1c533161261..542508c74f9 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -448,6 +448,7 @@ describe("plan follow-up", () => { if (!newSessionID || !next) throw new Error("expected follow-up session") expect(next.id).toBe(newSessionID) expect(next.parentID).toBeUndefined() + const planPath = Session.plan(await Session.get(seeded.sessionID)) const messages = await Session.messages({ sessionID: newSessionID }) const user = messages.find((item) => item.info.role === "user") expect(user?.info.role).toBe("user") @@ -460,6 +461,7 @@ describe("plan follow-up", () => { expect(part?.type).toBe("text") if (!part || part.type !== "text") throw new Error("expected text part") expect(part.text).toContain("Implement the following plan:") + expect(part.text).toContain(`Plan file: ${planPath}`) expect(part.text).toContain("1. Add API\n2. Add tests") expect(part.text).toContain("## Handover from Planning Session") expect(part.text).toContain("Found REST endpoints in src/api.ts") @@ -478,6 +480,7 @@ describe("plan follow-up", () => { test("ask - creates a new session in the planning session directory when the current instance differs", () => withInstance(async () => { + await using other = await tmpdir({ git: true }) const get = spyOn(Agent, "get").mockImplementation(async () => undefined as any) const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel) const llmSpy = spyOn(LLM, "stream").mockResolvedValue({ @@ -491,15 +494,17 @@ describe("plan follow-up", () => { }, } - const dir = path.join(Instance.directory, "worktrees", "feature") - await fs.mkdir(dir, { recursive: true }) + const dir = other.path const seeded = await Instance.provide({ directory: dir, fn: async () => seed({ text: "1. Add API\n2. Add tests" }), }) - const before = await sessions() + const before = await Instance.provide({ + directory: dir, + fn: async () => sessions(), + }) const pending = PlanFollowup.ask({ sessionID: seeded.sessionID, messages: seeded.messages, @@ -516,16 +521,31 @@ describe("plan follow-up", () => { }) await expect(pending).resolves.toBe("break") + const after = await Instance.provide({ + directory: dir, + fn: async () => sessions(), + }) - const after = await sessions() const prev = new Set(before.map((item) => item.id)) const added = after.filter((item) => !prev.has(item.id)) expect(added).toHaveLength(1) const next = added[0] + if (!next) throw new Error("expected follow-up session") expect(next?.directory).toBe(dir) expect(next?.parentID).toBeUndefined() if (next) { + const planPath = await Instance.provide({ + directory: dir, + fn: async () => Session.plan(await Session.get(seeded.sessionID)), + }) + const messages = await Session.messages({ sessionID: next.id }) + const user = messages.find((item) => item.info.role === "user") + if (!user || user.info.role !== "user") throw new Error("expected user message") + const part = user.parts.find((item) => item.type === "text") + if (!part || part.type !== "text") throw new Error("expected text part") + expect(part.text).toContain(`Plan file: ${planPath}`) + SessionPrompt.cancel(next.id) } })) diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index 1804ab5c2a2..e70ce1d2eed 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -1178,3 +1178,414 @@ test("diffFull with whitespace changes", async () => { }, }) }) + +// ── Tests for snapshot optimizations (upstream #17878, #20564) ──────── + +test("concurrent track() calls return consistent results", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Snapshot.track() + + await Filesystem.write(`${tmp.path}/a.txt`, "concurrent-change") + + const results = await Promise.all([ + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + ]) + + const hashes = results.filter(Boolean) + expect(hashes.length).toBe(5) + // All concurrent calls must return the same hash + expect(new Set(hashes).size).toBe(1) + }, + }) +}) + +test("batch revert with many files sharing same hash", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + // Create enough files to trigger batching (>1 per hash) + for (let i = 0; i < 20; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify all 20 files + for (let i = 0; i < 20; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) + } + // Add 5 new files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/new${i}.txt`, `new-${i}`) + } + + const patch = await Snapshot.patch(before!) + expect(patch.files.length).toBe(25) + + await Snapshot.revert([patch]) + + // Modified files should be restored + for (let i = 0; i < 20; i++) { + const content = await Filesystem.readText(`${tmp.path}/file${i}.txt`) + expect(content).toBe(`original-${i}`) + } + // New files should be deleted + for (let i = 0; i < 5; i++) { + expect( + await fs + .access(`${tmp.path}/new${i}.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) + } + }, + }) +}) + +test("batch revert with files in nested subdirectories", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.mkdir(`${dir}/a/b/c`, { recursive: true }) + await fs.mkdir(`${dir}/d/e`, { recursive: true }) + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/a/b/c/file${i}.txt`, `deep-${i}`) + await Filesystem.write(`${dir}/d/e/file${i}.txt`, `other-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${tmp.path}/a/b/c/file${i}.txt`, `modified-${i}`) + await Filesystem.write(`${tmp.path}/d/e/file${i}.txt`, `modified-${i}`) + } + + await Snapshot.revert([await Snapshot.patch(before!)]) + + for (let i = 0; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/a/b/c/file${i}.txt`)).toBe(`deep-${i}`) + expect(await Filesystem.readText(`${tmp.path}/d/e/file${i}.txt`)).toBe(`other-${i}`) + } + }, + }) +}) + +test("incremental add skips new files larger than 2MB", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Create a file larger than 2MB + const largeContent = "x".repeat(3 * 1024 * 1024) + await Filesystem.write(`${tmp.path}/large.bin`, largeContent) + // Also create a small file + await Filesystem.write(`${tmp.path}/small.txt`, "small change") + + const after = await Snapshot.track() + expect(after).toBeTruthy() + + const patch = await Snapshot.patch(before!) + // Small file should be tracked, large file should be excluded + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("small.txt") + expect(files).not.toContain("large.bin") + }, + }) +}) + +test("incremental add: tracked files that grow past 2MB are still visible in patch", async () => { + // The 2MB size filter only prevents NEW large files from being added to the + // snapshot index. Already-tracked files that grow past 2MB still appear in + // patch/diff because those compare against the working tree. This matches + // upstream OpenCode behavior. + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Filesystem.write(`${tmp.path}/growing.txt`, "small") + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Filesystem.write(`${tmp.path}/growing.txt`, "x".repeat(3 * 1024 * 1024)) + await Filesystem.write(`${tmp.path}/a.txt`, "changed") + + const patch = await Snapshot.patch(before!) + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("a.txt") + expect(files).toContain("growing.txt") + }, + }) +}) + +test("concurrent patch() calls return consistent results", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Filesystem.write(`${tmp.path}/a.txt`, "changed") + + const results = await Promise.all([ + Snapshot.patch(before!), + Snapshot.patch(before!), + Snapshot.patch(before!), + ]) + + // All should report the same changed files + for (const result of results) { + expect(result.files).toContain(fwd(tmp.path, "a.txt")) + } + }, + }) +}) + +test("batch revert with mix of modified, new, and deleted files", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 15; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify some files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `modified-${i}`) + } + // Delete some files + for (let i = 5; i < 10; i++) { + await fs.unlink(`${tmp.path}/file${i}.txt`) + } + // Add new files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/added${i}.txt`, `added-${i}`) + } + + await Snapshot.revert([await Snapshot.patch(before!)]) + + // Modified files restored + for (let i = 0; i < 5; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) + } + // Deleted files restored + for (let i = 5; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) + } + // Untouched files unchanged + for (let i = 10; i < 15; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`original-${i}`) + } + // New files removed + for (let i = 0; i < 5; i++) { + expect( + await fs + .access(`${tmp.path}/added${i}.txt`) + .then(() => true) + .catch(() => false), + ).toBe(false) + } + }, + }) +}) + +test("batch revert with multiple patches from different snapshots", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `v0-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Snapshot A + const snapA = await Snapshot.track() + expect(snapA).toBeTruthy() + + // Modify first 5 files + for (let i = 0; i < 5; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `v1-${i}`) + } + + // Snapshot B (captures v1 state) + const snapB = await Snapshot.track() + expect(snapB).toBeTruthy() + + // Modify last 5 files + for (let i = 5; i < 10; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `v2-${i}`) + } + + // Revert with patches from two different hashes + const patchA = await Snapshot.patch(snapA!) + const patchB = await Snapshot.patch(snapB!) + // patchA covers files 0-4 (changed in v1) + files 5-9 (changed in v2) + // patchB covers files 5-9 (changed in v2) + // Reverting [patchA, patchB]: patchA's hash wins for all files (first seen) + await Snapshot.revert([patchA, patchB]) + + // All files should be at v0 (snapA's state) + for (let i = 0; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`v0-${i}`) + } + }, + }) +}) + +test("concurrent track calls each produce a valid snapshot", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Snapshot.track() // warm up + + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) + } + + // Fire 5 concurrent tracks, then verify each hash is a usable snapshot + const hashes = (await Promise.all([ + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + Snapshot.track(), + ])).filter(Boolean) as string[] + + expect(hashes.length).toBe(5) + + // Every hash should produce a valid diffFull against itself (empty diff) + for (const hash of hashes) { + const diff = await Snapshot.diffFull(hash, hash) + expect(diff).toEqual([]) + } + + // Every hash should be usable for restore without error + await Snapshot.restore(hashes[0]!) + for (let i = 0; i < 10; i++) { + expect(await Filesystem.readText(`${tmp.path}/file${i}.txt`)).toBe(`changed-${i}`) + } + }, + }) +}) + +test("track after revert produces clean snapshot", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${dir}/file${i}.txt`, `original-${i}`) + } + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + for (let i = 0; i < 10; i++) { + await Filesystem.write(`${tmp.path}/file${i}.txt`, `changed-${i}`) + } + + await Snapshot.revert([await Snapshot.patch(before!)]) + + // After revert, a new track should match the original snapshot + const after = await Snapshot.track() + expect(after).toBeTruthy() + expect(after).toBe(before) + }, + }) +}) + +test("incremental add tracks newly created files", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Create several new files in subdirectories + await fs.mkdir(`${tmp.path}/newdir/sub`, { recursive: true }) + await Filesystem.write(`${tmp.path}/newdir/one.txt`, "one") + await Filesystem.write(`${tmp.path}/newdir/sub/two.txt`, "two") + await Filesystem.write(`${tmp.path}/three.txt`, "three") + + const patch = await Snapshot.patch(before!) + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("one.txt") + expect(files).toContain("two.txt") + expect(files).toContain("three.txt") + }, + }) +}) + +test("incremental add tracks modified and deleted files", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + // Modify one file, delete the other + await Filesystem.write(`${tmp.path}/a.txt`, "modified-a") + await fs.unlink(`${tmp.path}/b.txt`) + + const patch = await Snapshot.patch(before!) + const files = patch.files.map((f) => path.basename(f)) + expect(files).toContain("a.txt") + expect(files).toContain("b.txt") + }, + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 75d2c26d6b4..cf439ced8aa 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.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index d98211cbbc0..7eb0192c557 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.1.22", + "version": "7.1.23", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index be174960413..969a6d0aa44 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.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 406ac86df58..c874f5501d4 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.1.22", + "version": "7.1.23", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index c9110706fca..6535b8dcf0f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.1.22", + "version": "7.1.23", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index b8d00e59a36..ed93fce66e7 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "7.1.22", + "version": "7.1.23", "private": true, "type": "module", "license": "MIT", diff --git a/script/release b/script/release index 13761a1ec0b..044a2c59d6a 100755 --- a/script/release +++ b/script/release @@ -1,5 +1,6 @@ #!/usr/bin/env bash BUMP_TYPE=${1:-patch} +PRE_RELEASE=${2:-false} -gh workflow run publish.yml -f bump="$BUMP_TYPE" +gh workflow run publish.yml -f bump="$BUMP_TYPE" -f pre_release="$PRE_RELEASE" diff --git a/script/upstream/package.json b/script/upstream/package.json index facad75d5ae..76643aab7bc 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.1.22", + "version": "7.1.23", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index fe9c979ed85..12b653118d0 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.1.22", + "version": "7.1.23", "publisher": "sst-dev", "repository": { "type": "git",