mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge branch 'main' into imanolmaiztegui/kilo-opencode-v1.4.0
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Reduce git process load via visibility-aware polling and resolution caching in GitStatsPoller
|
||||
Generated
+12
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.5",
|
||||
"version": "7.2.6",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "7.2.5",
|
||||
"version": "7.2.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+2
-2
@@ -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
|
||||
+22
@@ -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()
|
||||
}
|
||||
+51
@@ -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>,
|
||||
)
|
||||
+393
@@ -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}")
|
||||
+283
@@ -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) }
|
||||
}
|
||||
}
|
||||
+83
-69
@@ -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")
|
||||
}
|
||||
}
|
||||
+74
@@ -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,
|
||||
)
|
||||
}
|
||||
+15
@@ -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)
|
||||
}
|
||||
}
|
||||
-42
@@ -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()
|
||||
}
|
||||
-14
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
-80
@@ -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>
|
||||
|
||||
+164
@@ -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)
|
||||
}
|
||||
}
|
||||
+97
@@ -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)
|
||||
))
|
||||
}
|
||||
}
|
||||
+249
@@ -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)
|
||||
}
|
||||
}
|
||||
+100
@@ -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())
|
||||
}
|
||||
}
|
||||
+176
@@ -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)
|
||||
}
|
||||
}
|
||||
+33
@@ -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()
|
||||
}
|
||||
}
|
||||
+192
@@ -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()
|
||||
}
|
||||
}
|
||||
+27
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
-18
@@ -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
|
||||
}
|
||||
}
|
||||
-18
@@ -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
|
||||
}
|
||||
}
|
||||
+30
-43
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+160
@@ -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
-1
@@ -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
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
+18
@@ -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
|
||||
}
|
||||
}
|
||||
+10
-9
@@ -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
-1
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
-17
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.5",
|
||||
"version": "7.2.6",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user