Merge branch 'main' into imanolmaiztegui/kilo-opencode-v1.4.0

This commit is contained in:
Imanol Maiztegui
2026-04-14 18:25:59 +02:00
committed by GitHub
127 changed files with 3219 additions and 1114 deletions
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Fixed default model falling back to the free model after login or org switch by invalidating cached provider state when auth changes.
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-gateway": patch
---
Include the feature header when requesting the models list from Kilo Gateway
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Reduce git process load via visibility-aware polling and resolution caching in GitStatsPoller
+12
View File
@@ -4,12 +4,24 @@
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<compositeConfiguration>
<compositeBuild compositeDefinitionSource="SCRIPT">
<builds>
<build path="$PROJECT_DIR$/packages/kilo-jetbrains/build-tasks" name="build-tasks">
<projects>
<project path="$PROJECT_DIR$/packages/kilo-jetbrains/build-tasks" />
</projects>
</build>
</builds>
</compositeBuild>
</compositeConfiguration>
<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/build-tasks" />
<option value="$PROJECT_DIR$/packages/kilo-jetbrains/frontend" />
<option value="$PROJECT_DIR$/packages/kilo-jetbrains/shared" />
</set>
+2
View File
@@ -175,6 +175,8 @@ Tests MUST test actual implementation, do not duplicate logic into a test.
User-facing changes (features, fixes, breaking changes) require a changeset file for release notes. Run `bunx changeset add` or manually create `.changeset/<slug>.md`. Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. See `.changeset/README.md` for details.
Changeset descriptions appear directly in release notes and are read by end users. Keep them concise and feature-oriented — describe **what changed from the user's perspective**, not implementation details. Write in imperative mood (e.g. "Support exporting conversations as markdown" not "Add a new export handler that serializes session messages to .md files").
## Pull Requests
PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus on intent and context a reviewer can't get from the diff — skip file-by-file inventories, test result summaries, and anything obvious from the code itself.
+16 -16
View File
@@ -30,7 +30,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
@@ -86,7 +86,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -119,7 +119,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -151,7 +151,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -180,7 +180,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -215,7 +215,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.2.5",
"version": "7.2.6",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -228,7 +228,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"@opentelemetry/api": "1.9.0",
@@ -248,7 +248,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@kobalte/core": "0.13.11",
"@opencode-ai/util": "workspace:*",
@@ -283,7 +283,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-i18n": "workspace:*",
@@ -336,7 +336,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.2.5",
"version": "7.2.6",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -479,7 +479,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"zod": "catalog:",
@@ -503,7 +503,7 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"semver": "^7.6.3",
},
@@ -514,7 +514,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -529,7 +529,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.2.5",
"version": "7.2.6",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -552,7 +552,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -602,7 +602,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "7.2.5",
"version": "7.2.6",
"dependencies": {
"zod": "catalog:",
},
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-QozPRHFapCgdinNYAc2Yk+8sk/w7lJN2StBSjeXh5Rc=",
"aarch64-linux": "sha256-da239x4X+qs1EFGKJnSZu1bsqRVbKQVOvOYzzORokyI=",
"aarch64-darwin": "sha256-vp+jfpceTQjalxYIUh1ewxDis8mcTJNh6phbyHn/ucA=",
"x86_64-darwin": "sha256-cQgLcQGTUizoMIZ+KMj9HNEmogL84bJYakWGlXxWvkk="
"x86_64-linux": "sha256-wRgRi939a/0RPCKthLccnBjYbiRcWhCOhbHd0HgyUhs=",
"aarch64-linux": "sha256-oYqoGtXLp9/GE5yU9l6RLoK8sVLxZKgp1BMcpmDhFe0=",
"aarch64-darwin": "sha256-zTpY9iM/Vp/OmQ35rybhRgoN7neXzNeugZD+0a5O+LU=",
"x86_64-darwin": "sha256-H/5W9FKEK/N9m11+N0sIZgr9LMQuhKaErrxYyQBEhtM="
}
}
+1 -1
View File
@@ -135,6 +135,6 @@
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
},
"version": "7.2.5",
"version": "7.2.6",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "7.2.5",
"version": "7.2.6",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "7.2.5",
"version": "7.2.6",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "7.2.5",
"version": "7.2.6",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.2.5",
"version": "7.2.6",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -7,8 +7,10 @@ description: "Connect a dedicated Google account to KiloClaw for access to Gmail
Connect a dedicated Google account to KiloClaw so it can interact with Google Workspace services — Gmail, Calendar, Drive, Docs, Sheets, Slides, Tasks, People, Forms, Chat, Classroom, and Apps Script.
{% callout type="warning" title="Use a standalone Google account" %}
We strongly recommend creating a **dedicated Google account** specifically for KiloClaw rather than connecting your personal Google account. This keeps your personal data separate and gives you full control over what KiloClaw can access. Throughout this guide, "the KiloClaw Google account" refers to this dedicated account.
{% callout type="warning" title="Use a dedicated Google account" %}
We recommend creating a **dedicated Google account** for KiloClaw. This keeps your personal data separate and gives you full control over what KiloClaw can access.
If you are using Google Workspace, we recommend creating the bot account inside the Google Workspace.
{% /callout %}
## What You Get
@@ -46,6 +46,12 @@ Before configuring, take these preflight steps:
1. **Create a Google Account for your Claw** — Go to [google.com](https://www.google.com/) and create a new Google/Gmail account dedicated to your KiloClaw. Something like `yourname.bot@gmail.com` works well.
{% callout type="tip" title="Google Workspace users" %}
If your organization uses Google Workspace, create the dedicated bot account inside your Workspace domain (e.g., `claw@yourcompany.com`) rather than as a standalone `@gmail.com` account.
A Workspace-managed account benefits from your organization's admin policies, making configuration easier.
{% /callout %}
2. **Set up Google Cloud** — Visit [console.cloud.google.com](https://console.cloud.google.com). Accept the terms of service and click "Start my free tier". You may need to add a credit card for identity verification.
{% callout type="info" %}
@@ -1,171 +0,0 @@
---
title: "Setup walkthrough"
description: "Start-to-finish guide for configuring your KiloClaw instance"
---
# Setup walkthrough
This guide walks you through a full KiloClaw setup — from creating accounts to scheduling your first automated workflow. Plan for about 60 minutes.
---
## Planning your setup
For most users, a useful KiloClaw configuration involves:
1. A **chat platform** (called a "channel" in OpenClaw) so you can message your Claw
2. **Google services** for email, calendar, and Drive
3. **GitHub** for code and markdown syncing
### Use dedicated accounts for your Claw
We recommend creating **separate accounts** for your KiloClaw rather than connecting it to your personal accounts. This applies to Google, GitHub, and any other services you connect. A dedicated account improves isolation — your personal data stays separate, and you can control exactly what access the Claw has by sharing or delegating only what you want.
### Chat platform options
- **[Kilo Chat](https://app.kilo.ai)** — available in the web app and coming soon to iOS and Android; requires zero configuration
- **[Telegram](/docs/kiloclaw/chat-platforms/telegram)** — easy to set up, private by default
- **[Discord](/docs/kiloclaw/chat-platforms/discord)** — moderate setup
- **[Slack](/docs/kiloclaw/chat-platforms/slack)** — most involved setup
{% callout type="warning" title="Chain-of-connection security" %}
If your Claw has access to sensitive data (like your email), be careful which chat platform you connect it to. On broadly-accessible platforms like Slack or Discord, anyone on the server could potentially message your Claw and access that data. If you're connecting sensitive integrations, use a private platform like Kilo Chat or Telegram.
{% /callout %}
The steps below walk you through this configuration.
---
## Preflight Steps
Take these steps before configuring your Claw.
If you are doing a [1-1 configuration call with Kilo](https://kilo.ai/kiloclaw/config-service), please complete these steps before the call.
### Google
Configuring Google services is by far the most involved part of setting up your Claw.
Before configuring, take these preflight steps:
1. **Create a Google Account for your Claw** — Go to [google.com](https://www.google.com/) and create a new Google/Gmail account dedicated to your KiloClaw. Something like `yourname.bot@gmail.com` works well.
2. **Set up Google Cloud** — Visit [console.cloud.google.com](https://console.cloud.google.com). Accept the terms of service and click "Start my free tier". You may need to add a credit card for identity verification.
{% callout type="info" %}
Nothing KiloClaw does costs any money with Google.
{% /callout %}
3. **Install Docker** — KiloClaw configures Google by running a Docker container on your machine. Download Docker at [docker.com](https://www.docker.com/), then open it. You don't need to sign in or create a Docker account.
## Other Services
1. **Create a GitHub account for your Claw** — Using your new Gmail address, create a matching GitHub account for your Claw.
---
## Set up a messaging platform
Your Claw needs a way to communicate with you. **[Kilo Chat](https://app.kilo.ai)** requires no setup — just open the web app. For other platforms, follow the relevant guide:
- [Telegram](/docs/kiloclaw/chat-platforms/telegram) — about 2 minutes
- [Discord](/docs/kiloclaw/chat-platforms/discord) — about 10 minutes
- [Slack](/docs/kiloclaw/chat-platforms/slack) — about 15 minutes; always use the manifest
{% callout type="tip" %}
If you're not sure which to pick, Kilo Chat (no setup) or Telegram (2 minutes) are the easiest options.
{% /callout %}
---
## Set up Google OAuth
This lets your Claw act as the bot Google account — sending email, reading calendar, and more. Takes about 15 minutes.
Prerequisites: Docker is installed and running, and your bot Google account is already created.
1. In the KiloClaw dashboard, go to **Settings → Google Account** and copy the Docker command shown.
2. Open a terminal and run the command.
3. Follow the steps in the console:
- At each step, confirm you're logged in to the bot account (check the top-right corner of the screen).
- After project creation, confirm you're in the correct project.
- The last step may look like it failed — this is expected.
For full details, see the [Google setup guide](/docs/kiloclaw/development-tools/google).
---
## Set up GitHub
A dedicated bot GitHub account is strongly recommended. Takes about 7 minutes.
**Create a Personal Access Token (PAT):**
1. In GitHub, go to **Settings → Developer Settings → Personal Access Tokens → Classic → Generate new token**.
2. Select these scopes: `repo`, `workflow`, `write:org`, `read:user`.
For full details, see the [GitHub setup guide](/docs/kiloclaw/development-tools/github).
**Set up a private workspace repo:**
Once GitHub is connected, ask your Claw to back up its workspace:
```
Use your GitHub access to back up your workspace. Make it a GitHub repo and push it as a private repo. Add me as a member so I can see it. Then set up a cron job to pull, rebase, and push any changes at least once an hour.
```
After sending that, redeploy from the dashboard to pick up the changes.
---
## Grant email and calendar access
After OAuth is set up, decide how much access to give your Claw to your personal accounts.
| Option | What it does | Best for | Configured from |
| ----------------------- | --------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------- |
| Forward select emails | A Gmail filter forwards specific senders or labels to the bot account | Targeted use cases like newsletter digests | Your personal account |
| Forward all email | Forwards your full inbox to the bot | Simpler setups where noise is acceptable | Bot account (destination) |
| Full account delegation | Gives the bot direct read/write access to your personal account | Maximum capability | Your personal account — Gmail Settings → Add a delegate |
{% callout type="info" %}
Email forwarding is configured from the **destination** (bot) account. Account delegation is configured from the **source** (personal) account.
{% /callout %}
**Push notifications:** by default, your Claw wakes up on every incoming email. If you'd prefer a digest (e.g., once at 7am), disable push notifications in **Settings → Google Account** on the [dashboard](/docs/kiloclaw/dashboard) — otherwise it processes each email as it arrives.
**Google Calendar:** share your personal calendar from your personal Google account. Go to **Google Calendar → Settings → Settings for my calendars → [your calendar] → Share with specific people**, and add the bot account.
---
## Enable auto-approval
By default, KiloClaw asks for confirmation before every tool call. To let it act freely, go to the [KiloClaw dashboard](https://app.kilo.ai/claw) and enable auto-approval in the **Default Permissions** section.
---
## Prompt and schedule work
**How to prompt your Claw:** just tell it in plain language what you want. Be specific. If you want it to remember something across sessions, tell it to write it down in a specific file.
**Scheduling jobs:** tell your Claw when and what to do — for example:
```
Every day at 7am, summarize my emails and send me a digest.
```
Mentioning "cron job" helps it understand you want a recurring scheduled task.
**Skills:** the OpenClaw Skills hub has pre-built capabilities you can add. The easiest way to install a skill is to just ask your Claw by name:
```
Install the [skill name] skill.
```
---
## Manage inference
**Model picker:** Balanced is a good starting point. Frontier is more capable but significantly more expensive.
You can also use your KiloPass credits — find this under **Profile** in the dashboard.
@@ -825,4 +825,14 @@ module.exports = [
basePath: false,
permanent: true,
},
// ============================================
// KILOCLAW
// ============================================
{
source: "/docs/kiloclaw/suggested-configuration",
destination: "/docs/kiloclaw/end-to-end",
basePath: false,
permanent: true,
},
]
+2 -2
View File
@@ -55,12 +55,12 @@
<!-- packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx -->
- <https://github.com/Kilo-Org/kilocode/issues/6986>
<!-- packages/kilo-vscode/src/agent-manager/constants.ts -->
- <https://github.com/Kilo-Org/kilocode/issues/new?template=bug-report.yml>
<!-- packages/opencode/src/cli/cmd/tui/app.tsx -->
- <https://github.com/Kilo-Org/kilocode/issues/new/choose>
<!-- packages/kilo-vscode/webview-ui/src/components/chat/FeedbackDialog.tsx -->
- <https://github.com/microsoft/vscode-eslint/archive/refs/heads/main.zip>
<!-- packages/opencode/src/lsp/server.ts -->
- <https://github.com/OpenRouterTeam/ai-sdk-provider/pull/344/>
<!-- packages/opencode/src/provider/transform.ts -->
- <https://github.com/oven-sh/bun/issues/16682>
<!-- packages/opencode/src/provider/provider.ts -->
- <https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.2.5",
"version": "7.2.6",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+2 -1
View File
@@ -1,6 +1,6 @@
import { z } from "zod"
import { getKiloUrlFromToken } from "../auth/token.js"
import { getDefaultHeaders } from "../headers.js"
import { getDefaultHeaders, buildKiloHeaders } from "../headers.js"
import { KILO_API_BASE, KILO_OPENROUTER_BASE, MODELS_FETCH_TIMEOUT_MS, PROMPTS, AI_SDK_PROVIDERS } from "./constants.js"
/**
@@ -89,6 +89,7 @@ export async function fetchKiloModels(options?: {
const response = await fetch(modelsURL, {
headers: {
...getDefaultHeaders(),
...buildKiloHeaders(undefined, { kilocodeOrganizationId: organizationId }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS),
@@ -32,6 +32,7 @@ interface KiloRoutesDeps extends ImportDeps {
Auth: Auth
ModelCache: ModelCache
z: Z
Instance: ImportDeps["Instance"] & { disposeAll(): Promise<void> }
}
/**
@@ -206,6 +207,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
ModelCache.clear("kilo")
clearModesCache()
await Instance.disposeAll()
return c.json(true)
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.2.5",
"version": "7.2.6",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+5 -3
View File
@@ -54,15 +54,17 @@ The built plugin archive is at `build/distributions/kilo.jetbrains-<version>.zip
## 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.
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.
On a fresh worktree, `runIde` now checks `backend/build/generated/cli/cli/` first. If the local-platform CLI binary is missing, it runs the standard single-binary generation flow and copies the result into the backend resources automatically.
That bootstrap is local-development only. Production packaging still requires running `bun run build:production` so all platform binaries are present.
---
## 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.
You can run `./gradlew buildPlugin` directly for local development. Gradle will auto-generate the current-platform CLI binary if `backend/build/generated/cli/` is missing.
For production verification:
@@ -3,6 +3,7 @@ plugins {
alias(libs.plugins.kotlin)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.openapi.generator)
id("build-tasks")
}
kotlin {
@@ -27,23 +28,22 @@ openApiGenerate {
apiPackage.set("ai.kilocode.jetbrains.api.client")
modelPackage.set("ai.kilocode.jetbrains.api.model")
configOptions.set(mapOf(
"serializationLibrary" to "moshi",
"serializationLibrary" to "kotlinx_serialization",
"omitGradleWrapper" to "true",
"omitGradlePluginVersions" to "true",
"useCoroutines" to "false",
"sourceFolder" to "src/main/kotlin",
"enumPropertyNaming" to "UPPERCASE",
))
// Remap schema "File" so the generated class is not named java.io.File
modelNameMappings.set(mapOf(
"File" to "DiffFileInfo",
))
// Map empty anyOf references to kotlin.Any
typeMappings.set(mapOf(
"AnyOfLessThanGreaterThan" to "kotlin.Any",
"anyOf<>" to "kotlin.Any",
"number" to "kotlin.Double",
"decimal" to "kotlin.Double",
))
// Normalise OpenAPI 3.1 → 3.0-compatible patterns
openapiNormalizer.set(mapOf(
"SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING" to "true",
"SIMPLIFY_ONEOF_ANYOF" to "true",
@@ -54,51 +54,9 @@ openApiGenerate {
generateModelDocumentation.set(false)
}
// Fix openapi-generator 3.1.1 codegen bugs in generated Kotlin sources.
//
// The OpenAPI spec uses `const: true` on boolean fields (e.g. `healthy`).
// openapi-generator turns these into single-value enum classes:
//
// val healthy: GlobalHealth200Response.Healthy
// enum class Healthy(val value: kotlin.Boolean) { @Json(name = "true") TRUE("true") }
//
// Moshi's EnumJsonAdapter calls nextString() for the value, but the server sends
// a JSON boolean `true`, not a JSON string `"true"`, causing:
// JsonDataException: Expected a string but was BOOLEAN at path $.healthy
//
// Fix: replace the enum field type with kotlin.Boolean, remove the enum class.
val fixGeneratedApi by tasks.registering {
val fixGeneratedApi by tasks.registering(FixGeneratedApiTask::class) {
dependsOn("openApiGenerate")
val dir = generatedApi
doLast {
// Regex to find boolean const enum declarations inside data classes.
// Captures the enum name so we can find and fix the corresponding field.
val enumDecl = Regex(
"""enum class (\w+)\(val value: kotlin\.Boolean\)"""
)
dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file ->
var text = file.readText()
val names = enumDecl.findAll(text).map { it.groupValues[1] }.toList()
if (names.isEmpty()) return@forEach
for (name in names) {
// Replace field type: `val foo: EnclosingClass.EnumName` → `val foo: kotlin.Boolean`
text = text.replace(Regex("""(val \w+:\s*)\w+\.$name""")) { m ->
"${m.groupValues[1]}kotlin.Boolean"
}
// Remove the @JsonClass annotation + enum class block
text = text.replace(Regex(
"""\n\s*@JsonClass\(generateAdapter = false\)\s*\n\s*enum class $name\(val value: kotlin\.Boolean\)\s*\{[^}]*\}"""
), "")
// Remove the orphaned KDoc block that preceded the enum (lines of ` *` ending with `*/`)
// These look like: \n /**\n * \n *\n * Values: TRUE\n */
text = text.replace(Regex(
"""\n\s*/\*\*\s*\n(\s*\*[^\n]*\n)*\s*\*/\s*(?=\n\s*\n)"""
), "")
}
file.writeText(text)
}
}
generated.set(generatedApi)
}
tasks.named("compileKotlin") {
@@ -117,33 +75,36 @@ val requiredPlatforms = listOf(
"windows-arm64",
)
val checkCli by tasks.registering {
val localCli by tasks.registering(PrepareLocalCliTask::class) {
description = "Prepare local CLI binary for JetBrains dev"
val os = providers.systemProperty("os.name").map {
val name = it.lowercase()
if (name.contains("mac")) return@map "darwin"
if (name.contains("win")) return@map "windows"
if (name.contains("linux")) return@map "linux"
throw GradleException("Unsupported host OS: $it")
}
val arch = providers.systemProperty("os.arch").map {
val name = it.lowercase()
if (name == "aarch64" || name == "arm64") return@map "arm64"
if (name == "x86_64" || name == "amd64") return@map "x64"
throw GradleException("Unsupported host arch: $it")
}
script.set(rootProject.layout.projectDirectory.file("script/build.ts"))
root.set(rootProject.layout.projectDirectory)
out.set(cliDir)
platform.set(os.zip(arch) { a, b -> "$a-$b" })
exe.set(platform.map { if (it.startsWith("windows")) "kilo.exe" else "kilo" })
}
val prod = production
val checkCli by tasks.registering(CheckCliTask::class) {
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."
)
}
}
dir.set(cliDir)
this.production.set(prod)
platforms.set(requiredPlatforms)
if (!prod.get()) {
dependsOn(localCli)
}
}
@@ -162,6 +123,13 @@ dependencies {
implementation(project(":shared"))
implementation(libs.okhttp)
implementation(libs.okhttp.sse)
implementation(libs.moshi)
implementation(libs.moshi.kotlin)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
@@ -1,4 +1,4 @@
package ai.kilocode.server
package ai.kilocode.backend
import okhttp3.ConnectionPool
import okhttp3.Interceptor
@@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit
* Both clients bundle Basic Auth via an interceptor and are fully independent
* of any IntelliJ-platform-provided HTTP stack.
*/
object KiloHttpClients {
object KiloBackendHttpClients {
private const val CONNECT_TIMEOUT_MS = 10_000L
private const val HEALTH_TIMEOUT_MS = 3_000L
@@ -0,0 +1,22 @@
package ai.kilocode.backend
import com.intellij.openapi.components.Service
import com.intellij.openapi.project.Project
import kotlinx.coroutines.CoroutineScope
/**
* Project-level backend service that provides the project's working
* directory for scoping CLI API calls.
*
* Currently a thin shell — will hold project-scoped data loading
* (providers, agents, config, etc.) in the future.
*/
@Service(Service.Level.PROJECT)
class KiloBackendProjectService(
private val project: Project,
private val cs: CoroutineScope,
) {
/** Project working directory sent as the `directory` parameter. */
val directory: String
get() = project.basePath ?: ""
}
@@ -0,0 +1,20 @@
package ai.kilocode.backend.app
/**
* Abstraction over the CLI process lifecycle.
*
* Production: [KiloBackendCliManager]. Tests: fake returning mock server port.
*/
interface CliServer {
sealed class State {
data class Ready(val port: Int, val password: String) : State()
data class Error(val message: String, val details: String? = null) : State()
}
var forceExtract: Boolean
fun process(): Process?
suspend fun init(): State
fun exited(proc: Process)
fun stop()
fun dispose()
}
@@ -0,0 +1,51 @@
package ai.kilocode.backend.app
import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
/**
* Full application lifecycle state, combining CLI transport connection
* status with data-loading progress.
*
* [ConnectionState] stays internal to [KiloConnectionService] for the
* transport layer. This sealed class is what the frontend observes.
*/
sealed class KiloAppState {
data object Disconnected : KiloAppState()
data object Connecting : KiloAppState()
data class Loading(val progress: LoadProgress) : KiloAppState()
data class Ready(val data: AppData) : KiloAppState()
data class Error(val message: String, val errors: List<LoadError> = emptyList()) : KiloAppState()
}
/**
* Tracks which global data fetches have completed during the [KiloAppState.Loading] phase.
*/
data class LoadProgress(
val config: Boolean = false,
val notifications: Boolean = false,
val profile: ProfileResult = ProfileResult.PENDING,
)
/** Outcome of the profile fetch. */
enum class ProfileResult { PENDING, LOADED, NOT_LOGGED_IN }
/**
* Error detail for a single resource that failed to load.
*/
data class LoadError(
val resource: String,
val status: Int? = null,
val detail: String? = null,
)
/**
* All global data that has been successfully loaded.
* Present only in [KiloAppState.Ready].
*/
data class AppData(
val profile: KiloProfile200Response?,
val config: Config,
val notifications: List<KiloNotifications200ResponseInner>,
)
@@ -0,0 +1,393 @@
package ai.kilocode.backend.app
import ai.kilocode.backend.util.IntellijLog
import ai.kilocode.backend.util.KiloLog
import ai.kilocode.jetbrains.api.client.DefaultApi
import ai.kilocode.jetbrains.api.infrastructure.ClientError
import ai.kilocode.jetbrains.api.infrastructure.ClientException
import ai.kilocode.jetbrains.api.infrastructure.ServerError
import ai.kilocode.jetbrains.api.infrastructure.ServerException
import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.rpc.dto.HealthDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.net.ConnectException
import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicReference
/**
* App-level orchestrator that owns the CLI server lifecycle and
* loads project-independent data after the connection is established.
*
* This is the single entry point for the CLI backend. The frontend
* reaches it via [KiloAppRpcApi][ai.kilocode.rpc.KiloAppRpcApi] RPC.
*
* All lifecycle operations ([connect], [restart], [reinstall], and
* internal reconnect) are serialized by a single [Mutex]. The owned
* [KiloBackendCliManager] and [KiloConnectionService] perform no
* internal synchronization — they rely on this mutex.
*
* After the CLI server connects, the app enters a [KiloAppState.Loading]
* phase. Config and notifications are required (retried up to 3×).
* Profile is optional — 401 (not logged in) is not an error.
*/
@Service(Service.Level.APP)
class KiloBackendAppService private constructor(
private val cs: CoroutineScope,
private val server: CliServer,
private val log: KiloLog,
) : Disposable {
/** IntelliJ service injection entry point. */
constructor(cs: CoroutineScope) : this(
cs,
KiloBackendCliManager(),
IntellijLog(KiloBackendAppService::class.java),
)
companion object {
private const val MAX_RETRIES = 3
private const val RETRY_DELAY_MS = 1000L
/** Test factory — no IntelliJ deps needed. */
internal fun create(
cs: CoroutineScope,
server: CliServer,
log: KiloLog,
) = KiloBackendAppService(cs, server, log)
}
private val mutex = Mutex()
private val connection = KiloConnectionService(cs, server, onReconnect = {
cs.launch { reconnect() }
}, log = log)
private var router: Job? = null
private var loader: Job? = null
private val _appState = MutableStateFlow<KiloAppState>(KiloAppState.Disconnected)
val appState: StateFlow<KiloAppState> = _appState.asStateFlow()
val events: SharedFlow<SseEvent> get() = connection.events
val api: DefaultApi? get() = connection.api
@Volatile var profile: KiloProfile200Response? = null
private set
@Volatile var config: Config? = null
private set
@Volatile var notifications: List<KiloNotifications200ResponseInner> = emptyList()
private set
suspend fun connect() {
mutex.withLock {
val current = _appState.value
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) return
connection.connect()
}
}
suspend fun restart() {
mutex.withLock {
clear()
connection.restart()
}
}
suspend fun reinstall() {
mutex.withLock {
clear()
connection.reinstall()
}
}
/** One-shot health check via the generated API client. */
suspend fun health(): HealthDto {
val client = api ?: throw IllegalStateException("Not connected")
val response = client.globalHealth()
return HealthDto(healthy = response.healthy, version = response.version)
}
private suspend fun reconnect() {
mutex.withLock {
val current = _appState.value
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) {
log.info("reconnect: already ${current::class.simpleName} — skipping")
return
}
log.info("reconnect: full restart under mutex")
connection.restart()
}
}
init {
cs.launch {
connection.state.collect { next ->
when (next) {
ConnectionState.Disconnected -> _appState.value = KiloAppState.Disconnected
ConnectionState.Connecting -> _appState.value = KiloAppState.Connecting
is ConnectionState.Connected -> load()
is ConnectionState.Error -> _appState.value = KiloAppState.Error(next.message)
}
}
}
}
/**
* Launch all project-independent data fetches in parallel.
*
* Config and notifications are required — retried up to [MAX_RETRIES] times.
* Profile is optional — 401 (not logged in) is fine.
*
* Progress is tracked via [LoadProgress] and emitted as [KiloAppState.Loading].
* On success, transitions to [KiloAppState.Ready].
* On failure of required data, transitions to [KiloAppState.Error].
*/
private fun load() {
loader?.cancel()
loader = cs.launch {
log.info("Loading global data")
val progress = AtomicReference(LoadProgress())
_appState.value = KiloAppState.Loading(progress.get())
val errors = mutableListOf<LoadError>()
try {
coroutineScope {
launch {
try {
val result = fetchProfile()
progress.updateAndGet { it.copy(profile = result) }
.also { _appState.value = KiloAppState.Loading(it) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
val err = LoadError(
resource = "profile",
status = (e as? ClientException)?.statusCode
?: (e as? ServerException)?.statusCode,
detail = e.message,
)
synchronized(errors) { errors.add(err) }
throw LoadFailure(err)
}
}
launch {
val result = fetchWithRetry("config") { fetchConfig() }
if (result.value != null) {
config = result.value
progress.updateAndGet { it.copy(config = true) }
.also { _appState.value = KiloAppState.Loading(it) }
} else {
val err = result.error!!
synchronized(errors) { errors.add(err) }
throw LoadFailure(err)
}
}
launch {
val result = fetchWithRetry("notifications") { fetchNotifications() }
if (result.value != null) {
notifications = result.value
progress.updateAndGet { it.copy(notifications = true) }
.also { _appState.value = KiloAppState.Loading(it) }
} else {
val err = result.error!!
synchronized(errors) { errors.add(err) }
throw LoadFailure(err)
}
}
}
_appState.value = KiloAppState.Ready(
AppData(
profile = profile,
config = config!!,
notifications = notifications,
)
)
log.info("Global data loaded — app is Ready")
ensureRouter()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
log.warn("Global data load failed: ${e.message}")
_appState.value = KiloAppState.Error(
message = "Failed to load required data",
errors = synchronized(errors) { errors.toList() },
)
}
}
}
private suspend fun fetchProfile(): ProfileResult {
val client = connection.api ?: return ProfileResult.NOT_LOGGED_IN
return try {
val response = client.kiloProfile()
profile = response
log.info("Profile: ${response.profile.email}")
ProfileResult.LOADED
} catch (e: ClientException) {
if (e.statusCode == 401) {
log.info("Profile: not logged in (401)")
return ProfileResult.NOT_LOGGED_IN
}
log.warn("Profile fetch failed: HTTP ${e.statusCode}", e)
throw e
} catch (e: Exception) {
log.warn("Profile fetch failed: ${e.message}", e)
throw e
}
}
private suspend fun fetchConfig(): FetchResult<Config> {
val client = connection.api
?: return FetchResult.fail("config", detail = "Not connected")
return try {
FetchResult.ok(client.globalConfigGet())
} catch (e: Exception) {
log.warn("Global config fetch failed: ${e.message}", e)
logResponseBody("config", e)
FetchResult.fail("config", e)
}
}
private suspend fun fetchNotifications(): FetchResult<List<KiloNotifications200ResponseInner>> {
val client = connection.api
?: return FetchResult.fail("notifications", detail = "Not connected")
return try {
FetchResult.ok(client.kiloNotifications())
} catch (e: Exception) {
log.warn("Notifications fetch failed: ${e.message}", e)
logResponseBody("notifications", e)
FetchResult.fail("notifications", e)
}
}
/**
* Dump the HTTP response body from a failed API call for debugging.
* The generated client wraps the response in [ClientException.response]
* or [ServerException.response] as a [ClientError] / [ServerError] with
* a `body` field containing the raw response string.
*/
private fun logResponseBody(resource: String, e: Exception) {
val body = when (e) {
is ClientException -> (e.response as? ClientError<*>)?.body
is ServerException -> (e.response as? ServerError<*>)?.body
else -> null
}
if (body != null) {
log.warn("$resource response body: $body")
}
}
private suspend fun <T> fetchWithRetry(
name: String,
block: suspend () -> FetchResult<T>,
): FetchResult<T> {
var last: FetchResult<T> = FetchResult.fail(name, detail = "No attempts made")
repeat(MAX_RETRIES) { attempt ->
last = block()
if (last.value != null) return last
if (attempt < MAX_RETRIES - 1) {
log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms")
delay(RETRY_DELAY_MS)
}
}
log.error("$name: all $MAX_RETRIES attempts failed")
return last
}
private fun ensureRouter() {
if (router?.isActive == true) return
router = cs.launch {
connection.events.collect { event ->
when (event.type) {
"global.config.updated" -> launch {
val result = fetchConfig()
if (result.value != null) {
config = result.value
val current = _appState.value
if (current is KiloAppState.Ready) {
_appState.value = current.copy(
data = current.data.copy(config = result.value)
)
}
}
}
}
}
}
}
private fun clear() {
loader?.cancel()
router?.cancel()
profile = null
config = null
notifications = emptyList()
_appState.value = KiloAppState.Disconnected
}
override fun dispose() {
clear()
connection.dispose()
server.dispose()
}
}
/**
* Result of a data fetch — either a value or an error with details.
*/
private data class FetchResult<T>(val value: T?, val error: LoadError?) {
companion object {
fun <T> ok(value: T) = FetchResult<T>(value, null)
fun <T> fail(resource: String, exception: Exception) = FetchResult<T>(
value = null,
error = LoadError(
resource = resource,
status = httpStatus(exception),
detail = httpDetail(exception),
),
)
fun <T> fail(resource: String, detail: String) = FetchResult<T>(
value = null,
error = LoadError(resource = resource, detail = detail),
)
private fun httpStatus(e: Exception): Int? =
when (e) {
is ClientException -> e.statusCode
is ServerException -> e.statusCode
else -> null
}
private fun httpDetail(e: Exception): String? =
when (e) {
is ClientException -> "HTTP ${e.statusCode}: ${e.message}"
is ServerException -> "HTTP ${e.statusCode}: ${e.message}"
is ConnectException -> "Connection refused: ${e.message}"
is SocketTimeoutException -> "Timeout: ${e.message}"
else -> e.message
}
}
}
/** Thrown when a required data fetch exhausts all retries. */
private class LoadFailure(val error: LoadError) : Exception("Failed to load ${error.resource}")
@@ -0,0 +1,283 @@
package ai.kilocode.backend.app
import ai.kilocode.backend.util.IntellijLog
import ai.kilocode.backend.util.KiloLog
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.util.SystemInfo
import com.intellij.util.system.CpuArch
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
/**
* Manages the Kilo CLI binary lifecycle.
*
* Extracts the bundled CLI from JAR resources into IntelliJ's system directory,
* spawns `kilo serve --port 0`, and exposes the result as [State].
*
* Concurrency is handled by the owning [KiloBackendAppService] — all public
* methods are called under its mutex so no internal synchronization is needed.
*/
class KiloBackendCliManager(
private val log: KiloLog = IntellijLog(KiloBackendCliManager::class.java),
) : CliServer {
companion object {
private const val STARTUP_TIMEOUT_MS = 30_000L
private const val KILL_TIMEOUT_SECONDS = 5L
private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""")
}
private var process: Process? = null
private var hook: Thread? = null
/**
* When true, the next [extractCli] call deletes and re-extracts the binary
* regardless of the size check. Reset to false after extraction.
*/
@Volatile
override var forceExtract = false
override fun process(): Process? = process
/**
* Extract the CLI binary (if needed) and spawn `kilo serve`.
*
* Must be called under [KiloBackendAppService]'s mutex — no internal
* synchronization is performed.
*/
override suspend fun init(): CliServer.State {
return try {
val path = extractCli()
log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
withTimeout(STARTUP_TIMEOUT_MS) {
spawn(path)
}
} catch (e: Exception) {
log.warn("CLI startup failed", e)
// If spawn started a process but timed out (or failed after start),
// kill the orphaned process so it doesn't leak.
process?.let { proc ->
log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})")
process = null
uninstall()
kill(proc, "startup failure cleanup")
}
CliServer.State.Error(
message = e.message ?: "Unknown error",
details = e.stackTraceToString(),
)
}
}
/**
* Mark the given process as exited and clear state.
* Called from the process monitor when the CLI process dies.
*/
override fun exited(proc: Process) {
if (process != proc) return
process = null
uninstall()
}
/**
* Kill the running CLI process and reset state so the next [init] spawns fresh.
*/
override fun stop() {
val proc = process ?: return
process = null
uninstall()
kill(proc, "stop()")
}
private fun extractCli(): File {
val platform = platform()
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
val resource = "cli/$platform/$exe"
val loader = javaClass.classLoader
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
if (forceExtract && target.exists()) {
log.info("Force re-extracting CLI binary — deleting ${target.absolutePath}")
target.delete()
forceExtract = false
}
val url = loader.getResource(resource)
?: throw IllegalStateException("CLI binary not found in JAR resources at $resource")
val size = url.openConnection().contentLengthLong
if (size >= 0 && target.exists() && target.length() == size) {
log.info("CLI binary up-to-date at ${target.absolutePath}")
return target
}
log.info("Extracting CLI binary to ${target.absolutePath}")
target.parentFile.mkdirs()
url.openStream().use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
if (!SystemInfo.isWindows) {
target.setExecutable(true)
}
return target
}
private suspend fun spawn(cli: File): CliServer.State =
withContext(Dispatchers.IO) {
val pwd = generatePassword()
val env = buildMap {
putAll(System.getenv())
put("KILO_SERVER_PASSWORD", pwd)
put("KILO_CLIENT", "jetbrains")
put("KILO_ENABLE_QUESTION_TOOL", "true")
put("KILO_PLATFORM", "jetbrains")
put("KILO_APP_NAME", "kilo-code")
}
val cmd = listOf(cli.absolutePath, "serve", "--port", "0")
val builder = ProcessBuilder(cmd)
builder.environment().clear()
builder.environment().putAll(env)
builder.redirectErrorStream(false)
log.info("Starting CLI: ${cmd.joinToString(" ")}")
log.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code")
val proc = builder.start()
log.info("CLI process started (pid=${proc.pid()})")
process = proc
install(proc)
val stderr = StringBuilder()
Thread({
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
reader.lineSequence().forEach { line ->
log.warn("CLI stderr: $line")
synchronized(stderr) { stderr.appendLine(line) }
}
}
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
for (line in reader.lineSequence()) {
log.info("CLI stdout: $line")
val match = PORT_REGEX.find(line)
if (match != null) {
val p = match.groupValues[1].toInt()
log.info("CLI server ready on port $p")
return@withContext CliServer.State.Ready(port = p, password = pwd)
}
if (!proc.isAlive) break
}
}
val code = proc.waitFor()
val details = synchronized(stderr) { stderr.toString().trim() }
process = null
uninstall()
CliServer.State.Error(
message = "CLI process exited with code $code before announcing a port",
details = details.ifEmpty { null },
)
}
override fun dispose() {
val proc = process ?: return
process = null
uninstall()
kill(proc, "Disposing")
}
private fun install(proc: Process) {
uninstall()
val next = Thread({
log.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})")
kill(proc, "Shutdown hook", wait = false)
}, "kilo-cli-shutdown")
val ok = runCatching {
Runtime.getRuntime().addShutdownHook(next)
}
if (ok.isFailure) {
log.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull())
return
}
hook = next
}
private fun uninstall() {
val curr = hook ?: return
hook = null
val ok = runCatching {
Runtime.getRuntime().removeShutdownHook(curr)
}
if (ok.isFailure) {
log.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}")
}
}
private fun kill(proc: Process, source: String, wait: Boolean = true) {
log.info("$source — killing CLI process tree (pid ${proc.pid()})")
children(proc).forEach { it.destroy() }
proc.destroy()
if (!wait) return
if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
log.warn("CLI process did not exit after SIGTERM, sending SIGKILL")
children(proc).forEach { it.destroyForcibly() }
proc.destroyForcibly()
}
}
private fun children(proc: Process): List<ProcessHandle> {
return proc.toHandle().descendants().toList().asReversed()
}
private fun platform(): String {
val os = when {
SystemInfo.isMac -> "darwin"
SystemInfo.isLinux -> "linux"
SystemInfo.isWindows -> "windows"
else -> throw IllegalStateException(
"Unsupported OS: ${
System.getProperty(
"os.name"
)
}"
)
}
val arch = when (CpuArch.CURRENT) {
CpuArch.ARM64 -> "arm64"
CpuArch.X86_64 -> "x64"
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
}
return "$os-$arch"
}
private fun generatePassword(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -1,12 +1,9 @@
package ai.kilocode.server
package ai.kilocode.backend.app
import ai.kilocode.backend.util.IntellijLog
import ai.kilocode.backend.KiloBackendHttpClients
import ai.kilocode.backend.util.KiloLog
import ai.kilocode.jetbrains.api.client.DefaultApi
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.ConnectionStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -17,8 +14,6 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
@@ -41,7 +36,8 @@ sealed class ConnectionState {
data class SseEvent(val type: String, val data: String)
/**
* App-level service managing the CLI server connection.
* Manages the CLI server connection: SSE stream, health polling, heartbeat,
* and automatic reconnection.
*
* Uses two separate OkHttp clients mirroring the VS Code architecture:
* - [apiClient]: no call/read timeout used for the generated API client and SSE
@@ -49,12 +45,21 @@ data class SseEvent(val type: String, val data: String)
*
* The generated [DefaultApi] is configured with [apiClient] and exposed via [api]
* for typed access to all CLI server endpoints.
*
* Concurrency is handled by the owning [KiloBackendAppService] `connect`,
* `restart`, and `reinstall` are called under its mutex. Internal reconnect
* attempts delegate back to the owner via [onReconnect].
*
* Not a service owned and instantiated by [KiloBackendAppService].
*/
@Service(Service.Level.APP)
class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
class KiloConnectionService(
private val cs: CoroutineScope,
private val server: CliServer,
private val onReconnect: () -> Unit,
private val log: KiloLog = IntellijLog(KiloConnectionService::class.java),
) {
companion object {
private val LOG = Logger.getInstance(KiloConnectionService::class.java)
private const val HEARTBEAT_TIMEOUT_MS = 15_000L
private const val HEALTH_POLL_INTERVAL_MS = 10_000L
private const val RECONNECT_DELAY_MS = 250L
@@ -78,36 +83,47 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
private val source = AtomicReference<EventSource?>(null)
private val lastEvent = AtomicLong(0L)
@Volatile private var disposed = false
private var heartbeatJob: Job? = null
private var healthJob: Job? = null
private var processJob: Job? = null
private var reconnectJob: Job? = null
fun stream() = state.map(::dto).distinctUntilChanged()
/**
* Open a connection to the CLI server.
*
* Called under [KiloBackendAppService]'s mutex no internal guard needed.
*/
suspend fun connect() {
if (_state.value is ConnectionState.Connected || _state.value is ConnectionState.Connecting) return
open()
}
/** Kill the CLI process and restart it. Tears down all connections first. */
/**
* Kill the CLI process and restart it. Tears down all connections first.
*
* Called under [KiloBackendAppService]'s mutex.
*/
suspend fun restart() {
LOG.info("restart: initiated — tearing down current connection")
log.info("restart: initiated — tearing down current connection")
teardown()
LOG.info("restart: teardown complete — spawning new CLI process")
log.info("restart: teardown complete — spawning new CLI process")
open()
LOG.info("restart: open() returned — CLI process started")
log.info("restart: open() returned — CLI process started")
}
/** Kill the CLI process, re-extract the binary from JAR, and restart. */
/**
* Kill the CLI process, re-extract the binary from JAR, and restart.
*
* Called under [KiloBackendAppService]'s mutex.
*/
suspend fun reinstall() {
LOG.info("reinstall: initiated — tearing down current connection")
log.info("reinstall: initiated — tearing down current connection")
teardown()
LOG.info("reinstall: teardown complete — setting forceExtract flag")
service<ServerManager>().forceExtract = true
LOG.info("reinstall: spawning new CLI process (binary will be re-extracted)")
log.info("reinstall: teardown complete — setting forceExtract flag")
server.forceExtract = true
log.info("reinstall: spawning new CLI process (binary will be re-extracted)")
open()
LOG.info("reinstall: open() returned — CLI process started with fresh binary")
log.info("reinstall: open() returned — CLI process started with fresh binary")
}
/**
@@ -116,20 +132,20 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
* Order matters reconnect/health/heartbeat jobs are cancelled first so they
* cannot race with the SSE close or process kill.
*/
private suspend fun teardown() {
LOG.info("teardown: cancelling background jobs (reconnect, heartbeat, health, process)")
private fun teardown() {
log.info("teardown: cancelling background jobs (reconnect, heartbeat, health, process)")
reconnectJob?.cancel()
heartbeatJob?.cancel()
healthJob?.cancel()
processJob?.cancel()
LOG.info("teardown: closing SSE event source")
log.info("teardown: closing SSE event source")
source.getAndSet(null)?.cancel()
LOG.info("teardown: shutting down OkHttp clients")
log.info("teardown: shutting down OkHttp clients")
close()
setState(ConnectionState.Disconnected)
LOG.info("teardown: killing CLI process via ServerManager.stop()")
service<ServerManager>().stop()
LOG.info("teardown: complete")
log.info("teardown: killing CLI process via ServerManager.stop()")
server.stop()
log.info("teardown: complete")
}
private suspend fun open() {
@@ -140,21 +156,20 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
setState(ConnectionState.Connecting)
val cli = service<ServerManager>()
val result = cli.init()
val result = server.init()
if (result is ServerManager.ServerState.Error) {
if (result is CliServer.State.Error) {
setState(ConnectionState.Error(result.message))
return
}
val ready = result as ServerManager.ServerState.Ready
val ready = result as CliServer.State.Ready
port = ready.port
password = ready.password
// Create dual OkHttp clients (bundled — no IntelliJ platform deps)
val ac = KiloHttpClients.api(password)
val hc = KiloHttpClients.health(password)
val ac = KiloBackendHttpClients.api(password)
val hc = KiloBackendHttpClients.health(password)
apiClient = ac
healthClient = hc
@@ -164,7 +179,7 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
startSse()
startHeartbeatWatcher()
healthJob = healthLoop()
cli.process()?.let { proc ->
server.process()?.let { proc ->
processJob = monitorProcess(proc)
}
}
@@ -183,12 +198,12 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
.build()
)
source.set(factory.newEventSource(request, listener))
LOG.info("SSE: connecting to port $port")
log.info("SSE: connecting to port $port")
}
private val listener = object : EventSourceListener() {
override fun onOpen(src: EventSource, response: Response) {
LOG.info("SSE: connected")
log.info("SSE: connected")
setState(ConnectionState.Connected(port, password))
lastEvent.set(System.currentTimeMillis())
}
@@ -200,40 +215,45 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
}
override fun onClosed(src: EventSource) {
LOG.info("SSE: stream closed — scheduling reconnect")
log.info("SSE: stream closed — scheduling reconnect")
scheduleReconnect()
}
override fun onFailure(src: EventSource, t: Throwable?, response: Response?) {
if (t != null) {
LOG.warn("SSE: failure (${t.message}) — scheduling reconnect")
log.warn("SSE: failure (${t.message}) — scheduling reconnect")
} else {
LOG.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect")
log.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect")
}
setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})"))
scheduleReconnect()
}
}
/**
* Schedule a reconnect attempt. If the CLI process is still alive,
* just reconnect SSE. Otherwise, delegate to [onReconnect] which
* goes through [KiloBackendAppService]'s mutex for a full restart.
*/
private fun scheduleReconnect() {
if (disposed) return
if (reconnectJob?.isActive == true) return
reconnectJob = cs.launch {
delay(RECONNECT_DELAY_MS)
if (!isActive) return@launch
val cli = service<ServerManager>()
val proc = cli.process()
val proc = server.process()
if (proc?.isAlive == true) {
LOG.info("SSE: reconnecting")
log.info("SSE: reconnecting (process alive)")
source.getAndSet(null)?.cancel()
setState(ConnectionState.Connecting)
startSse()
return@launch
}
LOG.warn("CLI process not running — restarting")
open()
log.warn("CLI process not running — delegating full reconnect to AppService")
onReconnect()
}
}
@@ -245,7 +265,7 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
if (_state.value !is ConnectionState.Connected) continue
val elapsed = System.currentTimeMillis() - lastEvent.get()
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
LOG.warn("SSE: heartbeat timeout (${elapsed}ms) — forcing reconnect")
log.warn("SSE: heartbeat timeout (${elapsed}ms) — forcing reconnect")
source.getAndSet(null)?.cancel()
scheduleReconnect()
}
@@ -259,7 +279,7 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
if (_state.value !is ConnectionState.Connected) continue
val ok = checkHealth()
if (!ok && _state.value is ConnectionState.Connected) {
LOG.warn("Health check failed — forcing SSE reconnect")
log.warn("Health check failed — forcing SSE reconnect")
source.getAndSet(null)?.cancel()
scheduleReconnect()
}
@@ -274,16 +294,16 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
.build()
http.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
LOG.info("Health check exception: ${e.message}")
log.info("Health check exception: ${e.message}")
false
}
}
private fun monitorProcess(proc: Process) = cs.launch(Dispatchers.IO) {
proc.waitFor()
service<ServerManager>().exited(proc)
server.exited(proc)
val code = proc.exitValue()
LOG.warn("CLI process exited with code $code")
log.warn("CLI process exited with code $code")
source.getAndSet(null)?.cancel()
setState(ConnectionState.Error("CLI process exited with code $code"))
scheduleReconnect()
@@ -291,35 +311,29 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
private fun close() {
api = null
apiClient?.let { KiloHttpClients.shutdown(it) }
apiClient?.let { KiloBackendHttpClients.shutdown(it) }
apiClient = null
healthClient?.let { KiloHttpClients.shutdown(it) }
healthClient?.let { KiloBackendHttpClients.shutdown(it) }
healthClient = null
}
private fun setState(next: ConnectionState) {
if (disposed) return
_state.value = next
}
private fun dto(state: ConnectionState): ConnectionStateDto =
when (state) {
ConnectionState.Disconnected -> ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
ConnectionState.Connecting -> ConnectionStateDto(ConnectionStatusDto.CONNECTING)
is ConnectionState.Connected -> ConnectionStateDto(ConnectionStatusDto.CONNECTED)
is ConnectionState.Error -> ConnectionStateDto(ConnectionStatusDto.ERROR, state.message)
}
private fun extractType(data: String): String =
internal fun extractType(data: String): String =
TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown"
override fun dispose() {
fun dispose() {
disposed = true
source.getAndSet(null)?.cancel()
heartbeatJob?.cancel()
healthJob?.cancel()
processJob?.cancel()
reconnectJob?.cancel()
close()
setState(ConnectionState.Disconnected)
LOG.info("KiloConnectionService disposed")
_state.value = ConnectionState.Disconnected
log.info("KiloConnectionService disposed")
}
}
@@ -0,0 +1,74 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.backend.rpc
import ai.kilocode.backend.app.KiloAppState
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.backend.app.LoadError
import ai.kilocode.backend.app.LoadProgress
import ai.kilocode.backend.app.ProfileResult
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.LoadProgressDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import com.intellij.openapi.components.service
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Backend implementation of [KiloAppRpcApi].
*
* Delegates directly to the app-level [KiloBackendAppService] —
* no project resolution needed since all operations are app-scoped.
*/
class KiloAppRpcApiImpl : KiloAppRpcApi {
private val app: KiloBackendAppService get() = service()
override suspend fun connect() = app.connect()
override suspend fun state(): Flow<KiloAppStateDto> =
app.appState.map(::dto).distinctUntilChanged()
override suspend fun health(): HealthDto = app.health()
override suspend fun restart() = app.restart()
override suspend fun reinstall() = app.reinstall()
private fun dto(state: KiloAppState): KiloAppStateDto =
when (state) {
KiloAppState.Disconnected -> KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
KiloAppState.Connecting -> KiloAppStateDto(KiloAppStatusDto.CONNECTING)
is KiloAppState.Loading -> KiloAppStateDto(
status = KiloAppStatusDto.LOADING,
progress = progress(state.progress),
)
is KiloAppState.Ready -> KiloAppStateDto(KiloAppStatusDto.READY)
is KiloAppState.Error -> KiloAppStateDto(
status = KiloAppStatusDto.ERROR,
error = state.message,
errors = state.errors.map(::error),
)
}
private fun progress(p: LoadProgress) = LoadProgressDto(
config = p.config,
notifications = p.notifications,
profile = when (p.profile) {
ProfileResult.PENDING -> ProfileStatusDto.PENDING
ProfileResult.LOADED -> ProfileStatusDto.LOADED
ProfileResult.NOT_LOGGED_IN -> ProfileStatusDto.NOT_LOGGED_IN
},
)
private fun error(e: LoadError) = LoadErrorDto(
resource = e.resource,
status = e.status,
detail = e.detail,
)
}
@@ -0,0 +1,15 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.backend.rpc
import ai.kilocode.rpc.KiloAppRpcApi
import com.intellij.platform.rpc.backend.RemoteApiProvider
import fleet.rpc.remoteApiDescriptor
internal class KiloAppRpcApiProvider : RemoteApiProvider {
override fun RemoteApiProvider.Sink.remoteApis() {
remoteApi(remoteApiDescriptor<KiloAppRpcApi>()) {
KiloAppRpcApiImpl()
}
}
}
@@ -0,0 +1,20 @@
package ai.kilocode.backend.util
import com.intellij.openapi.diagnostic.Logger
interface KiloLog {
fun info(msg: String)
fun warn(msg: String, t: Throwable? = null)
fun error(msg: String, t: Throwable? = null)
}
internal class IntellijLog(cls: Class<*>) : KiloLog {
private val delegate = Logger.getInstance(cls)
override fun info(msg: String) = delegate.info(msg)
override fun warn(msg: String, t: Throwable?) {
if (t != null) delegate.warn(msg, t) else delegate.warn(msg)
}
override fun error(msg: String, t: Throwable?) {
if (t != null) delegate.error(msg, t) else delegate.error(msg)
}
}
@@ -1,42 +0,0 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.server.KiloProjectService
import com.intellij.openapi.components.service
import com.intellij.platform.project.ProjectId
import com.intellij.platform.project.findProjectOrNull
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
/**
* Backend implementation of [KiloProjectRpcApi].
*
* Resolves the project from the [ProjectId] passed by the frontend
* and delegates to the project-level [KiloProjectService].
*/
class KiloProjectRpcApiImpl : KiloProjectRpcApi {
private fun resolve(id: ProjectId): KiloProjectService {
val project = id.findProjectOrNull()
?: throw IllegalStateException("Project not found for id: $id")
return project.service()
}
override suspend fun connect(projectId: ProjectId) =
resolve(projectId).connect()
override suspend fun state(projectId: ProjectId): Flow<ConnectionStateDto> =
resolve(projectId).stream()
override suspend fun health(projectId: ProjectId): HealthDto =
resolve(projectId).health()
override suspend fun restart(projectId: ProjectId) =
resolve(projectId).restart()
override suspend fun reinstall(projectId: ProjectId) =
resolve(projectId).reinstall()
}
@@ -1,14 +0,0 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode.rpc
import com.intellij.platform.rpc.backend.RemoteApiProvider
import fleet.rpc.remoteApiDescriptor
internal class KiloProjectRpcApiProvider : RemoteApiProvider {
override fun RemoteApiProvider.Sink.remoteApis() {
remoteApi(remoteApiDescriptor<KiloProjectRpcApi>()) {
KiloProjectRpcApiImpl()
}
}
}
@@ -1,80 +0,0 @@
package ai.kilocode.server
import ai.kilocode.jetbrains.api.client.DefaultApi
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.HealthDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.project.Project
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
/**
* Project-level backend service that delegates to the app-level
* [KiloConnectionService] and scopes CLI API calls to this project's
* working directory.
*
* The VS Code extension likewise scopes calls via `x-kilo-directory`.
* In the JetBrains plugin this is achieved by passing the directory
* parameter to each generated API method.
*/
@Service(Service.Level.PROJECT)
class KiloProjectService(
private val project: Project,
private val cs: CoroutineScope,
) {
companion object {
private val LOG = Logger.getInstance(KiloProjectService::class.java)
}
private val connection: KiloConnectionService
get() = service()
/** Project working directory sent as the `directory` parameter. */
val directory: String
get() = project.basePath ?: ""
/** Connection state (delegates to app-level service). */
val state: StateFlow<ConnectionState>
get() = connection.state
/** Connection state mapped to DTO for RPC transport. */
fun stream() = connection.stream()
/** Ensure the CLI backend is running and connected. */
suspend fun connect() = connection.connect()
/** Kill the CLI process and restart it. */
suspend fun restart() = connection.restart()
/** Kill the CLI process, re-extract the binary, and restart. */
suspend fun reinstall() = connection.reinstall()
/**
* The generated API client, or null when disconnected.
*
* Callers should pass [directory] to each API method's `directory`
* parameter to scope requests to this project.
*/
val api: DefaultApi?
get() = connection.api
/**
* One-shot health check via the generated API client.
* Returns [HealthDto] or throws if not connected / server unreachable.
*/
suspend fun health(): HealthDto {
val client = api
if (client == null) {
LOG.warn("health: API client is null — not connected")
throw IllegalStateException("Not connected")
}
LOG.info("health: calling /global/health")
val response = client.globalHealth()
LOG.info("health: version=${response.version}")
return HealthDto(healthy = true, version = response.version)
}
}
@@ -1,298 +0,0 @@
package ai.kilocode.server
import com.intellij.openapi.Disposable
import com.intellij.openapi.application.PathManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.util.SystemInfo
import com.intellij.util.system.CpuArch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
/**
* Application-level service that manages the Kilo CLI binary lifecycle.
*
* Extracts the bundled CLI from JAR resources into IntelliJ's system directory,
* spawns `kilo serve --port 0`, and exposes the result as [ServerState].
*
* All concurrent callers of [init] share the same startup flow — only one
* CLI process is ever spawned.
*/
@Service(Service.Level.APP)
class ServerManager(private val cs: CoroutineScope) : Disposable {
sealed class ServerState {
data class Ready(val port: Int, val password: String) : ServerState()
data class Error(val message: String, val details: String? = null) :
ServerState()
}
companion object {
private val LOG = Logger.getInstance(ServerManager::class.java)
private const val STARTUP_TIMEOUT_MS = 30_000L
private const val KILL_TIMEOUT_SECONDS = 5L
private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""")
}
private val mutex = Mutex()
private var pending: Deferred<ServerState>? = null
private var process: Process? = null
private var hook: Thread? = null
/**
* When true, the next [extractCli] call deletes and re-extracts the binary
* regardless of the size check. Reset to false after extraction.
*/
@Volatile
var forceExtract = false
fun process(): Process? = process
suspend fun init(): ServerState {
val wait = mutex.withLock {
val curr = pending
if (curr != null && (!curr.isCompleted || process?.isAlive == true)) {
return@withLock curr
}
cs.async(Dispatchers.IO) { start() }.also {
pending = it
}
}
return wait.await()
}
suspend fun exited(proc: Process) {
mutex.withLock {
if (process != proc) return@withLock
process = null
pending = null
uninstall()
}
}
/** Kill the running CLI process and reset state so the next [init] spawns fresh. */
suspend fun stop() {
mutex.withLock {
val proc = process ?: return@withLock
process = null
pending = null
uninstall()
kill(proc, "stop()")
}
}
private suspend fun start(): ServerState {
return try {
val path = extractCli()
LOG.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
withTimeout(STARTUP_TIMEOUT_MS) {
spawn(path)
}
} catch (e: Exception) {
LOG.warn("CLI startup failed", e)
ServerState.Error(
message = e.message ?: "Unknown error",
details = e.stackTraceToString(),
)
}
}
private fun extractCli(): File {
val platform = platform()
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
val resource = "cli/$platform/$exe"
val loader = javaClass.classLoader
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
if (forceExtract && target.exists()) {
LOG.info("Force re-extracting CLI binary — deleting ${target.absolutePath}")
target.delete()
forceExtract = false
}
val url = loader.getResource(resource)
?: throw IllegalStateException("CLI binary not found in JAR resources at $resource")
val size = url.openConnection().contentLengthLong
if (size >= 0 && target.exists() && target.length() == size) {
LOG.info("CLI binary up-to-date at ${target.absolutePath}")
return target
}
LOG.info("Extracting CLI binary to ${target.absolutePath}")
target.parentFile.mkdirs()
url.openStream().use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}
if (!SystemInfo.isWindows) {
target.setExecutable(true)
}
return target
}
private suspend fun spawn(cli: File): ServerState =
withContext(Dispatchers.IO) {
val pwd = generatePassword()
val env = buildMap {
putAll(System.getenv())
put("KILO_SERVER_PASSWORD", pwd)
put("KILO_CLIENT", "jetbrains")
put("KILO_ENABLE_QUESTION_TOOL", "true")
put("KILO_PLATFORM", "jetbrains")
put("KILO_APP_NAME", "kilo-code")
}
val cmd = listOf(cli.absolutePath, "serve", "--port", "0")
val builder = ProcessBuilder(cmd)
builder.environment().clear()
builder.environment().putAll(env)
builder.redirectErrorStream(false)
LOG.info("Starting CLI: ${cmd.joinToString(" ")}")
LOG.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code")
val proc = builder.start()
LOG.info("CLI process started (pid=${proc.pid()})")
process = proc
install(proc)
val stderr = StringBuilder()
Thread({
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
reader.lineSequence().forEach { line ->
LOG.warn("CLI stderr: $line")
synchronized(stderr) { stderr.appendLine(line) }
}
}
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
for (line in reader.lineSequence()) {
LOG.info("CLI stdout: $line")
val match = PORT_REGEX.find(line)
if (match != null) {
val p = match.groupValues[1].toInt()
LOG.info("CLI server ready on port $p")
return@withContext ServerState.Ready(port = p, password = pwd)
}
if (!proc.isAlive) break
}
}
val code = proc.waitFor()
val details = synchronized(stderr) { stderr.toString().trim() }
process = null
uninstall()
ServerState.Error(
message = "CLI process exited with code $code before announcing a port",
details = details.ifEmpty { null },
)
}
override fun dispose() {
val proc = process ?: return
process = null
pending = null
uninstall()
kill(proc, "Disposing")
}
private fun install(proc: Process) {
uninstall()
val next = Thread({
LOG.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})")
kill(proc, "Shutdown hook", wait = false)
}, "kilo-cli-shutdown")
val ok = runCatching {
Runtime.getRuntime().addShutdownHook(next)
}
if (ok.isFailure) {
LOG.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull())
return
}
hook = next
}
private fun uninstall() {
val curr = hook ?: return
hook = null
val ok = runCatching {
Runtime.getRuntime().removeShutdownHook(curr)
}
if (ok.isFailure) {
LOG.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}")
}
}
private fun kill(proc: Process, source: String, wait: Boolean = true) {
LOG.info("$source — killing CLI process tree (pid ${proc.pid()})")
children(proc).forEach { it.destroy() }
proc.destroy()
if (!wait) return
if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
LOG.warn("CLI process did not exit after SIGTERM, sending SIGKILL")
children(proc).forEach { it.destroyForcibly() }
proc.destroyForcibly()
}
}
private fun children(proc: Process): List<ProcessHandle> {
return proc.toHandle().descendants().toList().asReversed()
}
private fun platform(): String {
val os = when {
SystemInfo.isMac -> "darwin"
SystemInfo.isLinux -> "linux"
SystemInfo.isWindows -> "windows"
else -> throw IllegalStateException(
"Unsupported OS: ${
System.getProperty(
"os.name"
)
}"
)
}
val arch = when (CpuArch.CURRENT) {
CpuArch.ARM64 -> "arm64"
CpuArch.X86_64 -> "x64"
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
}
return "$os-$arch"
}
private fun generatePassword(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) }
}
}
@@ -6,6 +6,6 @@
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.rpc.KiloProjectRpcApiProvider"/>
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.backend.rpc.KiloAppRpcApiProvider"/>
</extensions>
</idea-plugin>
@@ -0,0 +1,164 @@
package ai.kilocode.backend
import ai.kilocode.jetbrains.api.infrastructure.Serializer
import ai.kilocode.jetbrains.api.model.Config
import ai.kilocode.jetbrains.api.model.GlobalHealth200Response
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInnerAction
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
import ai.kilocode.jetbrains.api.model.KiloProfile200ResponseBalance
import ai.kilocode.jetbrains.api.model.KiloProfile200ResponseProfile
import ai.kilocode.jetbrains.api.model.KiloProfile200ResponseProfileOrganizationsInner
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Verifies that the generated API model classes serialize/deserialize
* correctly using the same [Serializer.kotlinxSerializationJson] instance
* that the production [DefaultApi] client uses.
*/
class ApiModelSerializationTest {
private val json = Serializer.kotlinxSerializationJson
@Test
fun `GlobalHealth200Response roundtrip`() {
val src = """{"healthy":true,"version":"2.1.0"}"""
val obj = json.decodeFromString<GlobalHealth200Response>(src)
assertTrue(obj.healthy)
assertEquals("2.1.0", obj.version)
val back = json.encodeToString(GlobalHealth200Response.serializer(), obj)
assertTrue(back.contains(""""healthy":true"""))
assertTrue(back.contains(""""version":"2.1.0""""))
}
@Test
fun `Config deserializes minimal JSON`() {
val obj = json.decodeFromString<Config>("""{}""")
assertNull(obj.model)
assertNull(obj.provider)
assertNull(obj.mcp)
}
@Test
fun `Config deserializes with known fields`() {
val src = """{"model":"claude-4","username":"alice"}"""
val obj = json.decodeFromString<Config>(src)
assertEquals("claude-4", obj.model)
assertEquals("alice", obj.username)
}
@Test
fun `Config ignores unknown fields`() {
val src = """{"model":"test","totally_new_field":"value","nested":{"a":1}}"""
val obj = json.decodeFromString<Config>(src)
assertEquals("test", obj.model)
}
@Test
fun `KiloNotifications200ResponseInner with action`() {
val src = """{
"id": "notif-1",
"title": "Update available",
"message": "Version 3.0 is out",
"action": {"actionText": "Update now", "actionURL": "https://example.com/update"}
}"""
val obj = json.decodeFromString<KiloNotifications200ResponseInner>(src)
assertEquals("notif-1", obj.id)
assertEquals("Update available", obj.title)
assertEquals("Version 3.0 is out", obj.message)
assertNotNull(obj.action)
assertEquals("Update now", obj.action!!.actionText)
assertEquals("https://example.com/update", obj.action!!.actionURL)
}
@Test
fun `KiloNotifications200ResponseInner without action`() {
val src = """{"id":"n2","title":"Info","message":"Hello"}"""
val obj = json.decodeFromString<KiloNotifications200ResponseInner>(src)
assertEquals("n2", obj.id)
assertNull(obj.action)
assertNull(obj.showIn)
}
@Test
fun `KiloNotifications200ResponseInner with showIn and suggestModelId`() {
val src = """{
"id": "n3",
"title": "Try new model",
"message": "Check it out",
"showIn": ["cli", "vscode"],
"suggestModelId": "claude-4"
}"""
val obj = json.decodeFromString<KiloNotifications200ResponseInner>(src)
assertEquals(listOf("cli", "vscode"), obj.showIn)
assertEquals("claude-4", obj.suggestModelId)
}
@Test
fun `empty notifications array`() {
val list = json.decodeFromString<List<KiloNotifications200ResponseInner>>("[]")
assertTrue(list.isEmpty())
}
@Test
fun `KiloProfile200Response with balance`() {
val src = """{
"profile": {"email": "user@test.com", "name": "User"},
"balance": {"balance": 42.5},
"currentOrgId": "org-1"
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
assertEquals("user@test.com", obj.profile.email)
assertEquals("User", obj.profile.name)
assertNotNull(obj.balance)
assertEquals(42.5, obj.balance!!.balance)
assertEquals("org-1", obj.currentOrgId)
}
@Test
fun `KiloProfile200Response with null balance`() {
val src = """{
"profile": {"email": "user@test.com"},
"balance": null,
"currentOrgId": null
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
assertEquals("user@test.com", obj.profile.email)
assertNull(obj.balance)
assertNull(obj.currentOrgId)
}
@Test
fun `KiloProfile200Response with organizations`() {
val src = """{
"profile": {
"email": "user@test.com",
"organizations": [
{"id": "org-1", "name": "Acme", "role": "admin"},
{"id": "org-2", "name": "Beta", "role": "member"}
]
},
"balance": null,
"currentOrgId": "org-1"
}"""
val obj = json.decodeFromString<KiloProfile200Response>(src)
assertNotNull(obj.profile.organizations)
assertEquals(2, obj.profile.organizations!!.size)
assertEquals("Acme", obj.profile.organizations!![0].name)
assertEquals("admin", obj.profile.organizations!![0].role)
}
@Test
fun `Config roundtrip preserves model field`() {
val original = Config(model = "gpt-4o", username = "test")
val encoded = json.encodeToString(Config.serializer(), original)
val decoded = json.decodeFromString<Config>(encoded)
assertEquals("gpt-4o", decoded.model)
assertEquals("test", decoded.username)
}
}
@@ -0,0 +1,97 @@
package ai.kilocode.backend
import ai.kilocode.backend.app.AppData
import ai.kilocode.backend.app.KiloAppState
import ai.kilocode.backend.app.LoadError
import ai.kilocode.backend.app.LoadProgress
import ai.kilocode.backend.app.ProfileResult
import ai.kilocode.jetbrains.api.model.Config
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class KiloAppStateTest {
@Test
fun `default LoadProgress has all fields unloaded`() {
val progress = LoadProgress()
assertFalse(progress.config)
assertFalse(progress.notifications)
assertEquals(ProfileResult.PENDING, progress.profile)
}
@Test
fun `LoadProgress copy tracks individual completion`() {
val p1 = LoadProgress()
val p2 = p1.copy(config = true)
assertTrue(p2.config)
assertFalse(p2.notifications)
val p3 = p2.copy(notifications = true, profile = ProfileResult.LOADED)
assertTrue(p3.config)
assertTrue(p3.notifications)
assertEquals(ProfileResult.LOADED, p3.profile)
}
@Test
fun `KiloAppState sealed subtypes are distinct`() {
assertIs<KiloAppState.Disconnected>(KiloAppState.Disconnected)
assertIs<KiloAppState.Connecting>(KiloAppState.Connecting)
assertIs<KiloAppState.Loading>(KiloAppState.Loading(LoadProgress()))
assertIs<KiloAppState.Error>(KiloAppState.Error("fail"))
}
@Test
fun `KiloAppState Error with errors list`() {
val errors = listOf(
LoadError("config", status = 500, detail = "server error"),
LoadError("notifications", detail = "timeout"),
)
val state = KiloAppState.Error("Failed", errors = errors)
assertEquals(2, state.errors.size)
assertEquals("config", state.errors[0].resource)
assertEquals(500, state.errors[0].status)
assertNull(state.errors[1].status)
}
@Test
fun `AppData construction`() {
val cfg = Config(model = "test")
val data =
AppData(profile = null, config = cfg, notifications = emptyList())
assertNull(data.profile)
assertEquals(cfg, data.config)
assertTrue(data.notifications.isEmpty())
}
@Test
fun `LoadError with all fields`() {
val err = LoadError(
resource = "config",
status = 503,
detail = "Service Unavailable"
)
assertEquals("config", err.resource)
assertEquals(503, err.status)
assertEquals("Service Unavailable", err.detail)
}
@Test
fun `LoadError with minimal fields`() {
val err = LoadError(resource = "notifications")
assertNull(err.status)
assertNull(err.detail)
}
@Test
fun `ProfileResult enum values`() {
assertEquals(3, ProfileResult.entries.size)
assertTrue(
ProfileResult.entries.containsAll(
listOf(ProfileResult.PENDING, ProfileResult.LOADED, ProfileResult.NOT_LOGGED_IN)
))
}
}
@@ -0,0 +1,249 @@
package ai.kilocode.backend
import ai.kilocode.backend.app.KiloAppState
import ai.kilocode.backend.app.KiloBackendAppService
import ai.kilocode.backend.testing.FakeCliServer
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
class KiloBackendAppServiceTest {
private val mock = MockCliServer()
private val log = TestLog()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@AfterTest
fun tearDown() {
scope.cancel()
mock.close()
}
private fun create(): KiloBackendAppService =
KiloBackendAppService.create(scope, FakeCliServer(mock), log)
@Test
fun `full lifecycle reaches Ready`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val ready = svc.appState.value as KiloAppState.Ready
assertNotNull(ready.data.config)
assertNotNull(ready.data.notifications)
}
@Test
fun `config is loaded`() = runBlocking {
mock.config = """{"model":"claude-4","username":"testuser"}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertNotNull(svc.config)
assertEquals("claude-4", svc.config!!.model)
}
@Test
fun `profile is loaded when available`() = runBlocking {
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertNotNull(svc.profile)
assertEquals("alice@test.com", svc.profile!!.profile.email)
}
@Test
fun `profile 401 does not prevent Ready`() = runBlocking {
mock.profileStatus = 401
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
// Profile is null but we still reached Ready
assertNull(svc.profile)
assertIs<KiloAppState.Ready>(svc.appState.value)
}
@Test
fun `config failure retries then transitions to Error`() = runBlocking {
mock.configStatus = 500
mock.config = """{"error":"internal"}"""
val svc = create()
svc.connect()
withTimeout(15_000) {
svc.appState.first { it is KiloAppState.Error }
}
val err = svc.appState.value as KiloAppState.Error
assertEquals("Failed to load required data", err.message)
assertTrue(err.errors.any { it.resource == "config" })
}
@Test
fun `notifications failure transitions to Error`() = runBlocking {
mock.notificationsStatus = 500
mock.notifications = """{"error":"internal"}"""
val svc = create()
svc.connect()
withTimeout(15_000) {
svc.appState.first { it is KiloAppState.Error }
}
val err = svc.appState.value as KiloAppState.Error
assertTrue(err.errors.any { it.resource == "notifications" })
}
@Test
fun `connect when already Ready is no-op`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
// Second connect should not change state
svc.connect()
assertIs<KiloAppState.Ready>(svc.appState.value)
}
@Test
fun `health returns HealthDto when connected`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val dto = svc.health()
assertTrue(dto.healthy)
assertEquals("1.0.0", dto.version)
}
@Test
fun `health forwards healthy false from server`() = runBlocking {
mock.health = """{"healthy":false,"version":"1.0.0"}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
val dto = svc.health()
assertFalse(dto.healthy)
assertEquals("1.0.0", dto.version)
}
@Test
fun `profile 500 transitions to Error`() = runBlocking {
mock.profileStatus = 500
mock.profile = """{"error":"internal"}"""
val svc = create()
svc.connect()
withTimeout(15_000) {
svc.appState.first { it is KiloAppState.Error }
}
val err = svc.appState.value as KiloAppState.Error
assertTrue(err.errors.any { it.resource == "profile" })
}
@Test
fun `dispose transitions to Disconnected`() = runBlocking {
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
svc.dispose()
assertEquals(KiloAppState.Disconnected, svc.appState.value)
}
@Test
fun `loading tracks progress through Loading state`() = runBlocking {
val svc = create()
val states = mutableListOf<KiloAppState>()
val collector = scope.launch {
svc.appState.collect { states.add(it) }
}
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
collector.cancel()
// Should have passed through Loading at least once
assertTrue(states.any { it is KiloAppState.Loading })
// Should have reached Ready
assertTrue(states.any { it is KiloAppState.Ready })
}
@Test
fun `SSE config updated event refreshes config`() = runBlocking {
mock.config = """{"model":"initial"}"""
val svc = create()
svc.connect()
withTimeout(10_000) {
svc.appState.first { it is KiloAppState.Ready }
}
assertEquals("initial", svc.config?.model)
// Change the config response and push an SSE event
mock.config = """{"model":"updated"}"""
mock.awaitSseConnection()
mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""")
// Wait for config to be refreshed
withTimeout(5_000) {
while (svc.config?.model != "updated") {
delay(100)
}
}
assertEquals("updated", svc.config?.model)
}
}
@@ -0,0 +1,100 @@
package ai.kilocode.backend
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import java.util.Base64
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class KiloBackendHttpClientsTest {
@Test
fun `api client sends correct basic auth header`() {
val pwd = "secret123"
val server = MockWebServer()
server.enqueue(MockResponse().setBody("ok"))
server.start()
val client = KiloBackendHttpClients.api(pwd)
try {
val request = okhttp3.Request.Builder()
.url(server.url("/test"))
.build()
client.newCall(request).execute().use { response ->
assertEquals(200, response.code)
}
val recorded = server.takeRequest()
val expected = "Basic ${Base64.getEncoder().encodeToString("kilo:$pwd".toByteArray())}"
assertEquals(expected, recorded.getHeader("Authorization"))
} finally {
KiloBackendHttpClients.shutdown(client)
server.shutdown()
}
}
@Test
fun `api client has no call or read timeout`() {
val client = KiloBackendHttpClients.api("test")
try {
assertEquals(0, client.callTimeoutMillis)
assertEquals(0, client.readTimeoutMillis)
} finally {
KiloBackendHttpClients.shutdown(client)
}
}
@Test
fun `api client has connect timeout`() {
val client = KiloBackendHttpClients.api("test")
try {
assertTrue(client.connectTimeoutMillis > 0)
} finally {
KiloBackendHttpClients.shutdown(client)
}
}
@Test
fun `health client has short timeout`() {
val client = KiloBackendHttpClients.health("test")
try {
assertEquals(3000, client.callTimeoutMillis)
assertEquals(3000, client.connectTimeoutMillis)
} finally {
KiloBackendHttpClients.shutdown(client)
}
}
@Test
fun `health client sends correct basic auth header`() {
val pwd = "healthpwd"
val server = MockWebServer()
server.enqueue(MockResponse().setBody("ok"))
server.start()
val client = KiloBackendHttpClients.health(pwd)
try {
val request = okhttp3.Request.Builder()
.url(server.url("/global/health"))
.build()
client.newCall(request).execute().use { response ->
assertEquals(200, response.code)
}
val recorded = server.takeRequest()
val expected = "Basic ${Base64.getEncoder().encodeToString("kilo:$pwd".toByteArray())}"
assertEquals(expected, recorded.getHeader("Authorization"))
} finally {
KiloBackendHttpClients.shutdown(client)
server.shutdown()
}
}
@Test
fun `shutdown evicts connection pool`() {
val client = KiloBackendHttpClients.api("test")
KiloBackendHttpClients.shutdown(client)
assertEquals(0, client.connectionPool.connectionCount())
}
}
@@ -0,0 +1,176 @@
package ai.kilocode.backend
import ai.kilocode.backend.app.CliServer
import ai.kilocode.backend.app.ConnectionState
import ai.kilocode.backend.app.KiloConnectionService
import ai.kilocode.backend.testing.FakeCliServer
import ai.kilocode.backend.testing.MockCliServer
import ai.kilocode.backend.testing.TestLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertTrue
class KiloConnectionServiceTest {
private val mock = MockCliServer()
private val fake = FakeCliServer(mock)
private val log = TestLog()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@AfterTest
fun tearDown() {
scope.cancel()
mock.close()
}
@Test
fun `connect transitions to Connected`() = runBlocking {
val reconnects = AtomicInteger(0)
val svc = KiloConnectionService(
scope,
fake,
{ reconnects.incrementAndGet() },
log
)
svc.connect()
// Wait for SSE to connect, which triggers Connected
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
assertIs<ConnectionState.Connected>(svc.state.value)
}
@Test
fun `connect provides API client`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
assertTrue(svc.api != null)
}
@Test
fun `SSE events are emitted`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
// Use first{} on the flow to capture the event — avoids race with SharedFlow subscription
val deferred = scope.launch {
val event = svc.events.first { it.type == "global.config.updated" }
assertEquals("global.config.updated", event.type)
}
// Small delay to ensure the collector subscription is active
delay(200)
mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""")
withTimeout(5_000) {
deferred.join()
}
}
@Test
fun `SSE close triggers error state`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
// Close SSE stream
mock.closeSse()
// Should transition away from Connected (to Error or Connecting on reconnect)
withTimeout(5_000) {
svc.state.first { it !is ConnectionState.Connected }
}
}
@Test
fun `init error transitions to Error state`() = runBlocking {
val failing = object : CliServer {
override var forceExtract = false
override fun process(): Process? = null
override suspend fun init() = CliServer.State.Error("binary not found")
override fun exited(proc: Process) {}
override fun stop() {}
override fun dispose() {}
}
val svc = KiloConnectionService(scope, failing, {}, log)
svc.connect()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Error }
}
val err = svc.state.value as ConnectionState.Error
assertEquals("binary not found", err.message)
}
@Test
fun `reinstall sets forceExtract on server`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
svc.reinstall()
assertTrue(fake.forceExtract)
}
@Test
fun `extractType parses type from JSON data`() {
val svc = KiloConnectionService(scope, fake, {}, log)
val result = svc.extractType("""{"type":"global.config.updated","payload":{}}""")
assertEquals("global.config.updated", result)
}
@Test
fun `extractType returns unknown for missing type`() {
val svc = KiloConnectionService(scope, fake, {}, log)
assertEquals("unknown", svc.extractType("""{"data":"something"}"""))
}
@Test
fun `dispose transitions to Disconnected`() = runBlocking {
val svc = KiloConnectionService(scope, fake, {}, log)
svc.connect()
mock.awaitSseConnection()
withTimeout(5_000) {
svc.state.first { it is ConnectionState.Connected }
}
svc.dispose()
assertEquals(ConnectionState.Disconnected, svc.state.value)
}
}
@@ -0,0 +1,33 @@
package ai.kilocode.backend.testing
import ai.kilocode.backend.app.CliServer
/**
* Fake [CliServer] that delegates to a [MockCliServer] instead of
* spawning a real CLI process. Returns the mock's port and password
* from [init], and has no real process to monitor.
*
* [stop] shuts down the current server socket (restartable).
* [dispose] does final cleanup (not restartable).
*/
class FakeCliServer(private val mock: MockCliServer) : CliServer {
override var forceExtract = false
override fun process(): Process? = null
override suspend fun init(): CliServer.State =
CliServer.State.Ready(mock.start(), mock.password)
override fun exited(proc: Process) {}
/** Shutdown the server socket but keep the mock alive for restart. */
override fun stop() {
mock.shutdown()
}
/** Final cleanup. */
override fun dispose() {
mock.close()
}
}
@@ -0,0 +1,192 @@
package ai.kilocode.backend.testing
import java.io.BufferedWriter
import java.io.OutputStreamWriter
import java.net.ServerSocket
import java.net.Socket
import java.net.SocketException
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* Lightweight mock HTTP server simulating the Kilo CLI server.
*
* Handles REST endpoints with configurable JSON responses and provides
* full control over the SSE `/global/event` stream. Uses raw sockets
* so SSE connections can be held open and events pushed on demand.
*
* Supports restart: [start] can be called after [shutdown] to bind a new port.
* Call [close] for final cleanup (shuts down the thread pool).
*/
class MockCliServer : AutoCloseable {
val password = "test-password"
// Configurable REST responses — can be changed between requests
@Volatile var health = """{"healthy":true,"version":"1.0.0"}"""
@Volatile var config = """{"model":"test/model"}"""
@Volatile var notifications = "[]"
@Volatile var profile = """{"profile":{"email":"test@test.com","name":"Test"},"balance":null,"currentOrgId":null}"""
@Volatile var profileStatus = 200
@Volatile var configStatus = 200
@Volatile var notificationsStatus = 200
private val executor = Executors.newCachedThreadPool { r ->
Thread(r, "mock-cli-${Thread.currentThread().id}").apply { isDaemon = true }
}
private val closed = AtomicBoolean(false)
private var server: ServerSocket? = null
private val connections = ConcurrentLinkedQueue<Socket>()
private var port = 0
// SSE stream control — reset on each start()
@Volatile private var sseWriter: BufferedWriter? = null
private var sseLatch = CountDownLatch(1)
private var sseConnected = CountDownLatch(1)
/** Start (or restart) the mock server. Returns the port. */
fun start(): Int {
// Clean up any previous instance
shutdownServer()
sseLatch = CountDownLatch(1)
sseConnected = CountDownLatch(1)
sseWriter = null
val srv = ServerSocket(0)
server = srv
port = srv.localPort
executor.submit { acceptLoop(srv) }
return port
}
/** Stop the server socket and SSE without killing the thread pool. */
fun shutdown() {
shutdownServer()
}
/** Wait until an SSE client has connected (up to [timeout] ms). */
fun awaitSseConnection(timeout: Long = 5_000): Boolean =
sseConnected.await(timeout, TimeUnit.MILLISECONDS)
/** Push an SSE event to the connected client. */
fun pushEvent(type: String, data: String) {
val w = sseWriter ?: return
synchronized(w) {
w.write("event: $type\n")
w.write("data: $data\n")
w.write("\n")
w.flush()
}
}
/** Close the SSE stream to simulate a server-side disconnect. */
fun closeSse() {
val w = sseWriter
sseWriter = null
runCatching { w?.close() }
sseLatch.countDown()
}
/** Final cleanup — shuts down the thread pool. Not restartable after this. */
override fun close() {
if (!closed.compareAndSet(false, true)) return
shutdownServer()
executor.shutdownNow()
}
private fun shutdownServer() {
sseLatch.countDown()
val w = sseWriter
sseWriter = null
runCatching { w?.close() }
connections.forEach { runCatching { it.close() } }
connections.clear()
runCatching { server?.close() }
server = null
}
private fun acceptLoop(srv: ServerSocket) {
while (!closed.get() && !srv.isClosed) {
try {
val socket = srv.accept()
connections.add(socket)
executor.submit { handle(socket) }
} catch (_: SocketException) {
break
}
}
}
private fun handle(socket: Socket) {
try {
val input = socket.getInputStream().bufferedReader()
val line = input.readLine() ?: return
val parts = line.split(" ")
if (parts.size < 2) return
val path = parts[1]
// Read all headers
while (true) {
val header = input.readLine()
if (header.isNullOrBlank()) break
}
val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream()))
when {
path == "/global/health" -> respond(output, 200, health)
path == "/global/config" -> respond(output, configStatus, config)
path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications)
path.startsWith("/kilo/profile") -> {
if (profileStatus == 401) {
respond(output, 401, """{"message":"Unauthorized"}""")
} else {
respond(output, profileStatus, profile)
}
}
path == "/global/event" -> handleSse(output)
else -> respond(output, 404, """{"error":"Not found"}""")
}
} catch (_: SocketException) {
// Client disconnected
} catch (_: Exception) {
// Ignore errors in test mock
}
}
private fun respond(writer: BufferedWriter, status: Int, body: String) {
val phrase = when (status) {
200 -> "OK"
401 -> "Unauthorized"
404 -> "Not Found"
500 -> "Internal Server Error"
else -> "Error"
}
val bytes = body.toByteArray(Charsets.UTF_8)
writer.write("HTTP/1.1 $status $phrase\r\n")
writer.write("Content-Type: application/json\r\n")
writer.write("Content-Length: ${bytes.size}\r\n")
writer.write("Connection: close\r\n")
writer.write("\r\n")
writer.write(body)
writer.flush()
}
private fun handleSse(writer: BufferedWriter) {
writer.write("HTTP/1.1 200 OK\r\n")
writer.write("Content-Type: text/event-stream\r\n")
writer.write("Cache-Control: no-cache\r\n")
writer.write("Connection: keep-alive\r\n")
writer.write("\r\n")
writer.flush()
sseWriter = writer
sseConnected.countDown()
// Block until SSE is closed or server shuts down
sseLatch.await()
}
}
@@ -0,0 +1,27 @@
package ai.kilocode.backend.testing
import ai.kilocode.backend.util.KiloLog
/**
* Test logger that captures messages for assertions and prints to stdout.
*/
class TestLog : KiloLog {
val messages = mutableListOf<String>()
override fun info(msg: String) {
synchronized(messages) { messages.add("INFO: $msg") }
println("[test] INFO: $msg")
}
override fun warn(msg: String, t: Throwable?) {
synchronized(messages) { messages.add("WARN: $msg") }
println("[test] WARN: $msg")
t?.printStackTrace()
}
override fun error(msg: String, t: Throwable?) {
synchronized(messages) { messages.add("ERROR: $msg") }
System.err.println("[test] ERROR: $msg")
t?.printStackTrace()
}
}
@@ -0,0 +1,16 @@
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
gradlePlugin {
plugins {
create("build-tasks") {
id = "build-tasks"
implementationClass = "BuildTasksPlugin"
}
}
}
@@ -0,0 +1 @@
rootProject.name = "build-tasks"
@@ -0,0 +1,13 @@
import org.gradle.api.Plugin
import org.gradle.api.Project
/**
* Empty plugin entry point required by the `gradlePlugin {}` DSL so that
* `id("build-tasks")` resolves in `backend/build.gradle.kts`.
*
* The real value lives in the custom task classes this composite build
* provides: [FixGeneratedApiTask], [PrepareLocalCliTask], and [CheckCliTask].
*/
class BuildTasksPlugin : Plugin<Project> {
override fun apply(target: Project) {}
}
@@ -0,0 +1,53 @@
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import java.io.File
/**
* Verify that CLI binaries exist before packaging the plugin.
* In production mode, all platform binaries must be present.
* In dev mode, only the current platform binary is required.
*/
abstract class CheckCliTask : DefaultTask() {
@get:InputDirectory
@get:PathSensitive(PathSensitivity.RELATIVE)
abstract val dir: DirectoryProperty
@get:Input
abstract val production: Property<Boolean>
@get:Input
abstract val platforms: ListProperty<String>
@TaskAction
fun run() {
val resolved = dir.get().asFile
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 (production.get()) {
val missing = platforms.get().filter { platform ->
val d = File(resolved, platform)
val exe = if (platform.startsWith("windows")) "kilo.exe" else "kilo"
!File(d, 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."
)
}
}
}
}
@@ -0,0 +1,158 @@
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.TaskAction
import java.io.File
/**
* Post-process openapi-generator output to fix codegen bugs that produce
* uncompilable or runtime-broken Kotlin when using kotlinx.serialization.
*
* Fixes applied:
* 1. Boolean const enums — `const: true`/`false` produce broken single-value
* enums. Replaced with plain `kotlin.Boolean`.
* 2. Double parentheses — `HashMap<…>()()` trailing extra `()`.
* 3. Private Double constructor — `kotlin.Double("5000")` → `5000.0`.
* 4. Missing @Contextual on `kotlin.Any` — kotlinx.serialization can't
* serialize `Any` without it.
* 5. Nullable body in ApiClient — OkHttp's `response.body` is nullable.
* 6. AnySerializer — registers a contextual `KSerializer<Any>` backed by
* `JsonElement` for dynamic JSON values.
* 7. Empty anyOf wrappers — `anyOf` unions that generate empty classes.
* Replaced with `kotlinx.serialization.json.JsonElement`.
*/
abstract class FixGeneratedApiTask : DefaultTask() {
@get:InputDirectory
abstract val generated: DirectoryProperty
@TaskAction
fun run() {
val root = generated.get().asFile
fixEmptyWrappers(root)
root.walkTopDown().filter { it.extension == "kt" }.forEach { fix(it) }
}
private fun fixEmptyWrappers(root: File) {
val models = File(root, "ai/kilocode/jetbrains/api/model")
if (!models.isDirectory) return
val empty = Regex("""\nclass \w+ \(\n\n\)""")
val wrappers = models.listFiles()
?.filter { it.extension == "kt" }
?.filter { f -> val t = f.readText(); empty.containsMatchIn(t) && !t.contains("val ") }
?.map { it.nameWithoutExtension }
?: return
for (name in wrappers) File(models, "$name.kt").delete()
root.walkTopDown().filter { it.extension == "kt" }.forEach { file ->
var text = file.readText()
var changed = false
for (name in wrappers) {
if (!text.contains(name)) continue
text = text.replace(Regex("""import [^\n]*\.$name\n"""), "")
text = text.replace(Regex("""\b$name\b"""), "kotlinx.serialization.json.JsonElement")
changed = true
}
if (changed) file.writeText(text)
}
}
private fun fix(file: File) {
var text = file.readText()
var changed = false
// Fix 1: boolean const enums
val decl = Regex("""enum class (\w+)\(val value: kotlin\.Boolean\)""")
for (name in decl.findAll(text).map { it.groupValues[1] }.toList()) {
text = text.replace(Regex("""(val \w+:\s*)\w+\.$name""")) { m ->
"${m.groupValues[1]}kotlin.Boolean"
}
text = text.replace(Regex(
"""\n\s*@Serializable\s*\n\s*enum class $name\(val value: kotlin\.Boolean\)\s*\{[^}]*\}"""
), "")
text = text.replace(Regex(
"""\n\s*/\*\*\s*\n(\s*\*[^\n]*\n)*\s*\*/\s*(?=\n\s*\n)"""
), "")
changed = true
}
// Fix 2: double parentheses `HashMap<…>()()`
if (text.contains("()()")) {
text = text.replace("()()", "()")
changed = true
}
// Fix 3: `kotlin.Double("…")` → double literal
val ctor = Regex("""kotlin\.Double\("(\d+(?:\.\d+)?)"\)""")
if (ctor.containsMatchIn(text)) {
text = ctor.replace(text) { m ->
val n = m.groupValues[1]
if (n.contains(".")) n else "$n.0"
}
changed = true
}
// Fix 4: @Contextual on bare kotlin.Any
if (text.contains("kotlin.Any") &&
text.contains("import kotlinx.serialization.Contextual") &&
text.contains("@Serializable") &&
text.contains("data class")
) {
text = text.replace(
Regex("""(?<!@Contextual )kotlin\.Any"""),
"@Contextual kotlin.Any"
)
changed = true
}
// Fix 5: nullable body in ApiClient
if (file.name == "ApiClient.kt") {
val guard = "val body = response.body"
if (text.contains(guard) && !text.contains("if (body == null) return null")) {
text = text.replace(guard, "$guard\n if (body == null) return null")
text = text.replace("body?.", "body.")
changed = true
}
if (text.contains("it.body.string()")) {
text = text.replace("it.body.string()", "it.body?.string()")
changed = true
}
}
// Fix 6: AnySerializer in Serializer.kt
if (file.name == "Serializer.kt" && !text.contains("AnySerializer")) {
text = text.replace(
"import kotlinx.serialization.modules.SerializersModuleBuilder",
"import kotlinx.serialization.modules.SerializersModuleBuilder\n" +
"import kotlinx.serialization.KSerializer\n" +
"import kotlinx.serialization.descriptors.SerialDescriptor\n" +
"import kotlinx.serialization.encoding.Decoder\n" +
"import kotlinx.serialization.encoding.Encoder\n" +
"import kotlinx.serialization.json.JsonDecoder\n" +
"import kotlinx.serialization.json.JsonEncoder\n" +
"import kotlinx.serialization.json.JsonElement"
)
text = text.replace(
"contextual(StringBuilder::class, StringBuilderAdapter)",
"contextual(StringBuilder::class, StringBuilderAdapter)\n" +
" contextual(Any::class, AnySerializer)"
)
text = text.trimEnd() + "\n\n" +
"internal object AnySerializer : KSerializer<Any> {\n" +
" private val delegate = JsonElement.serializer()\n" +
" override val descriptor: SerialDescriptor = delegate.descriptor\n" +
" override fun serialize(encoder: Encoder, value: Any) {\n" +
" val json = (encoder as JsonEncoder).json\n" +
" encoder.encodeSerializableValue(delegate, json.encodeToJsonElement(delegate, value as? JsonElement ?: return))\n" +
" }\n" +
" override fun deserialize(decoder: Decoder): Any {\n" +
" return (decoder as JsonDecoder).decodeJsonElement()\n" +
" }\n" +
"}\n"
changed = true
}
if (changed) file.writeText(text)
}
}
@@ -0,0 +1,74 @@
import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.process.ExecOperations
import java.io.File
import javax.inject.Inject
abstract class PrepareLocalCliTask : DefaultTask() {
@get:InputFile
abstract val script: RegularFileProperty
@get:Internal
abstract val root: DirectoryProperty
@get:OutputDirectory
abstract val out: DirectoryProperty
@get:Input
abstract val platform: Property<String>
@get:Input
abstract val exe: Property<String>
@get:Inject
abstract val exec: ExecOperations
@TaskAction
fun run() {
val bin = out.file("${platform.get()}/${exe.get()}").get().asFile
if (bin.exists()) return
exec.exec {
workingDir = root.get().asFile
commandLine(findBun(), "script/build.ts", "--prepare-cli")
}
}
/**
* Resolve the absolute path to `bun`. The Gradle daemon's PATH is often
* stripped down and doesn't include Homebrew or user-local bin dirs.
* Probe common install locations so the build works without manual PATH setup.
*/
private fun findBun(): String {
// 1. Already on PATH?
val which = runCatching {
ProcessBuilder("which", "bun")
.redirectErrorStream(true)
.start()
.inputStream.bufferedReader().readLine()?.trim()
}.getOrNull()
if (which != null && File(which).isFile) return which
// 2. Common install locations
val home = System.getProperty("user.home")
val candidates = listOf(
"$home/.bun/bin/bun",
"/opt/homebrew/bin/bun",
"/usr/local/bin/bun",
"$home/.nvm/current/bin/bun",
)
for (path in candidates) {
val f = File(path)
if (f.isFile && f.canExecute()) return f.absolutePath
}
// 3. Fall back — let the OS resolve it (will fail with a clear message)
return "bun"
}
}
+4
View File
@@ -52,9 +52,13 @@ intellijPlatform {
}
tasks.named<JavaExec>("runIde") {
dependsOn(":backend:processResources")
jvmArgumentProviders += CommandLineArgumentProvider {
listOf("-Dnosplash=true")
}
}
tasks.named<Delete>("clean") {
delete(layout.buildDirectory)
}
@@ -1,65 +0,0 @@
package ai.kilocode
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.ui.components.JBLabel
import com.intellij.ui.content.ContentFactory
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.SwingConstants
class KiloToolWindowFactory : ToolWindowFactory {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
val svc = project.service<KiloApiService>()
val icon = JBLabel(
IconLoader.getIcon("/icons/kilo-content.svg", KiloToolWindowFactory::class.java),
).apply {
horizontalAlignment = SwingConstants.CENTER
alignmentX = JPanel.CENTER_ALIGNMENT
}
val text = JBLabel(KiloBundle.message("toolwindow.status.disconnected"), SwingConstants.CENTER).apply {
alignmentX = JPanel.CENTER_ALIGNMENT
font = JBUI.Fonts.label(13f)
foreground = UIUtil.getContextHelpForeground()
setAllowAutoWrapping(true)
}
val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
add(icon)
add(Box.createVerticalStrut(JBUI.scale(16)))
add(text)
}
val panel = JPanel(GridBagLayout()).apply {
isOpaque = false
add(body, GridBagConstraints())
}
val content = ContentFactory.getInstance().createContent(panel, "", false)
val ui = Disposer.newDisposable()
val job = svc.watch { msg ->
text.text = msg
}
Disposer.register(ui, Disposable { job.cancel() })
content.setDisposer(ui)
toolWindow.contentManager.addContent(content)
ActionManager.getInstance().getAction("Kilo.Settings")?.let {
toolWindow.setTitleActions(listOf(it))
}
svc.connect()
}
}
@@ -1,18 +0,0 @@
package ai.kilocode.actions
import ai.kilocode.KiloApiService
import ai.kilocode.rpc.dto.ConnectionStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
class ReinstallKiloAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
e.project?.service<KiloApiService>()?.reinstallAsync()
}
override fun update(e: AnActionEvent) {
val state = e.project?.service<KiloApiService>()?.state?.value
e.presentation.isEnabled = state?.status != ConnectionStatusDto.CONNECTING
}
}
@@ -1,18 +0,0 @@
package ai.kilocode.actions
import ai.kilocode.KiloApiService
import ai.kilocode.rpc.dto.ConnectionStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
class RestartKiloAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
e.project?.service<KiloApiService>()?.restartAsync()
}
override fun update(e: AnActionEvent) {
val state = e.project?.service<KiloApiService>()?.state?.value
e.presentation.isEnabled = state?.status != ConnectionStatusDto.CONNECTING
}
}
@@ -1,16 +1,13 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode
package ai.kilocode.client
import ai.kilocode.rpc.KiloProjectRpcApi
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.rpc.KiloAppRpcApi
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.platform.project.projectId
import fleet.rpc.client.durable
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.CoroutineScope
@@ -22,20 +19,19 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* Frontend project-level service for Kilo CLI interaction.
* App-level frontend service for Kilo CLI interaction.
*
* Communicates with the backend via [KiloProjectRpcApi], passing
* [project.projectId] on every call so the backend can resolve the
* correct project-level service without scanning ProjectManager.
* Communicates with the backend via [KiloAppRpcApi]. All operations
* are app-scoped no project context is needed.
*
* Callers of [watch] are responsible for scheduling UI updates on
* the EDT and converting [KiloAppStateDto] to display text.
*/
@Service(Service.Level.PROJECT)
class KiloApiService(
private val project: Project,
private val cs: CoroutineScope,
) {
@Service(Service.Level.APP)
class KiloAppService(private val cs: CoroutineScope) {
companion object {
private val LOG = Logger.getInstance(KiloApiService::class.java)
private val init = ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
private val LOG = Logger.getInstance(KiloAppService::class.java)
private val init = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED)
}
private val started = AtomicBoolean(false)
@@ -45,10 +41,10 @@ class KiloApiService(
var version: String? = null
private set
val state: StateFlow<ConnectionStateDto> = flow {
val state: StateFlow<KiloAppStateDto> = flow {
durable {
KiloProjectRpcApi.getInstance()
.state(project.projectId())
KiloAppRpcApi.getInstance()
.state()
.collect { emit(it) }
}
}.stateIn(cs, SharingStarted.Eagerly, init)
@@ -57,14 +53,14 @@ class KiloApiService(
if (!started.compareAndSet(false, true)) return
cs.launch {
durable {
KiloProjectRpcApi.getInstance().connect(project.projectId())
KiloAppRpcApi.getInstance().connect()
}
}
}
/** One-shot health check. Returns null on failure. */
suspend fun health(): HealthDto? = try {
durable { KiloProjectRpcApi.getInstance().health(project.projectId()) }
durable { KiloAppRpcApi.getInstance().health() }
} catch (e: Exception) {
LOG.warn("health check failed", e)
null
@@ -75,7 +71,7 @@ class KiloApiService(
LOG.info("restart: resetting state and sending RPC")
started.set(false)
version = null
durable { KiloProjectRpcApi.getInstance().restart(project.projectId()) }
durable { KiloAppRpcApi.getInstance().restart() }
LOG.info("restart: RPC returned — backend restart complete")
}
@@ -84,7 +80,7 @@ class KiloApiService(
LOG.info("reinstall: resetting state and sending RPC")
started.set(false)
version = null
durable { KiloProjectRpcApi.getInstance().reinstall(project.projectId()) }
durable { KiloAppRpcApi.getInstance().reinstall() }
LOG.info("reinstall: RPC returned — backend reinstall complete")
}
@@ -114,27 +110,18 @@ class KiloApiService(
}
}
fun watch(fn: (String) -> Unit): Job {
val mgr = ToolWindowManager.getInstance(project)
/**
* Collect app state changes and invoke [fn] for each update.
*
* The callback receives raw [KiloAppStateDto] the caller is
* responsible for converting to display text and scheduling on the EDT.
*/
fun watch(fn: (KiloAppStateDto) -> Unit): Job {
return cs.launch {
state.collect { next ->
// Fetch CLI version when we become connected
if (next.status == ConnectionStatusDto.CONNECTED) fetchVersionAsync()
mgr.invokeLater {
fn(text(next))
}
if (next.status == KiloAppStatusDto.READY) fetchVersionAsync()
fn(next)
}
}
}
private fun text(state: ConnectionStateDto): String =
when (state.status) {
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected")
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected")
ConnectionStatusDto.ERROR -> KiloBundle.message(
"toolwindow.status.error",
state.error ?: KiloBundle.message("toolwindow.error.unknown"),
)
}
}
@@ -0,0 +1,160 @@
package ai.kilocode.client
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.KiloAppStateDto
import ai.kilocode.rpc.dto.KiloAppStatusDto
import ai.kilocode.rpc.dto.LoadErrorDto
import ai.kilocode.rpc.dto.LoadProgressDto
import ai.kilocode.rpc.dto.ProfileStatusDto
import com.intellij.openapi.Disposable
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.util.Disposer
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import com.intellij.openapi.wm.ToolWindowManager
import com.intellij.ui.components.JBLabel
import com.intellij.ui.content.ContentFactory
import com.intellij.util.ui.JBUI
import com.intellij.util.ui.UIUtil
import java.awt.GridBagConstraints
import java.awt.GridBagLayout
import javax.swing.Box
import javax.swing.BoxLayout
import javax.swing.JPanel
import javax.swing.SwingConstants
class KiloToolWindowFactory : ToolWindowFactory {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
val svc = service<KiloAppService>()
val mgr = ToolWindowManager.getInstance(project)
val icon = JBLabel(
IconLoader.getIcon("/icons/kilo-content.svg", KiloToolWindowFactory::class.java),
).apply {
horizontalAlignment = SwingConstants.CENTER
alignmentX = JPanel.CENTER_ALIGNMENT
}
val status = JBLabel(
KiloBundle.message("toolwindow.status.disconnected"),
SwingConstants.CENTER,
).apply {
alignmentX = JPanel.CENTER_ALIGNMENT
font = JBUI.Fonts.label(13f)
foreground = UIUtil.getContextHelpForeground()
setAllowAutoWrapping(true)
}
val detail = JBLabel("", SwingConstants.CENTER).apply {
alignmentX = JPanel.CENTER_ALIGNMENT
font = JBUI.Fonts.smallFont()
foreground = UIUtil.getContextHelpForeground()
setAllowAutoWrapping(true)
setCopyable(true)
}
val body = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
isOpaque = false
add(icon)
add(Box.createVerticalStrut(JBUI.scale(16)))
add(status)
add(Box.createVerticalStrut(JBUI.scale(6)))
add(detail)
}
val panel = JPanel(GridBagLayout()).apply {
isOpaque = false
add(body, GridBagConstraints())
}
val content = ContentFactory.getInstance().createContent(panel, "", false)
val ui = Disposer.newDisposable()
val job = svc.watch { state ->
mgr.invokeLater {
status.text = title(state)
detail.text = details(state)
detail.isVisible = detail.text.isNotEmpty()
}
}
Disposer.register(ui, Disposable { job.cancel() })
content.setDisposer(ui)
toolWindow.contentManager.addContent(content)
ActionManager.getInstance().getAction("Kilo.Settings")?.let {
toolWindow.setTitleActions(listOf(it))
}
svc.connect()
}
private fun title(state: KiloAppStateDto): String =
when (state.status) {
KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected")
KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading")
KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected")
KiloAppStatusDto.ERROR -> KiloBundle.message(
"toolwindow.status.error",
state.error ?: KiloBundle.message("toolwindow.error.unknown"),
)
}
private fun details(state: KiloAppStateDto): String =
when (state.status) {
KiloAppStatusDto.LOADING -> progress(state.progress)
KiloAppStatusDto.READY -> ready(state)
KiloAppStatusDto.ERROR -> errors(state)
else -> ""
}
private fun progress(p: LoadProgressDto?): String {
if (p == null) return ""
val lines = mutableListOf<String>()
lines.add(item("Config", p.config))
lines.add(item("Notifications", p.notifications))
lines.add(profile(p.profile))
return "<html>${lines.joinToString("<br>")}</html>"
}
private fun item(name: String, loaded: Boolean): String =
if (loaded) "$CHECK $name" else "$DOTS $name"
private fun profile(status: ProfileStatusDto): String =
when (status) {
ProfileStatusDto.PENDING -> "$DOTS Profile"
ProfileStatusDto.LOADED -> "$CHECK Profile"
ProfileStatusDto.NOT_LOGGED_IN -> "$DASH Profile (not logged in)"
}
private fun ready(state: KiloAppStateDto): String {
val svc = service<KiloAppService>()
val ver = svc.version
val lines = mutableListOf<String>()
if (ver != null) lines.add("CLI: $ver")
val p = state.progress
if (p != null && p.profile == ProfileStatusDto.NOT_LOGGED_IN) {
lines.add("Profile: not logged in")
}
return if (lines.isEmpty()) "" else "<html>${lines.joinToString("<br>")}</html>"
}
private fun errors(state: KiloAppStateDto): String {
if (state.errors.isEmpty()) return ""
val lines = state.errors.map(::formatError)
return "<html>Failed to load:<br>${lines.joinToString("<br>")}</html>"
}
private fun formatError(err: LoadErrorDto): String {
val suffix = err.detail ?: err.status?.let { "HTTP $it" } ?: ""
return if (suffix.isEmpty()) "$CROSS ${err.resource}"
else "$CROSS ${err.resource}: $suffix"
}
companion object {
private const val CHECK = "\u2713"
private const val CROSS = "\u2717"
private const val DOTS = "\u2026"
private const val DASH = "\u2013"
}
}
@@ -1,4 +1,4 @@
package ai.kilocode.actions
package ai.kilocode.client.actions
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
@@ -0,0 +1,18 @@
package ai.kilocode.client.actions
import ai.kilocode.client.KiloAppService
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
class ReinstallKiloAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
service<KiloAppService>().reinstallAsync()
}
override fun update(e: AnActionEvent) {
val status = service<KiloAppService>().state.value.status
e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING
}
}
@@ -0,0 +1,18 @@
package ai.kilocode.client.actions
import ai.kilocode.client.KiloAppService
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
class RestartKiloAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
service<KiloAppService>().restartAsync()
}
override fun update(e: AnActionEvent) {
val status = service<KiloAppService>().state.value.status
e.presentation.isEnabled = status != KiloAppStatusDto.CONNECTING && status != KiloAppStatusDto.LOADING
}
}
@@ -1,8 +1,8 @@
package ai.kilocode.actions
package ai.kilocode.client.actions
import ai.kilocode.KiloApiService
import ai.kilocode.KiloBundle
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.client.KiloAppService
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.rpc.dto.KiloAppStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
@@ -17,12 +17,13 @@ class StatusInfoAction : AnAction() {
}
override fun update(e: AnActionEvent) {
val svc = e.project?.service<KiloApiService>() ?: return
val svc = service<KiloAppService>()
val status = when (svc.state.value.status) {
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected.short")
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting.short")
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected.short")
ConnectionStatusDto.ERROR -> KiloBundle.message("toolwindow.status.error.short")
KiloAppStatusDto.READY -> KiloBundle.message("toolwindow.status.connected.short")
KiloAppStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting.short")
KiloAppStatusDto.LOADING -> KiloBundle.message("toolwindow.status.loading.short")
KiloAppStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected.short")
KiloAppStatusDto.ERROR -> KiloBundle.message("toolwindow.status.error.short")
}
val ver = svc.version?.let { " · $it" } ?: ""
e.presentation.text = "$status$ver"
@@ -1,4 +1,4 @@
package ai.kilocode
package ai.kilocode.client.plugin
import com.intellij.DynamicBundle
import org.jetbrains.annotations.PropertyKey
@@ -10,18 +10,18 @@
<toolWindow id="Kilo Code"
anchor="left"
icon="/icons/kilo.svg"
factoryClass="ai.kilocode.KiloToolWindowFactory"/>
factoryClass="ai.kilocode.client.KiloToolWindowFactory"/>
</extensions>
<actions>
<action id="Kilo.Restart"
class="ai.kilocode.actions.RestartKiloAction"/>
class="ai.kilocode.client.actions.RestartKiloAction"/>
<action id="Kilo.Reinstall"
class="ai.kilocode.actions.ReinstallKiloAction"/>
class="ai.kilocode.client.actions.ReinstallKiloAction"/>
<action id="Kilo.StatusInfo"
class="ai.kilocode.actions.StatusInfoAction"/>
class="ai.kilocode.client.actions.StatusInfoAction"/>
<group id="Kilo.SettingsGroup">
<reference ref="Kilo.Restart"/>
@@ -31,7 +31,7 @@
</group>
<action id="Kilo.Settings"
class="ai.kilocode.actions.KiloSettingsAction"
class="ai.kilocode.client.actions.KiloSettingsAction"
icon="AllIcons.General.GearPlain"/>
</actions>
</idea-plugin>
@@ -1,11 +1,13 @@
toolwindow.status.disconnected=Status: Disconnected
toolwindow.status.connecting=Status: Connecting...
toolwindow.status.loading=Status: Loading...
toolwindow.status.connected=Status: Connected
toolwindow.status.error=Status: Error - {0}
toolwindow.error.unknown=Unknown error
toolwindow.status.connected.short=Connected
toolwindow.status.connecting.short=Connecting\u2026
toolwindow.status.loading.short=Loading\u2026
toolwindow.status.disconnected.short=Disconnected
toolwindow.status.error.short=Error
@@ -4,16 +4,16 @@ intellij-gradle-plugin = "2.10.5"
intellij-rpc-plugin = "2.1.20-0.1"
kotlin-jvm-plugin = "2.1.20"
kotlin-serialization-plugin = "2.1.20"
kotlin-serialization = "1.7.3"
kotlin-serialization = "1.8.1"
okhttp = "4.12.0"
moshi = "1.15.1"
openapi-generator = "7.12.0"
openapi-generator = "7.21.0"
[libraries]
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
moshi = { module = "com.squareup.moshi:moshi", version.ref = "moshi" }
moshi-kotlin = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "moshi" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" }
[plugins]
intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" }
+7 -3
View File
@@ -4,8 +4,9 @@
* 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
* bun script/build.ts # Local build — only current platform binary required
* bun script/build.ts --production # Production build — all 6 platform binaries required
* bun script/build.ts --prepare-cli # Only prepare local CLI resources for Gradle
*
* Steps:
* 1. Builds CLI binaries (or uses prebuilt ones from dist/).
@@ -21,6 +22,7 @@ import { join, relative } from "node:path"
import { existsSync, mkdirSync, chmodSync, cpSync, rmSync } from "node:fs"
const production = process.argv.includes("--production")
const cliOnly = process.argv.includes("--prepare-cli")
const root = join(import.meta.dir, "..")
const packages = join(root, "..")
@@ -123,7 +125,9 @@ async function buildPlugin() {
try {
await prepareCli()
await buildPlugin()
if (!cliOnly) {
await buildPlugin()
}
} catch (err) {
console.error(`[jetbrains-build] ERROR: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
@@ -5,6 +5,7 @@ include("frontend")
include("backend")
pluginManagement {
includeBuild("build-tasks")
repositories {
mavenCentral()
gradlePluginPortal()
@@ -0,0 +1,39 @@
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.HealthDto
import ai.kilocode.rpc.dto.KiloAppStateDto
import com.intellij.platform.rpc.RemoteApiProviderService
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
import kotlinx.coroutines.flow.Flow
/**
* App-level RPC API exposed from backend to frontend.
*
* All operations are project-neutral — the CLI backend runs once
* per application, not per project.
*/
@Rpc
interface KiloAppRpcApi : RemoteApi<Unit> {
companion object {
suspend fun getInstance(): KiloAppRpcApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<KiloAppRpcApi>())
}
}
/** Ensure the CLI backend is running and connected. */
suspend fun connect()
/** Observe app lifecycle state changes. */
suspend fun state(): Flow<KiloAppStateDto>
/** One-shot health check against /global/health. */
suspend fun health(): HealthDto
/** Kill the CLI process and restart it. */
suspend fun restart()
/** Kill the CLI process, re-extract the binary, and restart. */
suspend fun reinstall()
}
@@ -1,42 +0,0 @@
package ai.kilocode.rpc
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.HealthDto
import com.intellij.platform.project.ProjectId
import com.intellij.platform.rpc.RemoteApiProviderService
import fleet.rpc.RemoteApi
import fleet.rpc.Rpc
import fleet.rpc.remoteApiDescriptor
import kotlinx.coroutines.flow.Flow
/**
* Project-scoped RPC API exposed from backend to frontend.
*
* Every method takes a [ProjectId] as its first parameter, following the
* JetBrains modular plugin template pattern. The frontend obtains the ID
* via `project.projectId()` and the backend resolves the project via
* `projectId.findProjectOrNull()`.
*/
@Rpc
interface KiloProjectRpcApi : RemoteApi<Unit> {
companion object {
suspend fun getInstance(): KiloProjectRpcApi {
return RemoteApiProviderService.resolve(remoteApiDescriptor<KiloProjectRpcApi>())
}
}
/** Ensure the CLI backend is running and connected. */
suspend fun connect(projectId: ProjectId)
/** Observe connection state changes. */
suspend fun state(projectId: ProjectId): Flow<ConnectionStateDto>
/** One-shot health check against /global/health. */
suspend fun health(projectId: ProjectId): HealthDto
/** Kill the CLI process and restart it. */
suspend fun restart(projectId: ProjectId)
/** Kill the CLI process, re-extract the binary, and restart. */
suspend fun reinstall(projectId: ProjectId)
}
@@ -1,17 +0,0 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
enum class ConnectionStatusDto {
DISCONNECTED,
CONNECTING,
CONNECTED,
ERROR,
}
@Serializable
data class ConnectionStateDto(
val status: ConnectionStatusDto,
val error: String? = null,
)
@@ -0,0 +1,41 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
enum class KiloAppStatusDto {
DISCONNECTED,
CONNECTING,
LOADING,
READY,
ERROR,
}
@Serializable
enum class ProfileStatusDto {
PENDING,
LOADED,
NOT_LOGGED_IN,
}
@Serializable
data class LoadProgressDto(
val config: Boolean = false,
val notifications: Boolean = false,
val profile: ProfileStatusDto = ProfileStatusDto.PENDING,
)
@Serializable
data class LoadErrorDto(
val resource: String,
val status: Int? = null,
val detail: String? = null,
)
@Serializable
data class KiloAppStateDto(
val status: KiloAppStatusDto,
val error: String? = null,
val errors: List<LoadErrorDto> = emptyList(),
val progress: LoadProgressDto? = null,
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.2.5",
"version": "7.2.6",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.2.5",
"version": "7.2.6",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
"description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
"version": "7.2.5",
"version": "7.2.6",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
+9 -2
View File
@@ -303,6 +303,13 @@ async function launch() {
args.push("--wait")
}
// Strip Electron/VS Code env vars so the spawned instance doesn't attach
// to the current Electron process (e.g. when launched from a VS Code task).
const env = { ...process.env }
for (const key of Object.keys(env)) {
if (key.startsWith("ELECTRON_") || key.startsWith("VSCODE_")) delete env[key]
}
console.log(`[launch] Starting VS Code (${mode} mode)`)
console.log(`[launch] Executable: ${app}`)
console.log(`[launch] Workspace: ${workspace}`)
@@ -311,7 +318,7 @@ async function launch() {
if (blocking) {
const result = Bun.spawnSync([app, ...args], {
cwd: workspace,
env: process.env,
env,
stdio: ["ignore", "inherit", "inherit"],
})
console.log(`[launch] VS Code exited (code ${result.exitCode})`)
@@ -321,7 +328,7 @@ async function launch() {
const child = spawn(app, args, {
cwd: workspace,
detached: !win,
env: process.env,
env,
stdio: "ignore",
...(win ? { shell: true } : {}),
})
+6 -1
View File
@@ -396,7 +396,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.visibilityDisposable?.dispose()
this.visibilityDisposable = webviewView.onDidChangeVisibility(() => {
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
this.statsPoller?.setEnabled(webviewView.visible)
if (this.statsPoller) {
this.statsPoller.setEnabled(webviewView.visible)
this.statsPoller.setVisible(webviewView.visible)
}
this.focusSession(webviewView.visible ? this.currentSession?.id : undefined)
})
this.initializeConnection()
@@ -3295,8 +3298,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.postMessage(msg)
},
log: () => {},
hiddenIntervalMs: 60000,
})
this.statsPoller.setEnabled(true)
this.statsPoller.setVisible(true)
}
/**
@@ -188,6 +188,11 @@ export class AgentManagerProvider implements Disposable {
}
this.panel = ctx
this.statsPoller.setVisible(ctx.visible)
ctx.onDidChangeVisibility((visible) => {
this.statsPoller.setVisible(visible)
})
this.stateReady = this.initializeState()
void this.sendRepoInfo()
this.sendKeybindings()
@@ -67,6 +67,9 @@ export class GitOps {
private readonly runGit: (args: string[], cwd: string) => Promise<string>
private readonly controller = new AbortController()
private readonly semaphore: Semaphore | undefined
private readonly resolutionCache = new Map<string, { value: string; expires: number }>()
private static readonly CACHE_TTL_MS = 60000
private static readonly MAX_CACHE_SIZE = 100
get disposed(): boolean {
return this.controller.signal.aborted
@@ -87,6 +90,30 @@ export class GitOps {
if (!this.controller.signal.aborted) {
this.controller.abort()
}
this.resolutionCache.clear()
}
private getCached(key: string): string | undefined {
const entry = this.resolutionCache.get(key)
if (entry && entry.expires > Date.now()) {
return entry.value
}
return undefined
}
private setCached(key: string, value: string): void {
if (this.resolutionCache.size >= GitOps.MAX_CACHE_SIZE) {
let oldestKey: string | undefined
let oldestExpiry = Infinity
for (const [k, v] of this.resolutionCache) {
if (v.expires < oldestExpiry) {
oldestExpiry = v.expires
oldestKey = k
}
}
if (oldestKey) this.resolutionCache.delete(oldestKey)
}
this.resolutionCache.set(key, { value, expires: Date.now() + GitOps.CACHE_TTL_MS })
}
private raw(args: string[], cwd: string): Promise<string> {
@@ -122,38 +149,68 @@ export class GitOps {
* 3. Falls back to `origin`
*/
async resolveRemote(cwd: string, branch?: string): Promise<string> {
const cacheKey = `remote:${cwd}:${branch}`
const cached = this.getCached(cacheKey)
if (cached) return cached
const upstream = await this.raw(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], cwd).catch(
() => "",
)
if (upstream.includes("/")) return upstream.split("/")[0]
if (upstream.includes("/")) {
const result = upstream.split("/")[0]
this.setCached(cacheKey, result)
return result
}
const name = branch || (await this.raw(["branch", "--show-current"], cwd).catch(() => ""))
if (name) {
const configured = await this.raw(["config", `branch.${name}.remote`], cwd).catch(() => "")
if (configured) return configured
if (configured) {
this.setCached(cacheKey, configured)
return configured
}
}
return "origin"
const result = "origin"
this.setCached(cacheKey, result)
return result
}
/** Resolve the upstream tracking ref for `branch`, or `undefined` if none is set. Note: the `@{upstream}` check uses the current HEAD, not `branch`. */
async resolveTrackingBranch(cwd: string, branch: string): Promise<string | undefined> {
const cacheKey = `tracking:${cwd}:${branch}`
const cached = this.getCached(cacheKey)
if (cached !== undefined) return cached === "" ? undefined : cached
const upstream = await this.raw(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd).catch(() => "")
if (upstream) return upstream
if (upstream) {
this.setCached(cacheKey, upstream)
return upstream
}
const remote = await this.resolveRemote(cwd, branch)
const ref = `${remote}/${branch}`
const resolved = await this.raw(["rev-parse", "--verify", ref], cwd).catch(() => "")
if (resolved) return ref
if (resolved) {
this.setCached(cacheKey, ref)
return ref
}
this.setCached(cacheKey, "")
return undefined
}
/** Resolve the repo's default branch via <remote>/HEAD. */
async resolveDefaultBranch(cwd: string, branch?: string): Promise<string | undefined> {
const remote = await this.resolveRemote(cwd, branch)
const cacheKey = `default-branch:${cwd}:${remote}`
const cached = this.getCached(cacheKey)
if (cached !== undefined) return cached
const head = await this.raw(["symbolic-ref", "--short", `refs/remotes/${remote}/HEAD`], cwd).catch(() => "")
return head || undefined
const result = head || undefined
this.setCached(cacheKey, result ?? "")
return result
}
async hasRemoteRef(cwd: string, ref: string): Promise<boolean> {
@@ -48,6 +48,7 @@ interface GitStatsPollerOptions {
intervalMs?: number
/** Shared concurrency gate for child process spawning. */
semaphore?: Semaphore
hiddenIntervalMs?: number
}
export class GitStatsPoller {
@@ -62,14 +63,27 @@ export class GitStatsPoller {
{ files: number; additions: number; deletions: number; ahead: number; behind: number }
> = {}
private readonly intervalMs: number
private readonly hiddenIntervalMs: number
private readonly git: GitOps
private skipWorktreeIds = new Set<string>()
private visible = true
constructor(private readonly options: GitStatsPollerOptions) {
this.intervalMs = options.intervalMs ?? 5000
this.hiddenIntervalMs = options.hiddenIntervalMs ?? 60000
this.git = options.git
}
setVisible(visible: boolean): void {
if (this.visible === visible) return
this.visible = visible
if (this.active && this.timer) {
clearTimeout(this.timer)
this.timer = undefined
this.schedule(this.visible ? this.intervalMs : this.hiddenIntervalMs)
}
}
skipWorktree(id: string): void {
this.skipWorktreeIds.add(id)
}
@@ -81,7 +95,8 @@ export class GitStatsPoller {
setEnabled(enabled: boolean): void {
if (enabled) {
if (this.active) return
this.start()
this.active = true
void this.poll()
return
}
this.stop()
@@ -100,10 +115,8 @@ export class GitStatsPoller {
this.lastStats = {}
}
private start(): void {
this.stop()
this.active = true
void this.poll()
private currentInterval(): number {
return this.visible ? this.intervalMs : this.hiddenIntervalMs
}
private schedule(delay: number): void {
@@ -119,7 +132,7 @@ export class GitStatsPoller {
this.busy = true
return this.fetch().finally(() => {
this.busy = false
this.schedule(this.intervalMs)
this.schedule(this.currentInterval())
})
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b57a543bd799f92b35d2c6037a4ffc932a37f59790213006a021bebef9e94b38
size 6897
@@ -3016,10 +3016,11 @@ const AgentManagerContent: Component = () => {
onClose={() => setSidePanel(null)}
onExpand={selection() !== null ? openReviewTab : undefined}
onRequestDiff={requestDiffFile}
onOpenFile={(file) => {
onOpenFile={(file, line) => {
const id = currentDiffSessionId()
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file })
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file })
if (id)
vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line })
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file, line })
}}
onRevertFile={revertCtl.revert}
revertingFiles={revertCtl.reverting()}
@@ -3044,10 +3045,10 @@ const AgentManagerContent: Component = () => {
diffStyle={reviewDiffStyle()}
onDiffStyleChange={setSharedDiffStyle}
onRequestDiff={requestDiffFile}
onOpenFile={(file) => {
onOpenFile={(file, line) => {
const id = currentDiffSessionId()
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file })
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file })
if (id) vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line })
else if (selection() === LOCAL) vscode.postMessage({ type: "openFile", filePath: file, line })
}}
onRevertFile={revertCtl.revert}
revertingFiles={revertCtl.reverting()}
@@ -40,7 +40,7 @@ interface DiffPanelProps {
onClose: () => void
onExpand?: () => void
onRequestDiff?: (file: string) => void
onOpenFile?: (relativePath: string) => void
onOpenFile?: (relativePath: string, line?: number) => void
onRevertFile?: (file: string) => void
revertingFiles?: Set<string>
}
@@ -520,6 +520,10 @@ export const DiffPanel: Component<DiffPanelProps> = (props) => {
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)}
onLineNumberClick={(event) => {
if (event.annotationSide === "deletions") return
props.onOpenFile?.(diff.file, event.lineNumber)
}}
/>
</Show>
</Show>
@@ -40,7 +40,7 @@ interface FullScreenDiffViewProps {
diffStyle: DiffStyle
onDiffStyleChange: (style: DiffStyle) => void
onRequestDiff?: (file: string) => void
onOpenFile?: (relativePath: string) => void
onOpenFile?: (relativePath: string, line?: number) => void
onRevertFile?: (file: string) => void
revertingFiles?: Set<string>
onClose: () => void
@@ -596,6 +596,10 @@ export const FullScreenDiffView: Component<FullScreenDiffViewProps> = (props) =>
renderAnnotation={buildAnnotation}
enableGutterUtility={true}
onGutterUtilityClick={(result) => handleGutterClick(diff.file, result)}
onLineNumberClick={(event) => {
if (event.annotationSide === "deletions") return
props.onOpenFile?.(diff.file, event.lineNumber)
}}
/>
</Show>
</Show>
@@ -130,6 +130,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
onSelectSession={props.onSelectSession}
onShowHistory={props.onShowHistory}
questions={standaloneQuestions}
readonly={props.readonly}
/>
</div>
</div>
@@ -44,6 +44,8 @@ interface MessageListProps {
onShowHistory?: () => void
/** Non-tool question requests to render inline at the bottom of the message list */
questions?: () => QuestionRequest[]
/** When true (subagent viewer), replace the welcome screen with an initializing indicator */
readonly?: boolean
}
export const MessageList: Component<MessageListProps> = (props) => {
@@ -114,7 +116,12 @@ export const MessageList: Component<MessageListProps> = (props) => {
<span>{language.t("session.messages.loading")}</span>
</div>
</Show>
<Show when={isEmpty()}>
<Show when={isEmpty() && props.readonly}>
<div class="message-list-empty">
<p class="kilo-about-text">{language.t("session.messages.initializing")}</p>
</div>
</Show>
<Show when={isEmpty() && !props.readonly}>
<div class="message-list-empty">
<KiloLogo />
<p class="kilo-about-text">{language.t("session.messages.welcome")}</p>
@@ -145,10 +145,14 @@ export const PermissionDock: Component<{
}
}
// Auto-focus the dock when it appears so keyboard shortcuts work immediately
// Keep keyboard shortcuts when the webview already has focus, but do not
// steal focus from the editor, terminal, or other VS Code surfaces.
createEffect(() => {
void props.request.id
requestAnimationFrame(() => root?.focus())
requestAnimationFrame(() => {
if (!document.hasFocus()) return
root?.focus()
})
})
return (
@@ -245,11 +245,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
}
// Auto-focus first option when dock appears, tab changes, or editing ends
// Keep keyboard navigation when the webview already has focus, but do not
// steal focus from the editor, terminal, or other VS Code surfaces.
createEffect(() => {
void store.tab
if (store.collapsed || store.editing || confirm()) return
requestAnimationFrame(() => {
if (!document.hasFocus()) return
const btn = root?.querySelector<HTMLButtonElement>("button[data-slot='question-option']:not(:disabled)")
btn?.focus()
})
@@ -376,7 +378,12 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
<Show when={store.editing}>
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
ref={(el) => {
setTimeout(() => {
if (!document.hasFocus()) return
el.focus()
}, 0)
}}
type="text"
data-slot="custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
@@ -13,6 +13,7 @@ import { BasicTool } from "@kilocode/kilo-ui/basic-tool"
import { Icon } from "@kilocode/kilo-ui/icon"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { useData } from "@kilocode/kilo-ui/context/data"
import { useLanguage } from "../../context/language"
import { useI18n } from "@kilocode/kilo-ui/context/i18n"
import { createAutoScroll } from "@kilocode/kilo-ui/hooks"
import { useSession } from "../../context/session"
@@ -39,6 +40,7 @@ function getSessionToolParts(store: ReturnType<typeof useData>["store"], session
const TaskToolRenderer: Component<ToolProps> = (props) => {
const data = useData()
const i18n = useI18n()
const language = useLanguage()
const session = useSession()
const vscode = useVSCode()
@@ -117,6 +119,11 @@ const TaskToolRenderer: Component<ToolProps> = (props) => {
<BasicTool icon="task" status={props.status} trigger={trigger()} defaultOpen>
<div ref={autoScroll.scrollRef} onScroll={autoScroll.handleScroll} data-component="tool-output" data-scrollable>
<div ref={autoScroll.contentRef} data-component="task-tools">
<Show when={running() && childToolParts().length === 0}>
<div data-slot="task-tool-item" data-state="starting">
<span data-slot="task-tool-title">{language.t("session.messages.taskStarting")}</span>
</div>
</Show>
<For each={childToolParts()}>
{(item) => {
const info = createMemo(() => getToolInfo(item.tool, item.state?.input))
@@ -197,7 +197,7 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
<Collapsible.Trigger>
<div data-component="session-turn-diffs-trigger">
<div data-slot="session-turn-diffs-title">
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>
<span data-slot="session-turn-diffs-label">{i18n.t("ui.sessionReview.change.modified")}</span>{" "}
<span data-slot="session-turn-diffs-count">
{diffs().length} {i18n.t(diffs().length === 1 ? "ui.common.file.one" : "ui.common.file.other")}
</span>
+2
View File
@@ -930,6 +930,8 @@ export const dict = {
"session.messages.welcome":
"Kilo Code هو مساعد ترميز بالذكاء الاصطناعي. اطلب منه بناء ميزات أو إصلاح أخطاء أو شرح قاعدة الشيفرة.",
"session.messages.scrollToBottom": "التمرير إلى الأسفل",
"session.messages.initializing": "جاري التهيئة...",
"session.messages.taskStarting": "جاري البدء...",
"session.status.writingResponse": "...جارٍ كتابة الرد",
"session.status.retry": "جارٍ إعادة المحاولة…",
"session.status.retrying": "...إعادة المحاولة (المحاولة {{ attempt }})… {{ message }}",
+2
View File
@@ -938,6 +938,8 @@ export const dict = {
"session.messages.welcome":
"Kilo Code é um assistente de programação com IA. Peça para criar funcionalidades, corrigir bugs ou explicar sua base de código.",
"session.messages.scrollToBottom": "Rolar para o final",
"session.messages.initializing": "O teraouiñ...",
"session.messages.taskStarting": "O kregiñ...",
"session.status.writingResponse": "Escrevendo resposta…",
"session.status.retry": "Tentando novamente…",
"session.status.retrying": "Tentando novamente (tentativa {{ attempt }})… {{ message }}",
+2
View File
@@ -943,6 +943,8 @@ export const dict = {
"session.messages.welcome":
"Kilo Code je AI asistent za programiranje. Zatražite da gradi funkcionalnosti, ispravlja greške ili objasni vašu bazu koda.",
"session.messages.scrollToBottom": "Pomakni se na dno",
"session.messages.initializing": "Inicijalizacija...",
"session.messages.taskStarting": "Pokretanje...",
"session.status.writingResponse": "Pisanje odgovora…",
"session.status.retry": "Ponovni pokušaj…",
"session.status.retrying": "Ponovni pokušaj (pokušaj {{ attempt }})… {{ message }}",
+2
View File
@@ -936,6 +936,8 @@ export const dict = {
"session.messages.welcome":
"Kilo Code er en AI-kodningsassistent. Bed den om at bygge funktioner, rette fejl eller forklare din kodebase.",
"session.messages.scrollToBottom": "Rul til bunden",
"session.messages.initializing": "Initialiserer...",
"session.messages.taskStarting": "Starter...",
"session.status.writingResponse": "Skriver svar…",
"session.status.retry": "Prøver igen…",
"session.status.retrying": "Prøver igen (forsøg {{ attempt }})… {{ message }}",
+2
View File
@@ -948,6 +948,8 @@ export const dict = {
"session.messages.welcome":
"Kilo Code ist ein KI-Programmierassistent. Bitten Sie ihn, Funktionen zu erstellen, Fehler zu beheben oder Ihre Codebasis zu erklären.",
"session.messages.scrollToBottom": "Nach unten scrollen",
"session.messages.initializing": "Initialisierung...",
"session.messages.taskStarting": "Wird gestartet...",
"session.status.writingResponse": "Antwort wird geschrieben…",
"session.status.retry": "Erneuter Versuch…",
"session.status.retrying": "Erneuter Versuch ({{ attempt }})… {{ message }}",
@@ -936,6 +936,8 @@ export const dict = {
"session.messages.welcome":
"Kilo Code is an AI coding assistant. Ask it to build features, fix bugs, or explain your codebase.",
"session.messages.scrollToBottom": "Scroll to bottom",
"session.messages.initializing": "Initializing...",
"session.messages.taskStarting": "Starting...",
"session.status.writingResponse": "Writing response...",
"session.status.retry": "Retrying…",
"session.status.retrying": "Retrying (attempt {{ attempt }})… {{ message }}",
+2
View File
@@ -944,6 +944,8 @@ export const dict = {
"session.messages.welcome":
"Kilo Code es un asistente de programación con IA. Pídele que cree funcionalidades, corrija errores o explique tu código.",
"session.messages.scrollToBottom": "Desplazar al final",
"session.messages.initializing": "Inicializando...",
"session.messages.taskStarting": "Iniciando...",
"session.status.writingResponse": "Escribiendo respuesta…",
"session.status.retry": "Reintentando…",
"session.status.retrying": "Reintentando (intento {{ attempt }})… {{ message }}",
+2
View File
@@ -950,6 +950,8 @@ export const dict = {
"session.messages.welcome":
"Kilo Code est un assistant de programmation IA. Demandez-lui de créer des fonctionnalités, corriger des bugs ou expliquer votre code.",
"session.messages.scrollToBottom": "Défiler vers le bas",
"session.messages.initializing": "Initialisation...",
"session.messages.taskStarting": "Démarrage...",
"session.status.writingResponse": "Rédaction de la réponse…",
"session.status.retry": "Nouvelle tentative…",
"session.status.retrying": "Nouvelle tentative (essai {{ attempt }})… {{ message }}",
+2
View File
@@ -935,6 +935,8 @@ export const dict = {
"session.messages.welcome":
"Kilo CodeはAIコーディングアシスタントです。機能の構築、バグの修正、コードベースの説明を依頼できます。",
"session.messages.scrollToBottom": "下にスクロール",
"session.messages.initializing": "初期化中...",
"session.messages.taskStarting": "開始中...",
"session.status.writingResponse": "応答を作成中…",
"session.status.retry": "再試行中…",
"session.status.retrying": "再試行中({{ attempt }}回目)… {{ message }}",
+2
View File
@@ -935,6 +935,8 @@ export const dict = {
"session.messages.welcome":
"Kilo Code는 AI 코딩 어시스턴트입니다. 기능 구축, 버그 수정 또는 코드베이스 설명을 요청하세요.",
"session.messages.scrollToBottom": "하단으로 스크롤",
"session.messages.initializing": "초기화 중...",
"session.messages.taskStarting": "시작 중...",
"session.status.writingResponse": "응답 작성 중...",
"session.status.retry": "재시도 중…",
"session.status.retrying": "재시도 중 ({{ attempt }}번째 시도)… {{ message }}",

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