Merge branch 'main' into mark/reimplement-mcp-removal

This commit is contained in:
Mark IJbema
2026-04-07 17:13:35 +02:00
committed by GitHub
201 changed files with 5725 additions and 1114 deletions
+8 -1
View File
@@ -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 }}
+4 -1
View File
@@ -4,7 +4,10 @@ node_modules
.worktrees
.sst
.env
.idea
.idea/*
!.idea/gradle.xml
!.idea/.run
.idea/*.iml
.vscode/*
!.vscode/extensions.json
!.vscode/launch.json
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="externalProjectPath" value="$PROJECT_DIR$/packages/kilo-jetbrains" />
<option name="gradleJvm" value="#JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$/packages/kilo-jetbrains" />
<option value="$PROJECT_DIR$/packages/kilo-jetbrains/backend" />
<option value="$PROJECT_DIR$/packages/kilo-jetbrains/frontend" />
<option value="$PROJECT_DIR$/packages/kilo-jetbrains/shared" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+27
View File
@@ -0,0 +1,27 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run JetBrains Plugin" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$/packages/kilo-jetbrains" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="runIde" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>false</GradleProfilingDisabled>
<GradleCoverageDisabled>false</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>
+332 -629
View File
File diff suppressed because it is too large Load Diff
+44 -18
View File
@@ -150,28 +150,54 @@
'';
in
pkgs.mkShell {
packages = with pkgs; [
bun
nodejs_20
pkg-config
openssl
git
gh
playwright-driver.browsers
vsce
unzip
gnutar
gzip
patchelf
ripgrep
kilo-dev
kilo-install-bin
kilo-bin
];
packages =
with pkgs;
[
bun
nodejs_20
pkg-config
openssl
git
gh
playwright-driver.browsers
vsce
unzip
gnutar
gzip
patchelf
ripgrep
jetbrains.jdk
jdk21
kilo-dev
kilo-install-bin
kilo-bin
]
++ lib.optionals stdenv.isLinux [
libX11
libXext
libXrender
libXtst
libXi
fontconfig
freetype
];
shellHook = ''
export KILO_ROOT="$PWD"
export PLAYWRIGHT_BROWSERS_PATH="${pkgs.playwright-driver.browsers}"
export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true
''
+ pkgs.lib.optionalString pkgs.stdenv.isLinux ''
export LD_LIBRARY_PATH="${
pkgs.lib.makeLibraryPath [
pkgs.libX11
pkgs.libXext
pkgs.libXrender
pkgs.libXtst
pkgs.libXi
pkgs.fontconfig
pkgs.freetype
]
}:$LD_LIBRARY_PATH"
'';
};
});
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4yd8H4UN7YIhdIQ607UoxSqwsQEKqUWkOifjQ2PEvn0=",
"aarch64-linux": "sha256-EuzzDc/j63eRV8QEFgDmQRIsiSq4ZPr9RJSdB9dH+kI=",
"aarch64-darwin": "sha256-BLCZubMC/n06HsUeO3FsvREwfsmjtiTBseSMDPPZNYM=",
"x86_64-darwin": "sha256-gU1uhExpYJhhVHbspCwCry/DwUiuddRvYl72RZqWzNo="
"x86_64-linux": "sha256-PvqhO2tOClenS37A80VsNuOglBs5fk8aaAtsktwG74s=",
"aarch64-linux": "sha256-+UN4dTY2s7GWjYumPoKTSv2pX7zhJB51hxJzCwqejaE=",
"aarch64-darwin": "sha256-N5JmmR6XrzGT86AL6TxpcQoZl7PTmgfd6ll5CVamQT0=",
"x86_64-darwin": "sha256-eyLUps3Kzg+6/VPt/XW78+vaL+9RY1aTtnnPxUPSkWQ="
}
}
+18 -8
View File
@@ -38,12 +38,12 @@
"@pierre/diffs": "1.1.0-beta.18",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"diff": "8.0.4",
"dompurify": "3.3.3",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
"ai": "5.0.124",
"hono": "4.10.7",
"hono": "4.12.12",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
@@ -58,11 +58,11 @@
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
"vite": "7.1.4",
"vite": "7.3.2",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.10",
"solid-js": "1.9.12",
"vite-plugin-solid": "2.11.10"
}
},
@@ -79,7 +79,7 @@
"turbo": "2.8.13"
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@aws-sdk/client-s3": "3.1025.0",
"@kilocode/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@kilocode/sdk": "workspace:*",
@@ -105,13 +105,23 @@
],
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:"
"@types/node": "catalog:",
"path-to-regexp": ">=8.4.0",
"picomatch": ">=2.3.2",
"defu": "6.1.6",
"lodash": "4.18.1",
"@xmldom/xmldom": ">=0.8.12",
"smol-toml": ">=1.6.1",
"fastify": ">=5.8.3",
"diff": "8.0.4",
"dompurify": "3.3.3",
"happy-dom": ">=20.8.9"
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@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.19",
"version": "7.1.22",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "7.1.19",
"version": "7.1.22",
"description": "",
"type": "module",
"exports": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "7.1.19",
"version": "7.1.22",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -43,7 +43,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"electron": "40.4.1",
"electron": "40.8.5",
"electron-builder": "^26",
"electron-vite": "^5",
"typescript": "~5.6.2",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "7.1.19",
"version": "7.1.22",
"type": "module",
"license": "MIT",
"scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.1.19"
version = "7.1.22"
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.19/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/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.19/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/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.19/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/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.19/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/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.19/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.1.22/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
@@ -0,0 +1,92 @@
import React from "react"
interface VideoEmbedProps {
url: string
title?: string
caption?: string
}
function extractVideoId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=)([^&\s]+)/,
/(?:youtube\.com\/embed\/)([^?\s]+)/,
/(?:youtu\.be\/)([^?\s]+)/,
/(?:youtube\.com\/v\/)([^?\s]+)/,
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match) {
return match[1] ?? null
}
}
return null
}
export function VideoEmbed({ url, title = "Video", caption }: VideoEmbedProps) {
const videoId = extractVideoId(url)
if (!videoId) {
return (
<div
style={{
padding: "1rem",
backgroundColor: "var(--red-100, #fee2e2)",
color: "var(--red-700, #b91c1c)",
borderRadius: "0.5rem",
margin: "1.5rem 0",
}}
>
Invalid YouTube URL: {url}
</div>
)
}
return (
<div
style={{
maxWidth: "640px",
margin: "1.5rem 0",
}}
>
<div
style={{
position: "relative",
paddingBottom: "56.25%",
height: 0,
overflow: "hidden",
borderRadius: "0.5rem",
}}
>
<iframe
src={`https://www.youtube.com/embed/${videoId}`}
title={title}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: "100%",
border: "none",
borderRadius: "0.5rem",
}}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
{caption && (
<figcaption
style={{
fontStyle: "italic",
textAlign: "center",
marginTop: "0.5rem",
color: "var(--gray-600, #6b7280)",
}}
>
{caption}
</figcaption>
)}
</div>
)
}
+1
View File
@@ -14,4 +14,5 @@ export * from "./TableOfContents"
export * from "./Tabs"
export * from "./ThemeToggle"
export * from "./TopNav"
export * from "./VideoEmbed"
export * from "./YouTube"
+1
View File
@@ -43,6 +43,7 @@ export const KiloClawNav: NavSection[] = [
subLinks: [
{ href: "/kiloclaw/triggers", children: "Overview" },
{ href: "/kiloclaw/triggers/webhooks", children: "Webhooks" },
{ href: "/kiloclaw/triggers/scheduled", children: "Scheduled" },
],
},
{
+3
View File
@@ -36,4 +36,7 @@ exclude = [
'^https?://api\.apertis\.ai/v1/?$',
# Redirects to authenticated Google Cloud console
'^https?://console\.cloud\.google\.com',
# Consistently times out in CI
'^https?://opncd\.ai',
'^https?://zod\.dev/v4/changelog',
]
+2
View File
@@ -5,4 +5,6 @@ export * from "./tabs.markdoc"
export * from "./icon.markdoc"
export * from "./image.markdoc"
export * from "./kilo-code-icon.markdoc"
export * from "./video.markdoc"
export * from "./youtube.markdoc"
@@ -0,0 +1,21 @@
import { VideoEmbed } from "../../components"
export const video = {
render: VideoEmbed,
selfClosing: true,
attributes: {
url: {
type: String,
required: true,
description: "The YouTube video URL",
},
title: {
type: String,
description: "Accessible title for the video iframe",
},
caption: {
type: String,
description: "Optional caption to display below the video",
},
},
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.1.19",
"version": "7.1.22",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -56,19 +56,32 @@ Once configured, the Review Agent runs automatically on PR/MR events. For platfo
Code Reviewer is also available locally. This is valuable for developers who want to review their code before pushing a pull request to their team publicly, or for developers who want reviews and don't need to ship a pull request to GitHub.
### VS Code
{% tabs %}
{% tab label="VSCode" %}
Select 'Review' from the mode dropdown after making local changes, and click 'Send' for AI-powered feedback and suggestions.
![VS Code interface showing Review option in mode dropdown](/docs/img/code-reviewer/review-mode.png "Review Mode")
### CLI
The CLI provides two commands for local code reviews:
Two slash commands are available for local code reviews:
- **`/local-review`** — Review all changes on your current branch vs the base branch
- **`/local-review-uncommitted`** — Review uncommitted changes (staged + unstaged)
{% /tab %}
{% tab label="CLI" %}
Two slash commands are available for local code reviews:
- **`/local-review`** — Review all changes on your current branch vs the base branch
- **`/local-review-uncommitted`** — Review uncommitted changes (staged + unstaged)
{% /tab %}
{% tab label="VSCode (Legacy)" %}
Select 'Review' from the mode dropdown after making local changes, and click 'Send' for AI-powered feedback and suggestions.
{% image src="/docs/img/code-reviewer/review-mode.png" alt="VS Code interface showing Review option in mode dropdown" width="800" caption="Review Mode" /%}
{% /tab %}
{% /tabs %}
## How Code Reviews Work
When a pull request or merge request is opened or updated:
@@ -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.<provider_id>.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,6 +91,43 @@ 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)
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. |
| `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
"limit": {
"context": 131072,
"output": 16384
}
```
#### How limits are resolved
Kilo resolves token limits in this order:
1. **Your config** — values you set under `provider.<id>.models.<model>.limit`
2. **Built-in catalog** — Kilo ships a snapshot of [models.dev](https://models.dev) and refreshes it hourly. If your model ID matches a known model, catalog values are used as defaults.
3. **Fallback** — if neither source provides a value, `context` and `output` default to `0`.
#### What happens when limits are `0`
If you use a custom or local model and don't specify limits — and the model isn't in the built-in catalog — both `context` and `output` resolve to `0`. This has meaningful side effects:
- **Compaction is disabled.** Kilo uses `context` to detect when the conversation exceeds the model's window and needs to be summarized. With `context: 0`, overflow detection is skipped and conversations will grow unbounded until the provider rejects the request.
- **Output falls back to 32,000 tokens.** When `output` is `0`, Kilo uses its internal default of 32,000 tokens (configurable via the `KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX` environment variable).
- **No context usage tracking.** Usage metrics that depend on knowing the context size are skipped.
{% callout type="warning" %}
For custom and local models, always set `limit.context` and `limit.output` to match the model's actual capabilities. Without these values, automatic context management is disabled.
{% /callout %}
## Examples
### Local model with LM Studio
@@ -226,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:
@@ -313,5 +350,6 @@ Control which models appear in the model picker for a provider using allowlists
**Model errors or unexpected behavior:**
- Set `tool_call: true` if you need the model to use tools (file editing, terminal, etc.)
- Set `limit.context` and `limit.output` to match the model's actual capabilities
- Set `limit.context` and `limit.output` to match the model's actual capabilities — see [Token Limits](#token-limits-limit) above for details and defaults
- If conversations seem to grow without being compacted, your `limit.context` is likely `0` (unset)
- For local models, ensure your inference server is running and accessible at the configured URL
@@ -2,6 +2,80 @@
This guide walks you through setting up Mistral's Codestral model for free autocomplete in Kilo Code. Mistral offers a free tier that's perfect for getting started with AI-powered code completions.
{% tabs %}
{% tab label="VS Code" %}
## Prerequisites
- A [Kilo Code account](https://app.kilo.ai) (free to create)
- A Mistral AI account with a Codestral API key
## Step 1: Navigate to Codestral in Mistral AI Studio
Go to the [Mistral AI console](https://console.mistral.ai/) and sign up or sign in to your account. In the sidebar, click **Codestral** under the Code section.
![Select Codestral](/docs/img/mistral-setup/06-navigate-to-codestral.png)
## Step 2: Generate API Key
Click the **Generate API Key** button to create your new Codestral API key.
![Confirm Generate](/docs/img/mistral-setup/07-confirm-key-generation.png)
## Step 3: Copy Your API Key
Once generated, click the **copy** button next to your API key to copy it to your clipboard.
![Copy API Key](/docs/img/mistral-setup/08-copy-api-key.png)
{% callout type="note" %}
The Codestral API key is separate from the standard Mistral La Plateforme API key. Make sure you generate a key specifically from the **Codestral** section of the Mistral console.
{% /callout %}
## Step 4: Add Your Key via BYOK in Kilo
1. Log into the [Kilo platform](https://app.kilo.ai).
2. Navigate to the [Bring Your Own Key (BYOK) page](https://app.kilo.ai/byok), available in the sidebar under **Account**.
3. Click **Add Your First Key** (or **Add Key** if you already have keys configured).
4. Select **Codestral** as the provider.
5. Paste your Codestral API key.
6. Click **Save**.
{% callout type="tip" %}
For more details on BYOK, see the [Bring Your Own Key documentation](/docs/getting-started/byok).
{% /callout %}
## Step 5: Verify Autocomplete is Working
Once your BYOK key is saved, Kilo Code's autocomplete will automatically use your Codestral key through the Kilo Gateway. No additional configuration is needed in the extension.
1. Open VS Code with the Kilo Code extension installed.
2. Start typing in any code file — you should see inline ghost-text suggestions powered by Codestral.
3. Press `Tab` to accept a suggestion.
The autocomplete status bar in VS Code shows the current provider ("Kilo Gateway") and tracks cumulative cost. With BYOK, requests are billed directly by Mistral at their rates (Codestral has a free tier) and show as $0.00 on your Kilo balance.
## How It Works
When you add a Codestral BYOK key, the request flow is:
```
Your Editor → Kilo Gateway (with your key) → Mistral
```
- The Kilo Gateway detects your BYOK key and routes autocomplete requests using it.
- You are billed directly by Mistral — Kilo does not add any markup.
- If your BYOK key is invalid, the request will fail (it does not fall back to Kilo's keys).
## Troubleshooting
- **Autocomplete not appearing?** Check that autocomplete is enabled in Kilo Code settings (it is on by default). Also verify you are signed into Kilo Code in the extension.
- **Key not working?** Ensure you copied the **Codestral** API key (not the standard La Plateforme key). You can verify your key at [console.mistral.ai/codestral](https://console.mistral.ai/codestral).
- **Seeing charges on your Kilo balance?** If you haven't configured BYOK, autocomplete defaults to using your Kilo credits. Add your Codestral key via BYOK to route requests through your own Mistral account.
{% /tab %}
{% tab label="VS Code Legacy" %}
## Video Walkthrough
{% youtube url="https://www.youtube.com/embed/0aqBbB8fPho" caption="Setting up Mistral for free autocomplete in Kilo Code" /%}
@@ -74,6 +148,9 @@ Click **Save** to apply your Mistral configuration. You're now ready to use free
![Save Settings](/docs/img/mistral-setup/10-save-settings.png)
{% /tab %}
{% /tabs %}
## Next Steps
- Learn more about [Autocomplete features](/docs/code-with-ai/features/autocomplete)
@@ -112,30 +112,43 @@ Cloud Agents are great for:
- **Automated refactors or tech debt cleanup** driven by Kilo Code
- **Offloading CI-like tasks**, experiments, or batch updates
## Webhook Triggers
## Triggers
Webhook triggers allow you to initiate cloud agent sessions via HTTP requests. This enables integration with external services and automation workflows.
Triggers allow you to initiate cloud agent sessions automatically, either via HTTP requests (webhooks) or on a recurring schedule. This enables integration with external services and time-based automation workflows.
{% callout type="note" %}
Webhook triggers are currently in beta and subject to change.
Triggers are currently in beta and subject to change.
{% /callout %}
### Accessing Webhooks
### Accessing Triggers
Webhook triggers are accessible from the main sidebar with an entry named **Webhook** and link to [https://app.kilo.ai/cloud/webhooks](https://app.kilo.ai/cloud/webhooks) for personal accounts. Organization-level webhook configurations are available through your organization's sidebar.
Triggers are accessible from the main sidebar under **Webhooks / Triggers** and link to [https://app.kilo.ai/cloud/triggers](https://app.kilo.ai/cloud/triggers) for personal accounts. Organization-level trigger configurations are available through your organization's sidebar.
### Activation Modes
When creating a trigger, you choose an **activation mode** that cannot be changed after creation:
- **Webhook**: Fires when an external service sends an HTTP request to the trigger's URL
- **Scheduled**: Fires on a recurring schedule defined by a cron expression
### Configuration
Webhook triggers utilize [agent environment profiles](#agent-environment-profiles) to configure the execution environment for triggered sessions. The agent resolves the profile at runtime, so profile updates apply automatically to future executions. Profiles referenced by triggers cannot be deleted until those triggers are updated or removed.
Triggers utilize [agent environment profiles](#agent-environment-profiles) to configure the execution environment for triggered sessions. The agent resolves the profile at runtime, so profile updates apply automatically to future executions. Profiles referenced by triggers cannot be deleted until those triggers are updated or removed.
Webhook triggers do not support manual env var or setup command overrides at this time.
Triggers do not support manual env var or setup command overrides at this time.
### Scheduled Triggers
Scheduled triggers fire on a recurring schedule using cron expressions. You can configure them with a simple frequency picker (every 10 minutes, hourly, daily, weekly) or enter a raw cron expression for full control. Each trigger has a configurable timezone (default: UTC) and handles daylight saving time transitions automatically.
The minimum schedule interval is 10 minutes. Scheduled triggers use `{{scheduledTime}}` and `{{timestamp}}` as prompt template variables (webhook-specific variables like `{{body}}` are not available since there is no inbound HTTP request).
### Trigger Limits and Guidance
Webhook triggers are designed for low-volume invocations from trusted sources and are best suited for short-lived tasks.
Triggers are designed for low-volume invocations from trusted sources and are best suited for short-lived tasks.
- **Personal webhooks**: Execute in the same sandbox container as a user's Cloud Agent sessions. You can view/join invocations live.
- **Organization webhooks**: Execute in dedicated compute resources as a bot user, similar to Code Review sessions. You can share/fork the sessions when they're complete.
- **Personal triggers**: Execute in the same sandbox container as a user's Cloud Agent sessions. You can view/join invocations live.
- **Organization triggers**: Execute in dedicated compute resources as a bot user, similar to Code Review sessions. You can share/fork the sessions when they're complete.
Additional limits:
@@ -144,11 +157,13 @@ Additional limits:
- **Retention**: only the **most recent 100 requests per trigger** are retained
- **In-flight cap**: at most **20 requests per trigger** can be in `captured` or `inprogress` at once (returns `429`)
The webhook endpoint will return rate limit responses when the number of queued or processing requests exceeds system capacity.
The trigger endpoint will return rate limit responses when the number of queued or processing requests exceeds system capacity.
### Webhook Prompt Template Variables
### Prompt Template Variables
You can reference request data in a trigger’s prompt template using these placeholders:
You can reference data in a trigger’s prompt template using these placeholders.
**Webhook triggers:**
- `{{body}}` - raw request body (string)
- `{{bodyJson}}` - pretty-printed JSON if parseable, otherwise raw body
@@ -159,6 +174,11 @@ You can reference request data in a trigger’s prompt template using these plac
- `{{sourceIp}}` - client IP if provided (falls back to `unknown`)
- `{{timestamp}}` - capture timestamp (ISO string)
**Scheduled triggers:**
- `{{scheduledTime}}` - the time the schedule fired (ISO string)
- `{{timestamp}}` - capture timestamp (ISO string)
{% callout type="warning" title="Security Considerations" %}
Care should be taken when deciding to use webhooks as they are susceptible to prompt injection attacks. Especially in scenarios where webhook payloads may contain untrusted input. At this time we recommend using webhooks only for trusted sources.
{% /callout %}
@@ -37,8 +37,20 @@ See [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actio
### Is the context progress graph still available?
The context progress graph is being evaluated for the new extension.
This feature may be reintroduced in a future update.
The context progress graph will be [added soon](https://github.com/Kilo-Org/kilocode/issues/8210) for users who like to see it.
### I like to closely monitor and approve the behavior of the agent. How can I do that better in the new version?
We are working to improve the experience in closely managing an agent. Identified improvements and progress are being tracked in a [GitHub issue](https://github.com/Kilo-Org/kilocode/issues/8415).
In the meantime we suggest exploring:
- [Auto-approval](https://kilo.ai/docs/getting-started/settings/auto-approving-actions) of actions: to control what the agent is allowed to do, and require approval when desired
- [Agents](https://kilo.ai/docs/code-with-ai/agents/using-agents) (previously known as Modes): Managing the agent types in the extension, adding new ones, and setting the default models for each.
### How can I control which models each agent/mode uses?
Modes have been renamed to Agents in the new extension. You can set the default model for each agent in `Settings -> Models -> Model per Mode`. For more information please check the [agents documentation](https://kilo.ai/docs/code-with-ai/agents/using-agents).
### Where is the diff view for file changes?
@@ -50,6 +62,18 @@ 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.
### Where did orchestrator mode go?
Orchestrator mode is deprecated.
@@ -20,9 +20,9 @@ Because Autocomplete needs to be ready the moment you start typing, the model st
#### How much does it cost?
You can use Codestral 2508 for Autocomplete completely for free by configuring it through our Mistral integration.
You can use Codestral for Autocomplete without consuming Kilo credits by adding your own Mistral Codestral API key via BYOK (Bring Your Own Key). Mistral offers a free tier for Codestral.
**Setup Guide:** [Setting up Mistral for Free Autocomplete](/docs/code-with-ai/features/autocomplete/mistral-setup#setting-up-mistral-for-free-autocomplete)
**Setup Guide:** [Setting Up Mistral for Free Autocomplete](/docs/code-with-ai/features/autocomplete/mistral-setup)
#### How to Disable These Requests
@@ -64,9 +64,9 @@ If you run out of credits and haven't configured a free alternative, autocomplet
### How to Get It Free
Configure Mistral directly as your autocomplete provider. Mistral offers free access to their Codestral model, which is optimized for code completions. When you configure Mistral directly, it takes precedence over the default Kilo Code routing.
Add your own Mistral Codestral API key via **BYOK (Bring Your Own Key)** on the Kilo Gateway. Mistral offers a free tier for Codestral, and when you configure a BYOK key, autocomplete requests are routed using your key — billed directly by Mistral at $0 on your Kilo balance.
For step-by-step instructions with screenshots, see our [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup).
For step-by-step instructions, see our [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup).
## Free CLI Background Tasks
@@ -99,6 +99,6 @@ Replace `your-preferred-free-model` with any free model available in the model p
## Related Resources
- [Free and Budget Models](/docs/code-with-ai/agents/free-and-budget-models) - Complete guide to free and budget-friendly model options
- [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) - Step-by-step free autocomplete setup
- [Mistral Setup Guide](/docs/code-with-ai/features/autocomplete/mistral-setup) - Step-by-step autocomplete setup via BYOK
- [Autocomplete](/docs/code-with-ai/features/autocomplete) - Full autocomplete documentation
- [CLI Documentation](/docs/code-with-ai/platforms/cli) - Complete CLI reference
@@ -5,6 +5,8 @@ description: "Connect your KiloClaw agent to Slack"
# Slack
{% youtube url="https://youtu.be/Q5bt-qH-_pY" title="Slack Setup Guide" caption="How to connect your KiloClaw agent to Slack" /%}
Connect your KiloClaw agent to Slack by creating a Slack app from the OpenClaw manifest and linking it to your KiloClaw dashboard.
## Step 1: Create a Slack App from the OpenClaw Manifest
@@ -5,6 +5,8 @@ description: "Connect your KiloClaw agent to Telegram"
# Telegram
{% youtube url="https://youtu.be/hIfKz073hGw" title="Telegram Setup Guide" caption="How to connect your KiloClaw agent to Telegram" /%}
Connect your KiloClaw agent to Telegram by creating a bot via BotFather and linking it to your KiloClaw dashboard.
1. Open Telegram and search for [@BotFather](https://t.me/BotFather)
@@ -42,6 +42,8 @@ Before you begin, make sure you have:
## Setup
{% youtube url="https://youtu.be/PX444_j3O4I" title="Google Workspace Setup Guide" caption="How to connect your Google account to KiloClaw" /%}
1. Go to the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard)
2. Find the **Google Account** section
3. Copy the provided `docker run` command — it includes a short-lived authentication token
@@ -25,3 +25,45 @@ For security reasons, SSH access is currently disabled for all KiloClaw instance
Do **not** click **Update Now** inside the OpenClaw Control UI — this is not supported for KiloClaw instances and may break your setup.
Updates are managed by the KiloClaw platform team to ensure stability. When a new version is available, it will be announced in the **Changelog** on your dashboard. To apply the update, click **Upgrade & Redeploy** from the [KiloClaw Dashboard](/docs/kiloclaw/dashboard#redeploy).
## How do I migrate my OpenClaw?
Whether you're migrating from another OpenClaw provider to KiloClaw, moving between KiloClaw instances (e.g., individual to org or vice versa), or leaving KiloClaw for another OpenClaw provider, you should plan to migrate your workspace, memory, and context so your new Claw retains the same knowledge as before.
You should plan to reconfigure integrations in the new instance as these are often tied to the instance and will break if you attempt migration.
### 1. Back up your workspace
Have your current instance export the workspace. We recommend creating a GitHub repo or `tar` archive file for easy loading.
If you are on KiloClaw, you can use
**GitHub export** — make sure [GitHub is configured](/docs/kiloclaw/development-tools/github) and ask your instance:
> Create a new GitHub repo and push your entire workspace there with the `gh` CLI. Tell me the URL of the repo you used.
**Google Drive** — make sure [Google Drive is configured](/docs/kiloclaw/development-tools/google) and ask your instance:
> Tar compress your workspace and push the file to Google Drive with the `gog` CLI. Then share the filename you used.
### 2. Stand up the new instance
### 3. Reconfigure integrations on the new instance
If you are using GitHub or Google Drive for the migration, prioritize that configuration.
### 4. Restore the workspace on the new instance
On your new Claw, restore the workspace from whichever backup method you used:
**From GitHub:**
> The GitHub repo `<repo>` has a backup of your workspace. Pull the workspace from the repo with the `gh` CLI and overwrite the existing workspace directory with the repo's contents.
**From Google Drive:**
> The Google Drive file `<filename>` has a backup of your workspace. Pull the tar file from Google Drive with the `gog` CLI and overwrite the existing workspace directory with its contents.
{% callout type="note" %}
Replace `<repo>` or `<filename>` with the actual repository URL or filename from the backup step.
{% /callout %}
@@ -7,26 +7,31 @@ description: "Automate your KiloClaw agent with webhooks and scheduled triggers"
Triggers let external events and schedules drive your KiloClaw agent automatically. Instead of typing every instruction yourself, triggers deliver messages to your agent on your behalf. This lets it react to real-world events or run tasks on a schedule without polling.
All triggers are managed from the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard).
All triggers are managed from the **Settings** page in the KiloClaw section of the sidebar.
## Trigger Types
| Type | Description | Status |
| --- | --- | --- |
| [**Webhooks**](/docs/kiloclaw/triggers/webhooks) | Receive HTTP requests from external services (GitHub, Stripe, monitoring tools, etc.) and deliver them as chat messages to your agent | Available |
| **Time-based** | Run tasks on a schedule using cron expressions | Coming soon |
| Type | Description |
| --- | --- |
| [**Webhooks**](/docs/kiloclaw/triggers/webhooks) | Receive HTTP requests from external services (GitHub, Stripe, monitoring tools, etc.) and deliver them as chat messages to your agent |
| [**Scheduled**](/docs/kiloclaw/triggers/scheduled) | Run tasks on a recurring schedule (e.g. every 15 minutes, daily at 9 AM, weekdays only) |
## How Triggers Work
1. An event occurs (an HTTP request arrives, or a schedule fires)
2. The trigger validates the request and renders the payload through a **prompt template**
3. The rendered message is delivered to your KiloClaw instance as a chat message
1. A trigger fires
2. Your **prompt template** is rendered into a message
3. That message is delivered to your KiloClaw instance as a chat message
4. Your agent processes and responds like any other conversation
Prompt templates give you control over how payloads are presented to your agent. You can include instructions, context, and formatting alongside the raw event data.
Each trigger type has its own set of template variables. See the [Webhooks](/docs/kiloclaw/triggers/webhooks) and [Scheduled](/docs/kiloclaw/triggers/scheduled) pages for details.
{% callout type="warning" title="Triggers send prompts directly to your agent" %}
When a trigger fires, the rendered message is sent directly to your KiloClaw agent as a prompt. If your instance is configured with a permission model that allows all actions, the agent will execute commands automatically without your explicit approval. This means triggers can cause your agent to take actions without you being aware. Review your instance's [permission settings](/docs/kiloclaw/control-ui/exec-approvals) and prompt templates carefully before enabling triggers.
{% /callout %}
## Related
- [Webhooks](/docs/kiloclaw/triggers/webhooks)
- [Scheduled Triggers](/docs/kiloclaw/triggers/scheduled)
- [KiloClaw Overview](/docs/kiloclaw/overview)
- [Dashboard Reference](/docs/kiloclaw/dashboard)
@@ -0,0 +1,123 @@
---
title: "Scheduled Triggers"
description: "Run tasks on a schedule using cron expressions"
---
# Scheduled Triggers
Scheduled triggers let your KiloClaw agent run tasks automatically on a recurring schedule. Instead of waiting for an external event, a scheduled trigger fires at the times you define using cron expressions. When it fires, the prompt template is rendered and delivered as a chat message to your KiloClaw instance, just like a webhook.
## Setup
1. Go to **Settings** under the KiloClaw section in the sidebar
2. Find the **Scheduled Triggers** section and click **Add Scheduled Trigger**
3. Give your trigger a name (minimum 8 characters)
4. Configure the schedule and prompt template
5. Click **Save**
Each KiloClaw instance supports up to **5 scheduled triggers** alongside its single webhook.
## Configuring a Schedule
The schedule builder defaults to a friendly picker view. For more control, click **<> Advanced** to switch to raw cron input.
### Simple Mode (default)
Pick a frequency, time, and (optionally) days of the week from dropdown menus. The builder generates the cron expression for you behind the scenes and shows a preview of the next 5 upcoming runs.
- **Repeat**: Every 10 minutes, every 15 minutes, every 30 minutes, hourly, daily, weekly
- **At**: Select the time of day (for daily and weekly frequencies)
- **Day of week**: Select which days the trigger should fire (for weekly frequency)
### Advanced Mode
Click **<> Advanced** to enter a raw cron expression directly. This gives you full control over the schedule. The expression is validated in real time with a preview of upcoming fire times.
Cron expressions use the standard five-field format:
```
┌───────── minute (0-59)
│ ┌───────── hour (0-23)
│ │ ┌───────── day of month (1-31)
│ │ │ ┌───────── month (1-12)
│ │ │ │ ┌───────── day of week (0-7, where 0 and 7 are Sunday)
│ │ │ │ │
* * * * *
```
**Examples:**
| Expression | Meaning |
| --- | --- |
| `*/15 * * * *` | Every 15 minutes |
| `0 9 * * 1-5` | 9:00 AM on weekdays |
| `0 0 1 * *` | Midnight on the first of each month |
| `30 14 * * 3` | 2:30 PM every Wednesday |
{% callout type="note" title="Minimum interval" %}
The minimum interval between scheduled trigger runs is 10 minutes. Schedules more frequent than that are rejected.
{% /callout %}
### Timezone
Select a timezone for your schedule. The default is UTC. All fire times are calculated relative to the selected timezone, including automatic handling of daylight saving time transitions.
## Prompt Template
The prompt template controls what message your agent receives when the schedule fires. You can customize it from the trigger's settings.
**Default template:**
```
Run your scheduled task. Triggered at {{scheduledTime}}.
```
**Available variables:**
| Variable | Description |
| --- | --- |
| `{{scheduledTime}}` | The time the schedule fired (ISO string) |
| `{{timestamp}}` | Capture timestamp (ISO string) |
{% callout type="note" title="Webhook variables are not available" %}
Since scheduled triggers do not receive an HTTP request, variables like `{{body}}`, `{{bodyJson}}`, `{{headers}}`, `{{method}}`, `{{path}}`, and `{{query}}` are not populated. Use `{{scheduledTime}}` and `{{timestamp}}` instead.
{% /callout %}
## Managing Scheduled Triggers
### Pause and Resume
Toggle the **Active/Paused** switch to temporarily stop a trigger from firing. When paused, the schedule is suspended but the configuration is preserved. Resume at any time to restart the schedule.
### Edit
You can update the cron expression, timezone, and prompt template of an existing scheduled trigger at any time. The activation mode (webhook vs. scheduled) cannot be changed after creation.
### Delete
Remove a scheduled trigger from the inline controls in the Settings panel. A confirmation dialog is shown before deletion.
## Viewing Scheduled Trigger Activity
Scheduled trigger invocations appear in the same request history as webhooks. The **Source** column shows a **Scheduled** badge to distinguish them from webhook-triggered requests. Click into a request to see the scheduled fire time and other details.
## Example: Daily Standup Summary
Create a scheduled trigger that fires every weekday morning and asks your agent to summarize overnight activity:
1. Add a scheduled trigger in your KiloClaw Settings
2. Set the frequency to **Weekly** on **Monday through Friday** at **9:00 AM** in your local timezone
3. Customize the prompt template:
```
Good morning! Please summarize any overnight activity in the #engineering Slack channel and list open pull requests that need review today. Triggered at {{scheduledTime}}.
```
Your agent will receive this message every weekday at 9:00 AM and respond with the summary.
## Related
- [Webhooks](/docs/kiloclaw/triggers/webhooks)
- [Triggers Overview](/docs/kiloclaw/triggers)
- [KiloClaw Overview](/docs/kiloclaw/overview)
- [Dashboard Reference](/docs/kiloclaw/dashboard)
@@ -9,7 +9,7 @@ KiloClaw supports inbound webhooks so external events can trigger your agent aut
## Setup
1. Go to the **Settings** tab on your [KiloClaw dashboard](/docs/kiloclaw/dashboard)
1. Go to **Settings** under the KiloClaw section in the sidebar
2. Find the **Webhook Integration** card and click **Manage**
3. Click **Set Up Webhook**
@@ -104,6 +104,8 @@ Now every push to that repository sends a payload to your agent. Customize the p
## Related
- [Scheduled Triggers](/docs/kiloclaw/triggers/scheduled)
- [Triggers Overview](/docs/kiloclaw/triggers)
- [KiloClaw Overview](/docs/kiloclaw/overview)
- [Dashboard Reference](/docs/kiloclaw/dashboard)
- [GitHub Integration](/docs/kiloclaw/development-tools/github)
+4 -2
View File
@@ -1,7 +1,7 @@
# Source Code Links
<!-- Auto-generated by script/extract-source-links.ts — DO NOT EDIT -->
<!-- 79 unique URLs extracted from extension and CLI source -->
<!-- 80 unique URLs extracted from extension and CLI source -->
- <https://api.apertis.ai/v1>
<!-- packages/opencode/src/provider/model-cache.ts -->
@@ -21,7 +21,7 @@
<!-- packages/kilo-vscode/webview-ui/src/components/profile/ProfileView.tsx -->
- <https://app.kilo.ai/usage>
<!-- packages/opencode/src/kilocode/components/dialog-kilo-profile.tsx -->
- <https://blog.kilo.ai/we-completely-rebuilt-the-kilo-vs-code-extension>
- <https://blog.kilo.ai/p/new-kilo-for-vs-code-is-live>
<!-- packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx -->
- <https://chatgpt.com/explore/plus>
<!-- packages/opencode/src/provider/error.ts -->
@@ -83,6 +83,8 @@
- <https://kilo.ai/docs>
<!-- packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts -->
<!-- packages/opencode/src/cli/cmd/tui/app.tsx -->
- <https://kilo.ai/docs/code-with-ai/platforms/vscode/whats-new>
<!-- packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx -->
- <https://kilo.ai/docs/providers/#custom-provider>
<!-- packages/kilo-vscode/webview-ui/src/components/settings/CustomProviderDialog.tsx -->
- <https://kilo.ai/gateway>
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.1.19",
"version": "7.1.22",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
@@ -43,7 +43,7 @@
"@types/node": "catalog:",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:",
"solid-js": "1.9.10",
"solid-js": "catalog:",
"@opentui/core": "0.1.75",
"@opentui/solid": "0.1.75"
},
+2 -2
View File
@@ -27,8 +27,8 @@ export const DEFAULT_FREE_MODEL = "kilo-auto/free"
/** Token expiration duration in milliseconds (1 year) */
export const TOKEN_EXPIRATION_MS = 365 * 24 * 60 * 60 * 1000
/** User-Agent header value for requests */
export const USER_AGENT = "opencode-kilo-provider"
/** User-Agent header base value for requests */
export const USER_AGENT_BASE = "opencode-kilo-provider"
/** Content-Type header value for requests */
export const CONTENT_TYPE = "application/json"
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod"
import { getKiloUrlFromToken } from "../auth/token.js"
import { DEFAULT_HEADERS } from "../headers.js"
import { getDefaultHeaders } from "../headers.js"
import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js"
/**
@@ -88,7 +88,7 @@ export async function fetchKiloModels(options?: {
// Fetch models with timeout
const response = await fetch(modelsURL, {
headers: {
...DEFAULT_HEADERS,
...getDefaultHeaders(),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
+2 -2
View File
@@ -1,6 +1,6 @@
import { z } from "zod"
import { KILO_API_BASE, MODELS_FETCH_TIMEOUT_MS } from "./constants.js"
import { DEFAULT_HEADERS } from "../headers.js"
import { getDefaultHeaders } from "../headers.js"
/**
* Group entry in an organization mode config.
@@ -70,7 +70,7 @@ export async function fetchOrganizationModes(token: string, organizationId: stri
const url = `${KILO_API_BASE}/api/organizations/${encodeURIComponent(organizationId)}/modes`
const response = await fetch(url, {
headers: {
...DEFAULT_HEADERS,
...getDefaultHeaders(),
Authorization: `Bearer ${token}`,
},
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
+15 -4
View File
@@ -6,7 +6,7 @@ import {
HEADER_EDITORNAME,
HEADER_MACHINEID,
HEADER_FEATURE,
USER_AGENT,
USER_AGENT_BASE,
CONTENT_TYPE,
DEFAULT_EDITOR_NAME,
ENV_EDITOR_NAME,
@@ -36,12 +36,23 @@ export function getFeatureHeader(): string | undefined {
return process.env[ENV_FEATURE] || undefined
}
/**
* Get User-Agent header value.
* Appends the version from KILOCODE_VERSION when available.
*/
export function getUserAgent(): string {
const version = process.env[ENV_VERSION]
return version ? `${USER_AGENT_BASE}/${version}` : USER_AGENT_BASE
}
/**
* Default headers for KiloCode requests
*/
export const DEFAULT_HEADERS = {
"User-Agent": USER_AGENT,
"Content-Type": CONTENT_TYPE,
export function getDefaultHeaders(): Record<string, string> {
return {
"User-Agent": getUserAgent(),
"Content-Type": CONTENT_TYPE,
}
}
/**
+2 -2
View File
@@ -9,7 +9,7 @@ export { KiloAuthPlugin, default } from "./plugin.js"
export { createKilo } from "./provider.js"
export { createKiloDebug } from "./provider-debug.js"
export { kiloCustomLoader } from "./loader.js"
export { buildKiloHeaders, getEditorNameHeader, getFeatureHeader, DEFAULT_HEADERS } from "./headers.js"
export { buildKiloHeaders, getEditorNameHeader, getFeatureHeader, getDefaultHeaders, getUserAgent } from "./headers.js"
// ============================================================================
// Auth
@@ -86,7 +86,7 @@ export {
DEFAULT_MODEL,
DEFAULT_FREE_MODEL,
TOKEN_EXPIRATION_MS,
USER_AGENT,
USER_AGENT_BASE,
CONTENT_TYPE,
DEFAULT_PROVIDER_NAME,
ANONYMOUS_API_KEY,
+2 -2
View File
@@ -2,7 +2,7 @@ import { createOpenRouter } from "@openrouter/ai-sdk-provider"
import type { Provider as SDK } from "ai"
import type { KiloProviderOptions } from "./types.js"
import { getKiloUrlFromToken, getApiKey } from "./auth/token.js"
import { buildKiloHeaders, DEFAULT_HEADERS } from "./headers.js"
import { buildKiloHeaders, getDefaultHeaders } from "./headers.js"
import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js"
/**
@@ -32,7 +32,7 @@ export function createKiloDebug(options: KiloProviderOptions = {}): SDK {
// Merge custom headers with defaults
const customHeaders = {
...DEFAULT_HEADERS,
...getDefaultHeaders(),
...buildKiloHeaders(undefined, {
kilocodeOrganizationId: options.kilocodeOrganizationId,
kilocodeTesterWarningsDisabledUntil: undefined,
+2 -2
View File
@@ -4,7 +4,7 @@ import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import type { KiloProvider, KiloProviderOptions } from "./types.js"
import { getKiloUrlFromToken, getApiKey } from "./auth/token.js"
import { buildKiloHeaders, DEFAULT_HEADERS } from "./headers.js"
import { buildKiloHeaders, getDefaultHeaders } from "./headers.js"
import { KILO_API_BASE, ANONYMOUS_API_KEY } from "./api/constants.js"
/**
@@ -39,7 +39,7 @@ export function createKilo(options: KiloProviderOptions = {}): KiloProvider {
// Merge custom headers with defaults
const customHeaders = {
...DEFAULT_HEADERS,
...getDefaultHeaders(),
...buildKiloHeaders(undefined, {
kilocodeOrganizationId: options.kilocodeOrganizationId,
kilocodeTesterWarningsDisabledUntil: undefined,
+21 -13
View File
@@ -8,7 +8,7 @@
import { fetchProfile, fetchBalance } from "../api/profile.js"
import { fetchKilocodeNotifications, KilocodeNotificationSchema } from "../api/notifications.js"
import { fetchOrganizationModes, clearModesCache } from "../api/modes.js"
import { KILO_API_BASE, HEADER_FEATURE } from "../api/constants.js"
import { KILO_API_BASE, HEADER_FEATURE, HEADER_ORGANIZATIONID } from "../api/constants.js"
import { buildKiloHeaders } from "../headers.js"
import type { ImportDeps, DrizzleDb } from "../cloud-sessions.js"
import { fetchCloudSession, fetchCloudSessionForImport, importSessionToDb } from "../cloud-sessions.js"
@@ -536,12 +536,16 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
if (!token) return c.json({ error: "No valid token found" }, 401)
const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
const organizationId = auth.type === "oauth" ? auth.accountId : undefined
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
if (organizationId) {
headers[HEADER_ORGANIZATIONID] = organizationId
}
const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/status`, { headers })
if (!response.ok) {
const text = await response.text()
@@ -589,12 +593,16 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
const token = auth.type === "api" ? auth.key : auth.type === "oauth" ? auth.access : undefined
if (!token) return c.json({ error: "No valid token found" }, 401)
const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/chat-credentials`, {
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
})
const organizationId = auth.type === "oauth" ? auth.accountId : undefined
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
}
if (organizationId) {
headers[HEADER_ORGANIZATIONID] = organizationId
}
const response = await fetch(`${KILO_API_BASE}/api/kiloclaw/chat-credentials`, { headers })
if (!response.ok) {
const text = await response.text()
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.1.19",
"version": "7.1.22",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+5
View File
@@ -0,0 +1,5 @@
.gradle/
build/
.intellijPlatform/
.ai/
!gradle/wrapper/gradle-wrapper.jar
+71
View File
@@ -0,0 +1,71 @@
# Kilo JetBrains
AI coding agent plugin for JetBrains IDEs.
---
## Set up your environment
### Prerequisites
- **Bun** -- used to build CLI binaries and run build scripts
- **JDK 21+** -- required by Gradle and the IntelliJ Platform SDK
- **IntelliJ IDEA** -- to run the plugin in a sandboxed IDE
---
## Open in IntelliJ
When you open the monorepo root in IntelliJ IDEA, the Gradle project at `packages/kilo-jetbrains/` should be auto-detected via `.idea/gradle.xml`. If not, link it manually: **File > Settings > Build Tools > Gradle > +** and select `packages/kilo-jetbrains/settings.gradle.kts`.
---
## Build locally
From `packages/kilo-jetbrains/`:
```
bun run build
```
This builds the CLI binary for your current OS/arch only, copies it into the backend module resources, and runs `./gradlew buildPlugin`. The plugin archive is output to `build/distributions/`.
Or via Turbo from the repo root:
```
bun turbo build --filter=@kilocode/kilo-jetbrains
```
---
## Build for production
From `packages/kilo-jetbrains/`:
```
bun run build:production
```
This builds CLI binaries for all 6 desktop platforms (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64, windows-arm64), copies them all into the backend jar, and fails if any are missing. Gradle also validates all platforms are present via `-Pproduction=true`.
The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip`. This zip can be installed in any JetBrains IDE via **Settings > Plugins > Install Plugin from Disk**.
---
## Run the plugin
After building, use the `runIde` Gradle task (available in the Gradle tool window or via the "Run JetBrains Plugin" run configuration) to launch a sandboxed IntelliJ instance with the plugin installed.
Note: `runIde` does not build CLI binaries -- run `bun run build` at least once before using it. Subsequent Kotlin/Gradle changes can be iterated with `runIde` directly.
---
## Run Gradle directly
You can run `./gradlew buildPlugin` directly if CLI binaries are already in `backend/build/generated/cli/`. Gradle will fail with a clear error if they are missing.
For production verification:
```
./gradlew buildPlugin -Pproduction=true
```
@@ -0,0 +1,68 @@
plugins {
alias(libs.plugins.kotlin)
}
kotlin {
jvmToolchain(21)
}
sourceSets {
main {
resources.srcDir(layout.buildDirectory.dir("generated/cli"))
}
}
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false)
val requiredPlatforms = listOf(
"darwin-arm64",
"darwin-x64",
"linux-arm64",
"linux-x64",
"windows-x64",
"windows-arm64",
)
val checkCli by tasks.registering {
description = "Verify CLI binaries exist before building"
val dir = cliDir.map { it.asFile }
val prod = production.get()
val platforms = requiredPlatforms.toList()
doLast {
val resolved = dir.get()
if (!resolved.exists() || resolved.listFiles()?.isEmpty() != false) {
throw GradleException(
"CLI binaries not found at ${resolved.absolutePath}.\n" +
"Run 'bun run build' from packages/kilo-jetbrains/ to build CLI and plugin together."
)
}
if (prod) {
val missing = platforms.filter { platform ->
val dir = File(resolved, platform)
val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo"
!File(dir, exe).exists()
}
if (missing.isNotEmpty()) {
throw GradleException(
"Production build requires all platform CLI binaries.\n" +
"Missing: ${missing.joinToString(", ")}\n" +
"Run 'bun run build:production' to build all platforms."
)
}
}
}
}
tasks.processResources {
dependsOn(checkCli)
}
dependencies {
intellijPlatform {
intellijIdea(libs.versions.intellij.platform)
bundledModule("intellij.platform.backend")
}
implementation(project(":shared"))
}
@@ -0,0 +1,6 @@
<idea-plugin>
<dependencies>
<module name="intellij.platform.backend"/>
<module name="kilo.jetbrains.shared"/>
</dependencies>
</idea-plugin>
+60
View File
@@ -0,0 +1,60 @@
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.SplitModeTarget
group = "ai.kilocode.jetbrains"
version = "7.0.1"
plugins {
application
id("java")
alias(libs.plugins.intellij.platform)
alias(libs.plugins.kotlin) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.compose.compiler) apply false
}
subprojects {
apply(plugin = "org.jetbrains.intellij.platform.module")
}
allprojects {
repositories {
mavenCentral()
intellijPlatform {
defaultRepositories()
}
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies/")
}
}
dependencies {
intellijPlatform {
intellijIdea(libs.versions.intellij.platform)
pluginModule(implementation(project(":shared")))
pluginModule(implementation(project(":frontend")))
pluginModule(implementation(project(":backend")))
testFramework(TestFrameworkType.Platform)
}
}
intellijPlatform {
splitMode = true
splitModeTarget = SplitModeTarget.BOTH
pluginVerification {
ides {
create(IntelliJPlatformType.IntellijIdeaCommunity, libs.versions.intellij.platform)
}
}
}
tasks.named<JavaExec>("runIde") {
jvmArgumentProviders += CommandLineArgumentProvider {
listOf("-Dnosplash=true")
}
}
@@ -0,0 +1,17 @@
plugins {
alias(libs.plugins.kotlin)
alias(libs.plugins.compose.compiler)
}
kotlin {
jvmToolchain(21)
}
dependencies {
intellijPlatform {
intellijIdea(libs.versions.intellij.platform)
bundledModule("intellij.platform.frontend")
}
implementation(project(":shared"))
}
@@ -0,0 +1,6 @@
<idea-plugin>
<dependencies>
<module name="intellij.platform.frontend"/>
<module name="kilo.jetbrains.shared"/>
</dependencies>
</idea-plugin>
@@ -0,0 +1,4 @@
kotlin.stdlib.default.dependency=false
org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m
@@ -0,0 +1,12 @@
[versions]
intellij-platform = "2025.3"
intellij-gradle-plugin = "2.10.5"
kotlin-jvm-plugin = "2.1.20"
kotlin-serialization-plugin = "2.1.20"
kotlin-serialization = "1.7.3"
[plugins]
intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" }
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-jvm-plugin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization-plugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-jvm-plugin" }
Binary file not shown.
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+234
View File
@@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+8
View File
@@ -0,0 +1,8 @@
{
"name": "@kilocode/kilo-jetbrains",
"private": true,
"scripts": {
"build": "bun script/build.ts",
"build:production": "bun script/build.ts --production"
}
}
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bun
/**
* Build the Kilo JetBrains plugin.
*
* Usage:
* bun script/build.ts # Local build — only current platform binary required
* bun script/build.ts --production # Production build — all 6 platform binaries required
*
* Steps:
* 1. Builds CLI binaries (or uses prebuilt ones from dist/).
* Local: builds only current platform (--single).
* Production: builds all platforms.
* 2. Copies them into backend/build/generated/cli/cli/{os}/kilo[.exe]
* so they end up inside the backend jar at /cli/{os}/kilo.
* 3. Invokes Gradle to build the plugin.
*/
import { $ } from "bun"
import { join, relative } from "node:path"
import { existsSync, mkdirSync, chmodSync, cpSync, rmSync } from "node:fs"
const production = process.argv.includes("--production")
const root = join(import.meta.dir, "..")
const packages = join(root, "..")
const opencodeDir = join(packages, "opencode")
const distDir = join(opencodeDir, "dist")
const cliDir = join(root, "backend", "build", "generated", "cli", "cli")
/** All desktop platforms. */
const platforms = [
{ os: "darwin-arm64", exe: "kilo" },
{ os: "darwin-x64", exe: "kilo" },
{ os: "linux-arm64", exe: "kilo" },
{ os: "linux-x64", exe: "kilo" },
{ os: "windows-x64", exe: "kilo.exe" },
{ os: "windows-arm64", exe: "kilo.exe" },
] as const
function localPlatformTag(): string {
const os = process.platform === "win32" ? "windows" : process.platform
return `${os}-${process.arch}`
}
function log(msg: string) {
console.log(`[jetbrains-build] ${msg}`)
}
function distBinPath(os: string, exe: string): string {
return join(distDir, `@kilocode/cli-${os}`, "bin", exe)
}
function hasDist(): boolean {
if (production) {
return platforms.every((p) => existsSync(distBinPath(p.os, p.exe)))
}
const tag = localPlatformTag()
const local = platforms.find((p) => p.os === tag)
return local ? existsSync(distBinPath(local.os, local.exe)) : false
}
async function prepareCli() {
const mode = production ? "production" : "local"
log(`Mode: ${mode}`)
if (!hasDist()) {
log("Building CLI binaries via opencode...")
if (!existsSync(join(opencodeDir, "package.json"))) {
throw new Error(`Expected opencode package at ${opencodeDir}`)
}
const args = production ? [] : ["--single"]
await $`bun run build ${args}`.cwd(opencodeDir)
} else {
log("Found prebuilt CLI binaries in opencode/dist/, skipping CLI build")
}
if (existsSync(cliDir)) {
rmSync(cliDir, { recursive: true })
}
const missing: string[] = []
let copied = 0
for (const p of platforms) {
const src = distBinPath(p.os, p.exe)
if (!existsSync(src)) {
missing.push(p.os)
continue
}
const dir = join(cliDir, p.os)
mkdirSync(dir, { recursive: true })
const dest = join(dir, p.exe)
cpSync(src, dest)
chmodSync(dest, 0o755)
copied++
log(`Copied ${relative(root, src)} -> ${relative(root, dest)}`)
}
if (copied === 0) {
throw new Error("No CLI binaries were copied — the build cannot proceed")
}
if (production && missing.length > 0) {
throw new Error(`Production build requires all platform binaries. Missing: ${missing.join(", ")}`)
}
if (missing.length > 0) {
log(`Skipped ${missing.length} platforms (not needed for local build): ${missing.join(", ")}`)
}
log(`Copied ${copied}/${platforms.length} platform binaries`)
}
const gradlew = process.platform === "win32" ? "gradlew.bat" : "./gradlew"
async function buildPlugin() {
log("Building JetBrains plugin via Gradle...")
const args = production ? ["-Pproduction=true"] : []
await $`${gradlew} buildPlugin ${args}`.cwd(root)
log("Done. Plugin archive is in build/distributions/")
}
try {
await prepareCli()
await buildPlugin()
} catch (err) {
console.error(`[jetbrains-build] ERROR: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
}
@@ -0,0 +1,13 @@
rootProject.name = "kilo.jetbrains"
include("shared")
include("frontend")
include("backend")
pluginManagement {
repositories {
mavenCentral()
gradlePluginPortal()
maven("https://packages.jetbrains.team/maven/p/ij/intellij-dependencies/")
}
}
@@ -0,0 +1,13 @@
plugins {
alias(libs.plugins.kotlin)
}
kotlin {
jvmToolchain(21)
}
dependencies {
intellijPlatform {
intellijIdea(libs.versions.intellij.platform)
}
}
@@ -0,0 +1,2 @@
<idea-plugin>
</idea-plugin>
@@ -0,0 +1,17 @@
<idea-plugin>
<name>Kilo Code</name>
<vendor url="https://kilo.ai">Kilo Code</vendor>
<description><![CDATA[
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.
]]></description>
<content>
<module name="kilo.jetbrains.shared" loading="required"/>
<module name="kilo.jetbrains.frontend"/>
<module name="kilo.jetbrains.backend"/>
</content>
</idea-plugin>
@@ -0,0 +1,4 @@
<svg width="40" height="40" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M512 0H0V512H512V0Z" fill="black"/>
<path d="M512 512H0V0H512V512ZM322.783 322.784H278.261V392.747L308.472 422.958H378.435V378.437H322.782L322.783 322.784ZM422.957 308.474L392.746 278.263H322.783V322.784H378.435L378.435 378.437H422.957L422.957 308.474ZM233.739 278.263H189.217V322.784H233.739V278.263ZM89.0435 392.747L119.254 422.958H233.739V378.437H133.565V278.263H89.043L89.0435 392.747ZM372.538 189.217V119.254L342.327 89.0435H278.261V133.565H328.017V189.217H278.261V233.739H422.957V189.217H372.538ZM133.565 89.0435H89.0435V233.739H133.565V183.652H189.218V233.739H233.74V183.652L189.218 139.13H133.565V89.0435ZM233.739 89.0435H189.217L189.218 139.13H233.739V89.0435Z" fill="#FAF74F"/>
</svg>

After

Width:  |  Height:  |  Size: 810 B

+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.1.19",
"version": "7.1.22",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.1.19",
"version": "7.1.22",
"type": "module",
"license": "MIT",
"exports": {
@@ -102,7 +102,7 @@
"storybook": "10.2.10",
"storybook-solidjs-vite": "10.0.9",
"typescript": "catalog:",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-plugin-solid": "2.11.10"
},
"scripts": {
+19 -7
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
"version": "7.1.19",
"version": "7.1.22",
"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",
@@ -704,6 +706,11 @@
"default": false,
"description": "Enable chat textarea autocomplete"
},
"kilo-code.new.claudeCodeCompat": {
"type": "boolean",
"default": false,
"description": "Load CLAUDE.md instructions and skills from your Claude Code configuration directory into Kilo sessions. Enable this if you want Kilo to use your Claude Code instructions and skills."
},
"kilo-code.new.browserAutomation.enabled": {
"type": "boolean",
"default": false,
@@ -760,6 +767,11 @@
"none"
],
"description": "Sound to play on errors"
},
"kilo-code.new.showTaskTimeline": {
"type": "boolean",
"default": true,
"description": "Show the task timeline graph in the chat header"
}
}
}
@@ -818,7 +830,7 @@
"ts-morph": "27.0.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
"vite": "7.3.1",
"vite": "7.3.2",
"vite-plugin-solid": "2.11.10"
},
"dependencies": {
@@ -828,7 +840,7 @@
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "^7.0.0",
"diff": "8.0.4",
"dotenv": "^16.4.7",
"fastest-levenshtein": "^1.0.16",
"friendly-words": "1.3.1",
@@ -837,11 +849,11 @@
"lru-cache": "^11.0.2",
"openai": "^4.85.4",
"quick-lru": "^7.0.0",
"simple-git": "3.31.1",
"simple-git": "3.35.2",
"solid-js": "^1.9.11",
"uri-js": "^4.4.1",
"web-tree-sitter": "^0.24.7",
"yaml": "2.8.2",
"yaml": "2.8.3",
"zod": "^3.24.2"
}
}
+6 -3
View File
@@ -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",
})
+9 -5
View File
@@ -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`)
}
+66 -2
View File
@@ -45,6 +45,7 @@ import {
checkAndShowMigrationWizard,
handleRequestLegacyMigrationData,
handleStartLegacyMigration,
handleFinalizeLegacyMigration,
handleSkipLegacyMigration,
handleClearLegacyData,
type MigrationContext,
@@ -68,7 +69,11 @@ import {
fetchAndSendPendingPermissions,
type PermissionContext,
} from "./kilo-provider/handlers/permission-handler"
import { handleQuestionReply, handleQuestionReject } from "./kilo-provider/handlers/question"
import {
handleQuestionReply,
handleQuestionReject,
fetchAndSendPendingQuestions,
} from "./kilo-provider/handlers/question"
import {
buildActionContext,
@@ -331,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)
})
@@ -786,9 +793,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "requestBrowserSettings":
this.sendBrowserSettings()
break
case "requestClaudeCompatSetting":
this.sendClaudeCompatSetting()
break
case "requestNotificationSettings":
this.sendNotificationSettings()
break
case "requestTimelineSetting":
this.sendTimelineSetting()
break
case "requestNotifications":
this.fetchAndSendNotifications().catch((e) =>
console.error("[Kilo New] fetchAndSendNotifications failed:", e),
@@ -891,6 +904,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "clearLegacyData":
void handleClearLegacyData(this.migrationCtx)
break
case "finalizeLegacyMigration":
void handleFinalizeLegacyMigration(this.migrationCtx)
break
// legacy-migration end
case "enhancePrompt": {
const sdkClient = this.client
@@ -1042,6 +1058,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
await this.syncWebviewState("sse-connected")
await this.flushPendingSessionRefresh("sse-connected")
await fetchAndSendPendingPermissions(this.permissionCtx)
await fetchAndSendPendingQuestions(this.questionCtx)
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error)
this.postMessage({
@@ -1120,6 +1137,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.seedSessionStatusMap(),
])
this.sendNotificationSettings()
this.sendTimelineSetting()
// Start polling worktree diff stats for the sidebar badge
this.startStatsPolling()
@@ -1326,6 +1344,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
sessionID,
messages,
})
// Recover any missed permission/question prompts emitted by the child before
// we started tracking it. Both run fire-and-forget after messagesLoaded so
// the webview isn't blocked.
void fetchAndSendPendingPermissions(this.permissionCtx)
void fetchAndSendPendingQuestions(this.questionCtx)
} catch (err) {
this.syncedChildSessions.delete(sessionID)
console.error("[Kilo New] KiloProvider: Failed to sync child session:", err)
@@ -2059,6 +2083,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
private sendTimelineSetting(): void {
const config = vscode.workspace.getConfiguration("kilo-code.new")
this.postMessage({
type: "timelineSettingLoaded",
visible: config.get<boolean>("showTaskTimeline", true),
})
}
/** Returns the number of sessions currently in "busy" state. */
private getBusySessionCount(): number {
return getBusySessionCount(this.sessionStatusMap)
@@ -2390,6 +2422,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
return {
client: this.client,
currentSessionId: this.currentSession?.id,
trackedSessionIds: this.trackedSessionIds,
sessionDirectories: this.sessionDirectories,
postMessage: (msg: unknown) => this.postMessage(msg),
getWorkspaceDirectory: (sid?: string) => this.getWorkspaceDirectory(sid),
}
@@ -2561,6 +2595,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.sendAutocompleteSettings()
this.sendBrowserSettings()
this.sendNotificationSettings()
this.sendTimelineSetting()
// Re-send globalState items to the webview
this.postMessage({ type: "variantsLoaded", variants: {} })
@@ -2587,6 +2622,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
}
/**
* Read the current Claude Code compatibility setting and push it to the webview.
*/
private sendClaudeCompatSetting(): void {
const enabled = vscode.workspace.getConfiguration("kilo-code.new").get<boolean>("claudeCodeCompat", false)
this.postMessage({
type: "claudeCompatSettingLoaded",
enabled: enabled ?? false,
})
}
/** Re-fetch all server-side state after an auth change. */
private async reloadAfterAuthChange(): Promise<void> {
await Promise.all([
@@ -2672,6 +2718,24 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.contextSessionID = event.properties.info.id
}
// Auto-adopt child sessions as soon as the task tool part reveals their ID.
// This means the child's permission/question events are tracked immediately —
// before the webview renderer has a chance to call syncSession — eliminating
// the race where the child blocks on a prompt that the UI never sees.
if (event.type === "message.part.updated") {
const part = event.properties.part as {
type?: string
tool?: string
metadata?: { sessionId?: string }
sessionID?: string
}
const childId = part.type === "tool" && part.tool === "task" ? part.metadata?.sessionId : undefined
if (childId && !this.trackedSessionIds.has(childId)) {
console.log("[Kilo New] KiloProvider: 🔗 Auto-adopting child session from task tool", { childId })
void this.handleSyncSession(childId, part.sessionID ?? sessionID)
}
}
const msg = mapSSEEventToWebviewMessage(event, sessionID)
if (msg) {
if (msg.type === "partUpdated") {
@@ -7,7 +7,6 @@ import { parseWorktreeList, normalizePath } from "./git-import"
interface GitOpsOptions {
log: (...args: unknown[]) => void
refreshMs?: number
/** Override git command execution for testing. */
runGit?: (args: string[], cwd: string) => Promise<string>
}
@@ -40,15 +39,31 @@ interface ExecResult {
stderr: string
}
/**
* Build environment variables that prevent git and SSH from opening interactive
* prompts. Used for background operations (e.g. periodic fetch) so users with
* SSH keys that require passphrase confirmation are not bombarded with dialogs.
*
* Returns a full `process.env` overlay suitable for `simple-git.env()` or
* `child_process.spawn`. `GIT_SSH_COMMAND` is only overridden when the user
* hasn't already configured their own.
*/
export function nonInteractiveEnv(): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
GIT_TERMINAL_PROMPT: "0",
}
if (!process.env.GIT_SSH_COMMAND) {
env.GIT_SSH_COMMAND = "ssh -o BatchMode=yes"
}
return env
}
export class GitOps {
private lastFetch = new Map<string, number>()
private inflightFetch = new Map<string, Promise<void>>()
private readonly refreshMs: number
private readonly log: (...args: unknown[]) => void
private readonly runGit: (args: string[], cwd: string) => Promise<string>
constructor(options: GitOpsOptions) {
this.refreshMs = options.refreshMs ?? 120000
this.log = options.log
this.runGit =
options.runGit ??
@@ -114,33 +129,6 @@ export class GitOps {
.catch(() => false)
}
async refreshRemote(cwd: string, remote: string): Promise<void> {
if (!remote) return
const commonRaw = await this.raw(["rev-parse", "--git-common-dir"], cwd).catch(() => cwd)
const common = nodePath.isAbsolute(commonRaw) ? commonRaw : nodePath.resolve(cwd, commonRaw)
const key = `${common}:${remote}`
const existing = this.inflightFetch.get(key)
if (existing) return existing
const prev = this.lastFetch.get(key) ?? 0
const now = Date.now()
if (now - prev < this.refreshMs) return
this.lastFetch.set(key, now)
const job = this.raw(["fetch", "--quiet", "--no-tags", remote], cwd)
.catch((err) => {
this.log(`Failed to refresh remote refs for ${cwd}:`, err)
})
.then(() => undefined)
.finally(() => {
this.inflightFetch.delete(key)
})
this.inflightFetch.set(key, job)
return job
}
/** Return the set of worktree paths for the repo, excluding bare entries. */
async listWorktreePaths(cwd: string): Promise<Set<string>> {
const raw = await this.raw(["worktree", "list", "--porcelain"], cwd)
@@ -211,12 +199,11 @@ export class GitOps {
/**
* Count commits ahead and behind using `rev-list --left-right --count`.
* Callers are expected to pass a fully-qualified ref (e.g. "origin/main").
* Pass `remote` explicitly to refresh the tracking ref before counting;
* the remote is NOT inferred from the ref to avoid misinterpreting
* branch names that contain slashes (e.g. "release/1.0").
* Counts are computed against local tracking refs only — no fetch is
* performed, so values may be stale until an explicit git operation
* (push, pull, etc.) updates the refs.
*/
async aheadBehind(cwd: string, base: string, remote?: string): Promise<{ ahead: number; behind: number }> {
if (remote) await this.refreshRemote(cwd, remote)
async aheadBehind(cwd: string, base: string): Promise<{ ahead: number; behind: number }> {
return this.parseLeftRight(cwd, base)
}
@@ -159,7 +159,7 @@ export class GitStatsPoller {
const base = remoteRef(wt)
const [{ data: diffs }, ab] = await Promise.all([
client.worktree.diffSummary({ directory: wt.path, base }, { throwOnError: true }),
this.git.aheadBehind(wt.path, base, wt.remote),
this.git.aheadBehind(wt.path, base),
])
const files = diffs.length
const additions = diffs.reduce((sum: number, diff: FileDiff) => sum + diff.additions, 0)
@@ -248,7 +248,6 @@ export class GitStatsPoller {
const tracking = await this.git.resolveTrackingBranch(root, branch)
const base = tracking ?? (await this.git.resolveDefaultBranch(root, branch))
const remote = await this.git.resolveRemote(root, branch).catch(() => undefined)
let files: number
let additions: number
@@ -260,7 +259,7 @@ export class GitStatsPoller {
this.options.log(`Local stats: using HTTP client with base=${base}`)
const [{ data: diffs }, ab] = await Promise.all([
client.worktree.diffSummary({ directory: root, base }, { throwOnError: true }),
this.git.aheadBehind(root, base, remote),
this.git.aheadBehind(root, base),
])
files = diffs.length
additions = diffs.reduce((sum: number, d: FileDiff) => sum + d.additions, 0)
@@ -11,7 +11,7 @@ import * as fs from "fs"
import { randomUUID } from "crypto"
import simpleGit, { type SimpleGit } from "simple-git"
import { generateBranchName, sanitizeBranchName } from "./branch-name"
import type { GitOps } from "./GitOps"
import { type GitOps, nonInteractiveEnv } from "./GitOps"
import { execWithShellEnv } from "./shell-env"
import {
parsePRUrl,
@@ -662,10 +662,11 @@ export class WorktreeManager {
}
}
// Either not cached or cache is stale - do the fetch
// Either not cached or cache is stale - do the fetch.
// Use non-interactive env to prevent SSH passphrase popups.
onProgress?.("fetching", `Fetching ${remote}/${branch}...`)
try {
await this.git.fetch(remote, branch)
await simpleGit(this.root).env(nonInteractiveEnv()).fetch(remote, branch)
WorktreeManager.fetchCache.set(cacheKey, Date.now())
if (await this.refExistsLocally(`${remote}/${branch}`)) {
return {
+6 -2
View File
@@ -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", () => {
@@ -6,7 +6,11 @@
*/
import type { KiloClient } from "@kilocode/sdk/v2/client"
import type { LegacyMigrationData, MigrationSelections } from "../../legacy-migration/legacy-types"
import type {
LegacyMigrationData,
MigrationSelections,
MigrationSessionProgress,
} from "../../legacy-migration/legacy-types"
import * as MigrationService from "../../legacy-migration/migration-service"
/** Subset of vscode.ExtensionContext needed by migration handlers. */
@@ -31,10 +35,22 @@ export interface MigrationContext {
refreshSessions(): void
cachedLegacyData: LegacyMigrationData | null
migrationCheckInFlight: boolean
lastMigrationHadErrors?: boolean
disposeGlobal(): Promise<void>
broadcastComplete(): void
}
function postSessionProgress(ctx: MigrationContext, progress: MigrationSessionProgress): void {
ctx.postMessage({
type: "legacyMigrationSessionProgress",
session: progress.session,
index: progress.index,
total: progress.total,
phase: progress.phase,
error: progress.error,
})
}
/**
* Check for legacy data on first run and send migration state to the webview
* if the user has not yet been prompted.
@@ -111,26 +127,17 @@ export async function handleStartLegacyMigration(
(item, status, message) => {
ctx.postMessage({ type: "legacyMigrationProgress", item, status, message })
},
(progress: MigrationSessionProgress) => {
postSessionProgress(ctx, progress)
},
ctx.cachedLegacyData?.settings,
ctx.cachedLegacyData?.sessions,
)
const failed = results.some((r) => r.status === "error")
const success = results.some((r) => r.status === "success")
if (!failed && success) {
// Dispose all instances after a fully successful migration.
// Reloading the data will be handled once the server replies with a global.disposed event.
await ctx.disposeGlobal()
await MigrationService.setMigrationStatus(
ctx.extensionContext as Parameters<typeof MigrationService.setMigrationStatus>[0],
"completed",
)
ctx.broadcastComplete()
ctx.refreshSessions()
}
ctx.lastMigrationHadErrors = results.some((item) => item.status === "error")
ctx.postMessage({ type: "legacyMigrationComplete", results })
} catch (error) {
ctx.lastMigrationHadErrors = true
console.error("[Kilo New] KiloProvider: ❌ Migration failed", error)
ctx.postMessage({
type: "legacyMigrationComplete",
@@ -146,6 +153,17 @@ export async function handleStartLegacyMigration(
}
}
export async function handleFinalizeLegacyMigration(ctx: MigrationContext): Promise<void> {
if (!ctx.extensionContext) return
await ctx.disposeGlobal()
await MigrationService.setMigrationStatus(
ctx.extensionContext as Parameters<typeof MigrationService.setMigrationStatus>[0],
ctx.lastMigrationHadErrors ? "completed_with_errors" : "completed",
)
ctx.broadcastComplete()
ctx.refreshSessions()
}
/** Record that the user skipped migration and broadcast to all instances. */
export async function handleSkipLegacyMigration(ctx: MigrationContext): Promise<void> {
if (!ctx.extensionContext) return
@@ -1,7 +1,8 @@
/**
* Question handlers — extracted from KiloProvider.
*
* Manages question reply and reject flows from the tool question UI.
* Manages question reply and reject flows from the tool question UI,
* plus recovery of pending questions after SSE reconnections or child-session syncs.
* No vscode dependency.
*/
@@ -10,10 +11,46 @@ import type { KiloClient } from "@kilocode/sdk/v2/client"
interface QuestionContext {
readonly client: KiloClient | null
readonly currentSessionId: string | undefined
readonly trackedSessionIds: Set<string>
readonly sessionDirectories: ReadonlyMap<string, string>
postMessage(msg: unknown): void
getWorkspaceDirectory(sessionId?: string): string
}
/**
* Fetch all pending questions from the backend and forward any that belong
* to tracked sessions to the webview. Mirrors fetchAndSendPendingPermissions —
* called after child-session sync and after SSE reconnects so missed
* question.asked events don't leave the server blocked indefinitely.
*/
export async function fetchAndSendPendingQuestions(ctx: QuestionContext): Promise<void> {
if (!ctx.client) return
try {
const dirs = new Set<string>([ctx.getWorkspaceDirectory(), ...ctx.sessionDirectories.values()])
const seen = new Set<string>()
for (const dir of dirs) {
const { data } = await ctx.client.question.list({ directory: dir })
if (!data) continue
for (const q of data) {
if (seen.has(q.id)) continue
seen.add(q.id)
if (!ctx.trackedSessionIds.has(q.sessionID)) continue
ctx.postMessage({
type: "questionRequest",
question: {
id: q.id,
sessionID: q.sessionID,
questions: q.questions,
tool: q.tool,
},
})
}
}
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch pending questions:", error)
}
}
/** Handle question reply from the webview. */
export async function handleQuestionReply(
ctx: QuestionContext,
@@ -299,11 +299,18 @@ export interface MigrationCustomModeInfo {
nativeSlug?: string
}
export interface MigrationSessionInfo {
id: string
title: string
directory: string
time: number
}
export interface LegacyMigrationData {
providers: MigrationProviderInfo[]
mcpServers: MigrationMcpServerInfo[]
customModes: MigrationCustomModeInfo[]
sessions?: string[]
sessions?: MigrationSessionInfo[]
defaultModel?: { provider: string; model: string }
settings?: LegacySettings
hasData: boolean
@@ -330,11 +337,26 @@ export interface MigrationSettingsSelections {
autocomplete: boolean
}
export interface MigrationSessionSelection {
id: string
force?: boolean
}
export interface MigrationSelections {
providers: string[]
mcpServers: string[]
customModes: string[]
sessions?: string[]
sessions?: MigrationSessionSelection[]
defaultModel: boolean
settings: MigrationSettingsSelections
}
export type MigrationSessionPhase = "preparing" | "storing" | "skipped" | "done" | "summary" | "error"
export interface MigrationSessionProgress {
session: MigrationSessionInfo
index: number
total: number
phase: MigrationSessionPhase
error?: string
}
@@ -33,7 +33,10 @@ import type {
MigrationProviderInfo,
MigrationMcpServerInfo,
MigrationCustomModeInfo,
MigrationSessionInfo,
MigrationSessionProgress,
} from "./legacy-types"
import { buildSessionMeta, buildSessionProgress } from "./migration-session-progress"
import type { MigrationResultItem } from "./migration-types"
import { createSessionID } from "./sessions/lib/ids"
import { migrate as migrateSession } from "./sessions/migrate"
@@ -46,7 +49,7 @@ const SECRET_KEY = "roo_cline_config_api_config"
const CODEX_OAUTH_SECRET_KEY = "openai-codex-oauth-credentials"
const MIGRATION_STATUS_KEY = "kilo.legacyMigrationStatus"
type MigrationStatus = "completed" | "skipped"
type MigrationStatus = "completed" | "completed_with_errors" | "skipped"
// ---------------------------------------------------------------------------
// Status helpers
@@ -114,18 +117,27 @@ export async function detectLegacyData(context: vscode.ExtensionContext): Promis
}
async function readSessionsInGlobalStorage(context: vscode.ExtensionContext) {
const items = context.globalState.get<{ id: string }[]>("taskHistory", [])
const items = context.globalState.get<{ id: string; task?: string; workspace?: string; ts?: number }[]>(
"taskHistory",
[],
)
const base = vscode.Uri.joinPath(context.globalStorageUri, "tasks")
const ids: string[] = []
const sessions: MigrationSessionInfo[] = []
for (const item of items) {
const file = vscode.Uri.joinPath(base, item.id, "api_conversation_history.json")
const exists = await vscode.workspace.fs.stat(file).then(
() => true,
() => false,
)
if (exists) ids.push(item.id)
if (!exists) continue
sessions.push({
id: item.id,
title: item.task?.trim() || item.id,
directory: item.workspace?.trim() || "",
time: item.ts ?? 0,
})
}
return ids
return sessions
}
// ---------------------------------------------------------------------------
@@ -138,6 +150,11 @@ export type ProgressCallback = (
message?: string,
) => void
export type SessionProgressCallback = (progress: MigrationSessionProgress) => void
const SESSION_DELAY = 300
const SESSION_SUMMARY_DELAY = 1000
/**
* Executes migration for the selected items.
* Calls onProgress for each item with real-time status updates.
@@ -150,13 +167,16 @@ export async function migrate(
client: KiloClient,
selections: MigrationSelections,
onProgress: ProgressCallback,
onSessionProgress?: SessionProgressCallback,
cachedSettings?: LegacySettings,
cachedSessions?: MigrationSessionInfo[],
): Promise<MigrationResultItem[]> {
const profiles = await readLegacyProviderProfiles(context)
const mcpSettings = await readLegacyMcpSettings(context)
const customModes = await readLegacyCustomModes(context)
const prompts = readLegacyCustomModePrompts(context)
const legacySettings = cachedSettings ?? readLegacySettings(context)
const sessions = cachedSessions ?? (await readSessionsInGlobalStorage(context))
const results: MigrationResultItem[] = []
@@ -257,17 +277,35 @@ export async function migrate(
}
if (selections.sessions?.length) {
for (const id of selections.sessions) {
onProgress(id, "migrating")
const result = await migrateSession(id, context, client)
const list = selections.sessions
for (const [index, item] of list.entries()) {
onProgress(item.id, "migrating")
const session = sessions.find((entry: MigrationSessionInfo) => entry.id === item.id)
const meta = buildSessionMeta(session, index, list.length)
const progress = buildSessionProgress(meta, onSessionProgress)
const result = await migrateSession(item, context, client, meta, progress)
const reason = result.ok ? "Session migrated" : result.message
results.push({
item: id,
item: item.id,
category: "session",
status: result.ok ? "success" : "error",
message: reason,
})
onProgress(id, result.ok ? "success" : "error", reason)
onProgress(item.id, result.ok ? "success" : "error", reason)
if (index < list.length - 1) {
await new Promise((resolve) => setTimeout(resolve, SESSION_DELAY))
}
}
const last = list.at(-1)
const session = last ? sessions.find((item: MigrationSessionInfo) => item.id === last.id) : undefined
if (session && onSessionProgress) {
onSessionProgress({
session,
index: list.length,
total: list.length,
phase: "summary",
})
await new Promise((resolve) => setTimeout(resolve, SESSION_SUMMARY_DELAY))
}
}
@@ -0,0 +1,37 @@
import type { MigrationSessionInfo, MigrationSessionProgress } from "./legacy-types"
import type { SessionProgressCallback } from "./migration-service"
export interface MigrationSessionMeta {
session: MigrationSessionInfo
index: number
total: number
}
export function buildSessionMeta(
session: MigrationSessionInfo | undefined,
index: number,
total: number,
): MigrationSessionMeta | undefined {
if (!session) return undefined
return {
session,
index: index + 1,
total,
}
}
export function buildSessionProgress(
meta: MigrationSessionMeta | undefined,
onProgress: SessionProgressCallback | undefined,
) {
if (!meta || !onProgress) return undefined
return (progress: Omit<MigrationSessionProgress, "session" | "index" | "total">) => {
onProgress({
session: meta.session,
index: meta.index,
total: meta.total,
phase: progress.phase,
error: progress.error,
})
}
}
@@ -1,6 +1,8 @@
import * as vscode from "vscode"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { getMigrationErrorMessage } from "../errors/migration-error"
import type { MigrationSessionInfo, MigrationSessionProgress, MigrationSessionSelection } from "../legacy-types"
import { createSessionID } from "./lib/ids"
import type { LegacyHistoryItem } from "./lib/legacy-types"
import { parseSession } from "./parser"
@@ -8,21 +10,71 @@ type Result =
| {
ok: true
skipped?: boolean
payload: Awaited<ReturnType<typeof parseSession>>
payload?: Awaited<ReturnType<typeof parseSession>>
}
| {
ok: false
payload: Awaited<ReturnType<typeof parseSession>>
payload?: Awaited<ReturnType<typeof parseSession>>
message: string
}
export async function migrate(id: string, context: vscode.ExtensionContext, client: KiloClient): Promise<Result> {
type Progress = Omit<MigrationSessionProgress, "session" | "index" | "total">
type ProgressCallback = (progress: Progress) => void
type Payload = Awaited<ReturnType<typeof parseSession>>
function trimError(input: string) {
return input.length <= 100 ? input : `${input.slice(0, 100)}...`
}
export async function migrate(
input: MigrationSessionSelection,
context: vscode.ExtensionContext,
client: KiloClient,
meta?: {
session: MigrationSessionInfo
index: number
total: number
},
onProgress?: ProgressCallback,
): Promise<Result> {
const dir = vscode.Uri.joinPath(context.globalStorageUri, "tasks").fsPath
const items = context.globalState.get<LegacyHistoryItem[]>("taskHistory", [])
const item = items.find((item) => item.id === id)
const payload = await parseSession(id, dir, item)
const item = items.find((item) => item.id === input.id)
const progress = (next: Progress) => {
if (!meta || !onProgress) return
onProgress(next)
}
const skip = () => {
progress({ phase: "skipped" })
return {
ok: true as const,
skipped: true,
}
}
const fail = (error: unknown, payload?: Payload) => {
progress({
phase: "error",
error: trimError(getMigrationErrorMessage(error)),
})
return {
ok: false as const,
...(payload ? { payload } : {}),
message: getMigrationErrorMessage(error),
}
}
try {
if (!input.force) {
const result = await client.session.get({ sessionID: createSessionID(input.id) })
if (result.data) return skip()
}
progress({ phase: "preparing" })
const payload = await parseSession(input.id, dir, item)
progress({ phase: "storing" })
const project = await client.kilocode.sessionImport.project(payload.project, { throwOnError: true })
const projectID = project.data?.id ?? payload.project.id
const session = await client.kilocode.sessionImport.session(
@@ -31,11 +83,13 @@ export async function migrate(id: string, context: vscode.ExtensionContext, clie
projectID,
query_directory: payload.session.directory,
body_directory: payload.session.directory,
...(input.force ? { force: true } : {}),
},
{ throwOnError: true },
)
// Skip child imports when the session already exists so rerunning migration only imports missing sessions.
if (session.data?.skipped) {
progress({ phase: "skipped" })
return {
ok: true,
skipped: true,
@@ -51,15 +105,13 @@ export async function migrate(id: string, context: vscode.ExtensionContext, clie
await client.kilocode.sessionImport.part(part, { throwOnError: true })
}
progress({ phase: "done" })
return {
ok: true,
payload,
}
} catch (error) {
return {
ok: false,
payload,
message: getMigrationErrorMessage(error),
}
return fail(error)
}
}
@@ -66,6 +66,7 @@ export class ServerManager {
return new Promise((resolve, reject) => {
console.log("[Kilo New] ServerManager: 🎬 Spawning CLI process:", cliPath, ["serve", "--port", "0"])
const claudeCompat = vscode.workspace.getConfiguration("kilo-code.new").get<boolean>("claudeCodeCompat", false)
const serverProcess = spawn(cliPath, ["serve", "--port", "0"], {
env: {
...process.env,
@@ -80,6 +81,7 @@ export class ServerManager {
KILO_MACHINE_ID: vscode.env.machineId,
KILO_APP_VERSION: this.context.extension.packageJSON.version,
KILO_VSCODE_VERSION: vscode.version,
...(!claudeCompat && { KILO_DISABLE_CLAUDE_CODE: "true" }),
},
stdio: ["ignore", "pipe", "pipe"],
detached: true,
@@ -4,12 +4,8 @@ import * as os from "os"
import * as nodePath from "path"
import { GitOps } from "../../src/agent-manager/GitOps"
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function ops(handler: (args: string[], cwd: string) => Promise<string>): GitOps {
return new GitOps({ log: () => undefined, refreshMs: 120000, runGit: handler })
return new GitOps({ log: () => undefined, runGit: handler })
}
function runGit(cwd: string, args: string[]): string {
@@ -193,106 +189,29 @@ describe("GitOps", () => {
})
})
describe("refreshRemote", () => {
it("fetches the remote", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return "/repo/.git"
return ""
})
await git.refreshRemote("/repo", "origin")
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(1)
expect(fetches[0]![3]).toBe("origin")
})
it("skips empty remote name", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
return ""
})
await git.refreshRemote("/repo", "")
expect(commands.length).toBe(0)
})
it("throttles repeated fetches for the same remote", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return "/repo/.git"
return ""
})
await git.refreshRemote("/repo", "origin")
await git.refreshRemote("/repo", "origin")
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(1)
})
it("deduplicates inflight fetches", async () => {
const commands: string[][] = []
const git = new GitOps({
log: () => undefined,
refreshMs: 0,
runGit: async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return "/repo/.git"
if (args[0] === "fetch") {
await sleep(50)
return ""
}
return ""
},
})
await Promise.all([git.refreshRemote("/repo", "origin"), git.refreshRemote("/repo", "origin")])
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(1)
})
})
describe("aheadBehind", () => {
it("counts commits ahead and behind using the provided ref", async () => {
const git = ops(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "1\t3"
return ""
})
expect(await git.aheadBehind("/repo", "origin/main")).toEqual({ ahead: 3, behind: 1 })
})
it("fetches the explicitly-provided remote before counting", async () => {
it("does not fetch from remote", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t4"
return ""
})
expect(await git.aheadBehind("/repo", "myfork/main", "myfork")).toEqual({ ahead: 4, behind: 0 })
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(1)
expect(fetches[0]![3]).toBe("myfork")
})
it("skips fetch when no remote is provided", async () => {
const commands: string[][] = []
const git = ops(async (args) => {
commands.push(args)
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t2"
return ""
})
expect(await git.aheadBehind("/repo", "main")).toEqual({ ahead: 2, behind: 0 })
await git.aheadBehind("/repo", "myfork/main")
const fetches = commands.filter((c) => c[0] === "fetch")
expect(fetches.length).toBe(0)
})
it("returns zeros when rev-list fails", async () => {
const git = ops(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list") throw new Error("fatal")
return ""
})
@@ -302,8 +221,6 @@ describe("GitOps", () => {
it("uses the ref directly without double-prefixing", async () => {
const refs: string[] = []
const git = ops(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") {
refs.push(args[3]!)
return "0\t1"
@@ -66,9 +66,6 @@ describe("GitStatsPoller", () => {
log: () => undefined,
intervalMs: 5,
git: gitOps(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t1"
return ""
}),
@@ -106,9 +103,7 @@ describe("GitStatsPoller", () => {
log: () => undefined,
intervalMs: 5,
git: gitOps(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t2"
return ""
}),
@@ -231,9 +226,7 @@ describe("GitStatsPoller", () => {
if (args[0] === "worktree") {
return `worktree ${wtAPath}\nbranch refs/heads/branch-a\n`
}
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "rev-parse" && args[3] === "@{upstream}") return "origin/main"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list") return "1"
return ""
}),
@@ -287,8 +280,6 @@ describe("GitStatsPoller", () => {
git: gitOps(async (args) => {
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") return "feature"
if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "@{upstream}") return "origin/feature"
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t3"
if (args[0] === "branch") return "feature"
if (args[0] === "config") return "origin"
@@ -341,8 +332,6 @@ describe("GitStatsPoller", () => {
// myfork/HEAD resolves to the default branch
if (args[0] === "symbolic-ref" && args[2] === "refs/remotes/myfork/HEAD") return "myfork/develop"
if (args[0] === "branch") return "my-feature"
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return ".git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t5"
return ""
}),
@@ -406,7 +395,7 @@ describe("GitStatsPoller", () => {
})
})
it("refreshes upstream remote once for concurrent worktrees", async () => {
it("does not fetch from remote for ahead/behind counts", async () => {
const commands: string[][] = []
const emitted: Array<
Array<{ worktreeId: string; files: number; additions: number; deletions: number; ahead: number; behind: number }>
@@ -416,7 +405,6 @@ describe("GitStatsPoller", () => {
worktree: { diffSummary: async () => ({ data: diff(0, 0) }) },
} as unknown as KiloClient
// Worktrees store remote="upstream" so aheadBehind receives "upstream/main"
const poller = new GitStatsPoller({
getWorktrees: () => [worktree("a", "upstream"), worktree("b", "upstream")],
getWorkspaceRoot: () => undefined,
@@ -427,8 +415,6 @@ describe("GitStatsPoller", () => {
intervalMs: 500,
git: gitOps(async (args) => {
commands.push(args)
if (args[0] === "rev-parse" && args[1] === "--git-common-dir") return "/repo/.git"
if (args[0] === "fetch") return ""
if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t0"
return ""
}),
@@ -439,9 +425,6 @@ describe("GitStatsPoller", () => {
poller.stop()
const fetches = commands.filter((cmd) => cmd[0] === "fetch")
expect(fetches.length).toBe(1)
const fetch = fetches[0]
if (!fetch) throw new Error("expected fetch command")
expect(fetch[3]).toBe("upstream")
expect(fetches.length).toBe(0)
})
})
@@ -363,11 +363,21 @@ describe("mapSSEEventToWebviewMessage", () => {
properties: {
id: "q1",
sessionID: "sess-1",
questions: [],
questions: [
{
question: "Ready to implement?",
header: "Implement",
options: [{ label: "Implement", description: "Switch to code", mode: "code" }],
},
],
},
}
const msg = mapSSEEventToWebviewMessage(event, "sess-1")
expect(msg?.type).toBe("questionRequest")
if (msg?.type === "questionRequest") {
const questions = msg.question.questions as Array<{ options?: Array<{ mode?: string }> }>
expect(questions[0]?.options?.[0]?.mode).toBe("code")
}
})
it("maps question.replied to questionResolved", () => {
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import { createSessionID } from "../../../src/legacy-migration/sessions/lib/ids"
const parseSession = mock(async () => ({
project: {
@@ -43,8 +44,12 @@ function ctx() {
function client() {
const calls: Array<{ name: string; body: unknown }> = []
const get = mock(async () => ({ data: null as { id: string } | null }))
return {
calls,
session: {
get,
},
kilocode: {
sessionImport: {
project: async (body: unknown) => {
@@ -73,9 +78,57 @@ describe("legacy migration migrate", () => {
parseSession.mockClear()
})
it("skips before parsing when the migrated session already exists and force is false", async () => {
const api = {
session: {
get: async (body: unknown) => {
expect(body).toEqual({ sessionID: createSessionID("legacy-task-1") })
return { data: { id: createSessionID("legacy-task-1") } }
},
},
kilocode: {
sessionImport: {
project: async () => {
throw new Error("should not import project")
},
session: async () => {
throw new Error("should not import session")
},
message: async () => {
throw new Error("should not import message")
},
part: async () => {
throw new Error("should not import part")
},
},
},
}
const result = await migrate({ id: "legacy-task-1" }, ctx() as never, api as never)
expect(result.ok).toBe(true)
expect(result).toHaveProperty("skipped", true)
expect(parseSession).not.toHaveBeenCalled()
})
it("continues with import when force is true even if the migrated session already exists", async () => {
const api = client()
api.session.get.mockImplementation(async () => ({ data: { id: createSessionID("legacy-task-1") } }))
await migrate({ id: "legacy-task-1", force: true }, ctx() as never, api as never)
expect(parseSession).toHaveBeenCalledTimes(1)
const call = api.calls.find((item) => item.name === "session")
expect(call).toBeDefined()
expect((call?.body as { force?: boolean }).force).toBe(true)
})
it("inserts project then session then message then part in the correct order", async () => {
const calls: string[] = []
const api = {
session: {
get: async () => ({ data: null }),
},
kilocode: {
sessionImport: {
project: async () => {
@@ -144,7 +197,7 @@ describe("legacy migration migrate", () => {
] as never,
})
await migrate("legacy-task-1", ctx() as never, api as never)
await migrate({ id: "legacy-task-1" }, ctx() as never, api as never)
expect(calls).toEqual(["project", "session", "message", "part"])
})
@@ -152,7 +205,7 @@ describe("legacy migration migrate", () => {
it("uses the projectID returned by backend when inserting the session", async () => {
const api = client()
await migrate("legacy-task-1", ctx() as never, api as never)
await migrate({ id: "legacy-task-1" }, ctx() as never, api as never)
const call = api.calls.find((x) => x.name === "session")
expect(call).toBeDefined()
@@ -161,6 +214,9 @@ describe("legacy migration migrate", () => {
it("returns ok false without throwing when one backend insert fails", async () => {
const api = {
session: {
get: async () => ({ data: null }),
},
kilocode: {
sessionImport: {
project: async () => ({ data: { id: "project-real" } }),
@@ -173,7 +229,7 @@ describe("legacy migration migrate", () => {
},
}
const result = await migrate("legacy-task-1", ctx() as never, api as never)
const result = await migrate({ id: "legacy-task-1" }, ctx() as never, api as never)
expect(result.ok).toBe(false)
})
@@ -181,6 +237,9 @@ describe("legacy migration migrate", () => {
it("skips message and part imports when backend reports the session already exists", async () => {
const calls: string[] = []
const api = {
session: {
get: async () => ({ data: null }),
},
kilocode: {
sessionImport: {
project: async () => {
@@ -203,7 +262,7 @@ describe("legacy migration migrate", () => {
},
}
const result = await migrate("legacy-task-1", ctx() as never, api as never)
const result = await migrate({ id: "legacy-task-1" }, ctx() as never, api as never)
expect(result.ok).toBe(true)
expect(result).toHaveProperty("skipped", true)
@@ -0,0 +1,33 @@
import { describe, expect, it } from "bun:test"
import {
createSessionItem,
createSessionSummary,
updateSessionSummary,
} from "../../../webview-ui/src/components/migration/session-migration-summary-state"
const session = createSessionItem({
id: "legacy-1",
title: "Legacy task",
directory: "/workspace/testing",
time: 1,
})
describe("session migration summary state", () => {
it("moves a session from skipped to imported when a force rerun finishes successfully", () => {
const skipped = updateSessionSummary(createSessionSummary(), session, "skipped")
const done = updateSessionSummary(skipped, session, "done")
expect(done.skipped).toHaveLength(0)
expect(done.errored).toHaveLength(0)
expect(done.imported).toEqual([session])
})
it("moves a session into errored and removes it from the other buckets", () => {
const done = updateSessionSummary(createSessionSummary(), session, "done")
const next = updateSessionSummary(done, { ...session, error: "Boom\nstack" }, "error")
expect(next.imported).toHaveLength(0)
expect(next.skipped).toHaveLength(0)
expect(next.errored).toEqual([{ ...session, error: "Boom\nstack" }])
})
})
@@ -1,5 +1,10 @@
import { describe, it, expect } from "bun:test"
import { toggleAnswer } from "../../webview-ui/src/components/chat/question-dock-utils"
import {
resolveOptimisticQuestionAgent,
resolveQuestionMode,
resolveSelectedQuestionMode,
toggleAnswer,
} from "../../webview-ui/src/components/chat/question-dock-utils"
describe("toggleAnswer", () => {
it("adds answer when not present", () => {
@@ -36,3 +41,90 @@ describe("toggleAnswer", () => {
expect(result).toEqual(["a"])
})
})
describe("resolveQuestionMode", () => {
it("returns mode for matching predefined option", () => {
const result = resolveQuestionMode(
[
{ label: "Implement", description: "Switch to code", mode: "code" },
{ label: "Stay", description: "Remain here" },
],
"Implement",
)
expect(result).toBe("code")
})
it("returns undefined for unknown answer", () => {
const result = resolveQuestionMode([{ label: "Implement", description: "Switch", mode: "code" }], "Custom")
expect(result).toBeUndefined()
})
it("returns undefined when option has no mode", () => {
const result = resolveQuestionMode([{ label: "Stay", description: "Remain here" }], "Stay")
expect(result).toBeUndefined()
})
})
describe("resolveSelectedQuestionMode", () => {
it("returns the selected mode from predefined answers", () => {
const result = resolveSelectedQuestionMode(
[
[
{ label: "Implement", description: "Switch to code", mode: "code" },
{ label: "Stay", description: "Remain here" },
],
].map((options) => ({ options })),
[["Implement"]],
)
expect(result).toBe("code")
})
it("ignores custom answers that replace a mode option", () => {
const result = resolveSelectedQuestionMode(
[{ options: [{ label: "Implement", description: "Switch to code", mode: "code" }] }],
[["Implement custom flow"]],
)
expect(result).toBeUndefined()
})
it("keeps mode answers from other questions", () => {
const result = resolveSelectedQuestionMode(
[
{ options: [{ label: "Implement", description: "Switch to code", mode: "code" }] },
{ options: [{ label: "Stay", description: "Remain here" }] },
],
[["Implement"], ["Stay"]],
)
expect(result).toBe("code")
})
})
describe("resolveOptimisticQuestionAgent", () => {
it("stores the previous agent when applying an optimistic mode", () => {
const result = resolveOptimisticQuestionAgent(undefined, "ask", "code")
expect(result).toEqual({ base: "ask", agent: "code" })
})
it("reverts to the stored previous agent when the mode is cleared", () => {
const result = resolveOptimisticQuestionAgent("ask", "code", undefined)
expect(result).toEqual({ base: undefined, agent: "ask" })
})
it("avoids switching when the selected mode already matches the current agent", () => {
const result = resolveOptimisticQuestionAgent(undefined, "code", "code")
expect(result).toEqual({ base: undefined, agent: undefined })
})
it("keeps the original base agent while changing between mode answers", () => {
const result = resolveOptimisticQuestionAgent("ask", "code", "architect")
expect(result).toEqual({ base: "ask", agent: "architect" })
})
})
@@ -0,0 +1,60 @@
import { describe, it, expect } from "bun:test"
import { resolveSessionAgent } from "../../webview-ui/src/context/session-agent"
import type { Message } from "../../webview-ui/src/types/messages"
function makeMessage(overrides: Partial<Message> = {}): Message {
return {
id: "msg-1",
sessionID: "sess-1",
role: "user",
createdAt: new Date(0).toISOString(),
...overrides,
}
}
describe("resolveSessionAgent", () => {
it("returns the latest valid user agent", () => {
const result = resolveSessionAgent(
[
makeMessage({ id: "1", agent: "plan" }),
makeMessage({ id: "2", role: "assistant", agent: "ask" }),
makeMessage({ id: "3", agent: "code" }),
],
new Set(["plan", "code", "ask"]),
)
expect(result).toBe("code")
})
it("ignores assistant messages", () => {
const result = resolveSessionAgent(
[makeMessage({ role: "assistant", agent: "code" }), makeMessage({ agent: "plan" })],
new Set(["plan", "code"]),
)
expect(result).toBe("plan")
})
it("ignores unknown agent names", () => {
const result = resolveSessionAgent(
[makeMessage({ agent: "missing" }), makeMessage({ agent: "code" })],
new Set(["code"]),
)
expect(result).toBe("code")
})
it("ignores empty agent values", () => {
const result = resolveSessionAgent([makeMessage({ agent: " " })], new Set(["code"]))
expect(result).toBeUndefined()
})
it("returns undefined when no valid user agent exists", () => {
const result = resolveSessionAgent(
[makeMessage({ role: "assistant", agent: "code" }), makeMessage({ agent: undefined })],
new Set(["code"]),
)
expect(result).toBeUndefined()
})
})
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest"
import { color, palette, label } from "../../webview-ui/src/utils/timeline/colors"
import type {
Part,
ToolPart,
TextPart,
ReasoningPart,
StepStartPart,
StepFinishPart,
} from "../../webview-ui/src/types/messages"
function mkText(text = "hello"): TextPart {
return { id: "t1", type: "text", text }
}
function mkReasoning(text = "thinking..."): ReasoningPart {
return { id: "r1", type: "reasoning", text }
}
function mkTool(name: string, status: "pending" | "running" | "completed" | "error" = "completed"): ToolPart {
const base = { id: "tool1", type: "tool" as const, tool: name }
if (status === "pending") return { ...base, state: { status: "pending", input: {} } }
if (status === "running") return { ...base, state: { status: "running", input: {} } }
if (status === "error") return { ...base, state: { status: "error", input: {}, error: "fail" } }
return { ...base, state: { status: "completed", input: {}, output: "ok", title: name } }
}
function mkStepStart(): StepStartPart {
return { id: "ss1", type: "step-start" }
}
function mkStepFinish(): StepFinishPart {
return { id: "sf1", type: "step-finish", reason: "done" }
}
describe("timeline colors", () => {
it("classifies text parts as text color", () => {
expect(color(mkText())).toBe(palette.text)
})
it("classifies reasoning parts as reasoning color", () => {
expect(color(mkReasoning())).toBe(palette.reasoning)
})
it("classifies read tools as read color", () => {
expect(color(mkTool("read"))).toBe(palette.read)
expect(color(mkTool("glob"))).toBe(palette.read)
expect(color(mkTool("grep"))).toBe(palette.read)
expect(color(mkTool("ls"))).toBe(palette.read)
expect(color(mkTool("diagnostics"))).toBe(palette.read)
expect(color(mkTool("warpgrep"))).toBe(palette.read)
})
it("classifies write tools as write color", () => {
expect(color(mkTool("edit"))).toBe(palette.write)
expect(color(mkTool("write"))).toBe(palette.write)
expect(color(mkTool("patch"))).toBe(palette.write)
expect(color(mkTool("multiedit"))).toBe(palette.write)
expect(color(mkTool("apply_patch"))).toBe(palette.write)
})
it("classifies generic tools as tool color", () => {
expect(color(mkTool("bash"))).toBe(palette.tool)
expect(color(mkTool("task"))).toBe(palette.tool)
expect(color(mkTool("browser"))).toBe(palette.tool)
})
it("classifies errored tools as error color", () => {
expect(color(mkTool("bash", "error"))).toBe(palette.error)
expect(color(mkTool("read", "error"))).toBe(palette.error)
})
it("classifies step-start as step color", () => {
expect(color(mkStepStart())).toBe(palette.step)
})
it("classifies step-finish as success color", () => {
expect(color(mkStepFinish())).toBe(palette.success)
})
it("returns fallback for unknown part types", () => {
const weird = { id: "w1", type: "snapshot" } as unknown as Part
expect(color(weird)).toBe(palette.fallback)
})
})
describe("timeline labels", () => {
it("returns 'Text' for text parts", () => {
expect(label(mkText())).toBe("Text")
})
it("returns 'Reasoning' for reasoning parts", () => {
expect(label(mkReasoning())).toBe("Reasoning")
})
it("returns tool name for tool parts", () => {
expect(label(mkTool("bash"))).toBe("bash")
expect(label(mkTool("read"))).toBe("read")
})
it("returns 'Step start' for step-start", () => {
expect(label(mkStepStart())).toBe("Step start")
})
it("returns 'Step finish' for step-finish", () => {
expect(label(mkStepFinish())).toBe("Step finish")
})
})
@@ -0,0 +1,92 @@
import { describe, it, expect } from "vitest"
import { sizes, MAX_HEIGHT } from "../../webview-ui/src/utils/timeline/sizes"
import type { Part, TextPart, ToolPart, StepFinishPart } from "../../webview-ui/src/types/messages"
function mkText(text: string): TextPart {
return { id: `t-${text.length}`, type: "text", text }
}
function mkTool(name: string, input: Record<string, unknown> = {}, output = ""): ToolPart {
return {
id: `tool-${name}`,
type: "tool",
tool: name,
state: { status: "completed", input, output, title: name },
}
}
function mkStepFinish(input = 100, output = 50): StepFinishPart {
return {
id: "sf",
type: "step-finish",
reason: "done",
tokens: { input, output },
}
}
describe("timeline sizes", () => {
it("returns empty array for empty input", () => {
expect(sizes([])).toEqual([])
})
it("returns one entry per part", () => {
const parts: Part[] = [mkText("a"), mkText("bb"), mkText("ccc")]
const result = sizes(parts)
expect(result).toHaveLength(3)
})
it("all bars have uniform width", () => {
const parts: Part[] = [mkText("short"), mkText("a".repeat(500)), mkText("medium")]
const result = sizes(parts)
const w = result[0]!.width
for (const bar of result) {
expect(bar.width).toBe(w)
}
})
it("height stays within bounds", () => {
const parts: Part[] = [mkText("short"), mkText("a".repeat(500)), mkText("medium length text")]
const result = sizes(parts)
for (const bar of result) {
expect(bar.height).toBeGreaterThanOrEqual(8)
expect(bar.height).toBeLessThanOrEqual(MAX_HEIGHT)
}
})
it("larger content produces taller bars", () => {
const parts: Part[] = [mkText("x"), mkText("x".repeat(1000))]
const result = sizes(parts)
expect(result[1]!.height).toBeGreaterThan(result[0]!.height)
})
it("handles tool parts with input/output content", () => {
const parts: Part[] = [
mkTool("bash", { command: "ls" }, "file1\nfile2\nfile3"),
mkTool("read", { path: "README.md" }, "a".repeat(200)),
]
const result = sizes(parts)
expect(result).toHaveLength(2)
expect(result[0]!.content).toBeGreaterThan(0)
expect(result[1]!.content).toBeGreaterThan(result[0]!.content)
})
it("handles step-finish parts using token counts", () => {
const parts: Part[] = [mkStepFinish(1000, 500), mkStepFinish(100, 50)]
const result = sizes(parts)
expect(result).toHaveLength(2)
expect(result[0]!.content).toBeGreaterThan(result[1]!.content)
})
it("handles single-part input without crashing", () => {
const result = sizes([mkText("only one")])
expect(result).toHaveLength(1)
})
it("returns integer values for height", () => {
const parts: Part[] = [mkText("a"), mkText("bb"), mkText("ccc"), mkText("dddd")]
const result = sizes(parts)
for (const bar of result) {
expect(Number.isInteger(bar.height)).toBe(true)
}
})
})
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6442d82e137e36d165e5f10ca7dbf02445badf109d442eac05714c50593068fe
size 7819
oid sha256:e929c1f6dd90495d14edea552acfd2f1a3a609d1c3713651f5e11f40aac19309
size 8120
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00c00caa93376d82f4128001416fe14ee71f786f0bff3d6550dc97ff9a675037
size 27167
oid sha256:024ac7b12c5c6455d30c349dbfde0faff8d8c864761d0d79f0f9e08dbf7144e0
size 26331
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7576fe3532b45c91e0d4d65c97b515565e7b3ad26d6d713d0d52fdab0e0c0565
size 29627
oid sha256:2ff364be7ea176328dc7f4500e3568aed460a2caa9dd4dedca614cd1db273185
size 25150
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a69ac58dc72885950a702661b75bc1989a28e8b07b3136037255891b7e5a3b06
size 12839
oid sha256:070f9f8cb76fc01ceba027cbcaf198e0530545a0aa78ea1ead75f1e09bb252a1
size 14152
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:30346f68598419d1fe80a3f33d3326cc844a30356ebedb022a841be4096514c6
size 53909
oid sha256:5094e214a52bac4627f8fe280f4818c986753c49f160bf5295f21122054ea72f
size 53684
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e654374e5c12a4a184a0d27709cd773684e31e08a3d7b6305358849cf30c75be
size 50986
oid sha256:83a70ef1260f77c3017db90a918ee77c332c065bfc047e0f206948bb5dde83b9
size 50888
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b7e17426d82ff00ac8194963a3c3be946994018e2f6c9553dfaee237cb043fd9
size 11324
oid sha256:b569b91c3437b55163cc48a2fcf6f5b0ebd2c6a93aff425b14d70f6055201f20
size 12716
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e2eb5ca750d8a590df5a4c7a1be493290aa90535a98c5dc0f6fadfc802872567
size 3520
oid sha256:e2bb93bd5830bcfd208615e64c8b2b88855d1faa97cd7980df9d95cfada533d3
size 3561
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1e5fd252dcc1acaeefae5608621a1c32282f99c24f7ee464d375cd149722c4fc
size 3915
oid sha256:862a22d4123e5c512d4ae87eb881c5923f14ac5207bf77b4707063b3dcc53422
size 3991
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2da4bbf78414fd58ef84b03f421954741edae16df0a0fccc4ad4e99e752e70ff
size 3882
oid sha256:c49479d1fffe96d14300617d48be269e5bffe41bc50f555b1ee3fbb09d8919e1
size 3896

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