Merge pull request #8697 from Kilo-Org/acute-jingle

feat(jetbrains): lifecycle control, generated API client, dual HTTP clients, project-scoped RPC
This commit is contained in:
Kirill Kalishev
2026-04-10 11:43:11 -04:00
committed by GitHub
31 changed files with 1620 additions and 1 deletions
+11
View File
@@ -2,4 +2,15 @@
build/
.intellijPlatform/
.ai/
**/.kilo
.classpath
.project
.settings/
.kotlin/
bin/
**/.classpath
**/.project
**/.settings/
**/.kotlin/
**/bin/
!gradle/wrapper/gradle-wrapper.jar
+214
View File
@@ -0,0 +1,214 @@
# AGENTS.md — Kilo JetBrains Plugin
## Architecture (Split Mode)
- **Split-mode plugin** with three Gradle modules: `shared/`, `frontend/`, `backend/`. The module descriptors are `kilo.jetbrains.shared.xml`, `kilo.jetbrains.frontend.xml`, `kilo.jetbrains.backend.xml` — these must stay in sync with `plugin.xml`'s `<content>` block.
- Reference template for the split-mode structure: https://github.com/nicewith/intellij-platform-modular-plugin-template
- Official docs: https://plugins.jetbrains.com/docs/intellij/split-mode-for-remote-development.html
- The JetBrains reference template mirrors our overall structure well: root project assembles the final plugin, `shared` holds contracts, `frontend` holds UI, and `backend` holds project-local logic. Copy its split-mode wiring and RPC layout, but **do not** copy its Compose UI approach.
- Kotlin source goes under `{module}/src/main/kotlin/ai/kilocode/jetbrains/`. Package name is `ai.kilocode.jetbrains` (matches `group` in root `build.gradle.kts`).
- **Module placement rules**: backend modules host project model, indexing, analysis, execution, and CLI process management. Frontend modules host UI, typing assistance, and latency-sensitive features. Shared modules define RPC interfaces and data types used by both sides.
- In monolithic IDE mode (non-remote), all three modules load in one process — split plugins work fine without remote dev.
- Frontend ↔ backend communication uses RPC interfaces defined in `shared/`. Data sent over RPC must use `kotlinx.serialization`. In monolithic mode RPC is just an in-process suspend call.
- **Testing split mode**: run `./gradlew generateSplitModeRunConfigurations` to create a "Run IDE (Split Mode)" config that starts both frontend and backend processes locally. Emulate latency via the Split Mode widget (requires internal mode: `-Didea.is.internal=true`).
- The root `plugin.xml` is wiring only: keep plugin metadata and the `<content>` block there. Register services, extensions, listeners, and actions in the module XML descriptors, not in root `plugin.xml`.
- Module descriptor files must live directly in `{module}/src/main/resources/`, not in `META-INF/`.
- Module XMLs use `<dependencies>`, not `<depends>`. The allowed top-level registration tags are limited; keep module XMLs focused on `<resource-bundle>`, `<extensions>`, `<extensionPoints>`, `<actions>`, `<applicationListeners>`, and `<projectListeners>`.
- Module dependencies determine where code loads. In monolith mode both frontend and backend dependencies are satisfied, so both modules load together.
- Run inspection `Plugin DevKit | Code | Frontend and Backend API Usage` when adding or moving split-mode code.
## Split Feature Development
- For any new split feature, follow this flow: put UI in `frontend`, heavy/project-local logic in `backend`, and shared contracts in `shared`.
- Shared cross-process payloads must be `@Serializable`. Keep `shared` lightweight and avoid pulling frontend-only or backend-only APIs into it.
- Define RPC APIs in `shared` with `@Rpc`, `RemoteApi<Unit>`, and `suspend` methods only.
- Implement RPC providers in `backend` and register them via `com.intellij.platform.rpc.backend.remoteApiProvider` when RPC is introduced.
- Call RPC from `frontend` coroutines only. Never call RPC on the EDT; do not paper over this with blocking wrappers.
- Wrap long-lived RPC calls and flows in `durable {}` so they survive reconnects and backend restarts.
- For backend -> frontend push events, prefer Remote Topics over ad-hoc polling.
- Render empty state immediately and progressively fill data from the backend. Do not block first paint on backend state.
- Avoid chatty RPC. Debounce UI events, batch requests, cache results where appropriate, and page large datasets instead of sending everything at once.
- If a new split feature requires RPC support similar to the JetBrains template, mirror the template's wiring: `shared` and `frontend` use the RPC/serialization plugins, and the backend adds the required backend RPC platform modules.
## CLI Integration
- CLI process spawning, extraction, and lifecycle belong in `backend`.
- Detect architecture with `com.intellij.util.system.CpuArch.CURRENT`, not `System.getProperty("os.arch")`.
- Detect OS with `com.intellij.openapi.util.SystemInfo.isMac` / `isLinux` / `isWindows`.
- For packaging/build plumbing, see `script/build.ts` and `backend/build.gradle.kts`.
## Dependencies
- **Always bundle third-party libraries with the plugin.** Do not rely on libraries bundled with the IntelliJ platform (e.g. OkHttp, Gson, Guava, kotlinx-serialization-json). The IDE's bundled versions change across releases without notice and can cause version collisions, classloader conflicts, or silent API breakage. Declare all third-party dependencies as `implementation` in the relevant `build.gradle.kts` so they ship inside the plugin JAR and load from the plugin's own classloader.
- `kotlinx.coroutines` is the one mandatory exception — it is provided by the platform and must not be bundled (the IntelliJ Platform Gradle plugin enforces this automatically).
- Pin exact versions in `gradle/libs.versions.toml` and reference them via the version catalog (`libs.*`) in `build.gradle.kts`. Never hardcode version strings in `build.gradle.kts`.
## Services and Coroutines
- Official docs: https://plugins.jetbrains.com/docs/intellij/plugin-services.html and https://plugins.jetbrains.com/docs/intellij/launching-coroutines.html
- **Prefer light services**: annotate with `@Service` (or `@Service(Service.Level.PROJECT)`) instead of registering in XML when the service won't be overridden or exposed as API. Light services must be `final` in Java (no `open` in Kotlin), cannot use constructor injection of other services, and don't support `os`/`client`/`overrides` attributes.
- Non-light services that need XML registration go in `kilo.jetbrains.backend.xml` under `<extensions defaultExtensionNs="com.intellij"><applicationService>` (or `<projectService>`).
- **Constructor-injected `CoroutineScope`**: the recommended way to launch coroutines. Each service gets its own scope (child of an intersection scope). The scope is cancelled on app/project shutdown or plugin unload. Supported signatures: `MyService(CoroutineScope)` for app services, `MyService(Project, CoroutineScope)` for project services.
- The injected scope's context contains `Dispatchers.Default` and `CoroutineName(serviceClass)`. Switch to `Dispatchers.IO` for blocking I/O.
- **Avoid heavy constructor work** — defer initialization to methods. Never cache service instances in fields; always retrieve via `service<T>()` at the call site.
- `runBlockingCancellable` exists but is **not recommended** — use service scopes instead. For actions, use `currentThreadCoroutineScope()` which lets the Action System cancel the coroutine.
- No extra coroutines dependency is needed — `kotlinx.coroutines` is bundled by the IntelliJ platform and available transitively.
## CLI Server Protocol
- The plugin spawns `kilo serve --port 0` (OS assigns random port) and reads stdout for `listening on http://...:(\d+)` to discover the port.
- A random 32-byte hex password is passed via `KILO_SERVER_PASSWORD` env var for Basic Auth.
- Key env vars: `KILO_CLIENT=jetbrains`, `KILO_PLATFORM=jetbrains`, `KILO_APP_NAME=kilo-code`, `KILO_ENABLE_QUESTION_TOOL=true`.
- This is the same protocol used by the VS Code extension (`packages/kilo-vscode/src/services/cli-backend/server-manager.ts`).
## Build
- **Full build**: `bun run build` from `packages/kilo-jetbrains/` (builds CLI + Gradle plugin).
- **Gradle only**: `./gradlew buildPlugin` from `packages/kilo-jetbrains/` (requires CLI binaries already present).
- **Via Turbo**: `bun turbo build --filter=@kilocode/kilo-jetbrains` from repo root.
- **Run in sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does NOT build CLI binaries.
## Files That Must Change Together
- `plugin.xml` `<content>` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`)
- Service classes ↔ `<applicationService>`/`<projectService>` entries in the corresponding module XML
- `script/build.ts` platform list ↔ `backend/build.gradle.kts` `requiredPlatforms` list
## UI Design Guidelines
Official references:
- [IntelliJ Platform UI Guidelines](https://jetbrains.design/intellij/)
- [User Interface Components](https://plugins.jetbrains.com/docs/intellij/user-interface-components.html)
- [UI FAQ (colors, borders, icons)](https://plugins.jetbrains.com/docs/intellij/ui-faq.html)
### Do Not Use Kotlin Compose
**Do not use Kotlin Compose or `intellij.platform.compose` in this plugin.** The JetBrains modular template uses Compose for its demo tool window, but Kilo should use standard Swing with IntelliJ Platform components only. Keep all plugin UI in the existing Swing-based stack.
### Do Not Use JCEF (Embedded Browser)
**Do not use JCEF (`JBCefBrowser`) in this plugin.** JCEF does not work in JetBrains remote development (split mode): the frontend process runs on the client machine but JCEF requires a display on the host, making it effectively unusable for remote users. Use standard Swing with IntelliJ Platform components for all UI.
### When to Use What
| Need | API |
| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| Dialogs and settings pages with input fields bound to state | [Kotlin UI DSL v2](https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html) (`com.intellij.ui.dsl.builder`) |
| Tool window panels, action-driven UI, custom components | Standard Swing with IntelliJ Platform component replacements (see below) |
| Menus and toolbars | [Action System](https://plugins.jetbrains.com/docs/intellij/action-system.html) |
### Kotlin UI DSL v2
Use `panel { }` as the top-level builder — returns a `DialogPanel`. Structure: `panel``row` → cells (factory methods like `textField()`, `checkBox()`, `label()`). The DSL is for forms with bindings (`bindText`, `bindSelected`, etc.) and is **not** intended for general tool window UI.
```kotlin
// Settings/dialog example
panel {
row("Label:") { textField().bindText(model::value) }
group("Section") {
row { checkBox("Enable feature").bindSelected(model::enabled) }
}
}
```
Key patterns: `group {}` for titled sections, `indent {}` for left indent, `collapsibleGroup {}` for expandable sections, `buttonsGroup {}` for radio groups, `enabledIf()`/`visibleIf()` for reactive visibility, `.align(AlignX.FILL)` to stretch components.
To explore DSL capabilities interactively: **Tools → Internal Actions → UI → Kotlin UI DSL → UI DSL Showcase** (requires internal mode).
### Tool Windows
- Register declaratively in module XML via `com.intellij.toolWindow` extension point (already done in `kilo.jetbrains.frontend.xml`).
- Implement `ToolWindowFactory.createToolWindowContent()` — called lazily on first click (zero overhead if unused).
- Use `SimpleToolWindowPanel(vertical = true)` as a convenient base — supports toolbar + content layout.
- Add tabs via `ToolWindow.contentManager`: create content with `ContentFactory.getInstance().createContent(component, title, isLockable)`, then `contentManager.addContent()`.
- For conditional display, implement `ToolWindowFactory.isApplicableAsync(project)`.
- Always use `ToolWindowManager.invokeLater()` instead of `Application.invokeLater()` for tool-window-related EDT tasks.
### Dialogs
- Extend `DialogWrapper`. Call `init()` from the constructor. Override `createCenterPanel()` to return UI content — prefer Kotlin UI DSL v2 for the panel contents.
- Override `getPreferredFocusedComponent()` for initial focus, `getDimensionServiceKey()` for size persistence.
- Show with `showAndGet()` (modal, returns boolean) or `show()` (then use `getExitCode()`).
- Input validation: call `initValidation()` in constructor, override `doValidate()` → return `null` if valid or `ValidationInfo(message, component)` if not.
### Platform Components — Always Use Instead of Raw Swing
| Instead of | Use | Package |
| --------------------- | ---------------------- | ------------------------------- |
| `JLabel` | `JBLabel` | `com.intellij.ui.components` |
| `JTextField` | `JBTextField` | `com.intellij.ui.components` |
| `JTextArea` | `JBTextArea` | `com.intellij.ui.components` |
| `JList` | `JBList` | `com.intellij.ui.components` |
| `JScrollPane` | `JBScrollPane` | `com.intellij.ui.components` |
| `JTable` | `JBTable` | `com.intellij.ui.table` |
| `JTree` | `Tree` | `com.intellij.ui.treeStructure` |
| `JSplitPane` | `JBSplitter` | `com.intellij.ui` |
| `JTabbedPane` | `JBTabs` | `com.intellij.ui.tabs` |
| `JCheckBox` | `JBCheckBox` | `com.intellij.ui.components` |
| `Color` | `JBColor` | `com.intellij.ui` |
| `EmptyBorder` | `JBUI.Borders.empty()` | `com.intellij.util.ui` |
| Hardcoded pixel sizes | `JBUI.scale(px)` | `com.intellij.util.ui` |
Inspection `Plugin DevKit | Code | Undesirable class usage` highlights when you use raw Swing where a platform replacement exists.
### Multi-line and Rich Text
| Need | Component |
| ----------------------------------------------------- | ------------------------------------------------------ |
| Rich HTML with modern CSS, icons, shortcuts | `JBHtmlPane` (`com.intellij.ui.components.JBHtmlPane`) |
| Simple multi-line label with HTML | `JBLabel` + `XmlStringUtil.wrapInHtml()` |
| Scrollable / wrapping HTML panel | `SwingHelper.createHtmlViewer()` |
| High-perf colored text fragments (trees/lists/tables) | `SimpleColoredComponent` |
| Plain-text newline splitting | `MultiLineLabel` — legacy, do not use in new code |
- Build HTML programmatically with `HtmlChunk`/`HtmlBuilder` (`com.intellij.openapi.util.text.HtmlChunk`). Avoid raw HTML string concatenation — it risks injection and breaks localization.
- For simple wrapping/escaping: `XmlStringUtil.wrapInHtml(content)`, `XmlStringUtil.wrapInHtmlLines(lines...)`, `XmlStringUtil.escapeString(text)`.
- Selectable/copyable label text: `JBLabel.setCopyable(true)` (switches internally to `JEditorPane` while preserving label appearance). Use `setAllowAutoWrapping(true)` for auto-wrap.
- When creating a `JEditorPane` manually, always use `HTMLEditorKitBuilder` instead of constructing `HTMLEditorKit` directly: `editorPane.setEditorKit(HTMLEditorKitBuilder.simple())` or `.withWordWrapViewFactory().build()`.
- Single-line overflow/ellipsis: use `SwingTextTrimmer` — do not manually truncate strings.
- All user-visible strings go in `*.properties` files; HTML markup in values is acceptable.
### Colors and Theming
- **Never** use `java.awt.Color` directly. Use `JBColor(lightColor, darkColor)` or `JBColor.namedColor("key", fallback)` for theme-aware colors.
- For lazy color retrieval (e.g. in painting), use `JBColor.lazy { UIManager.getColor("key") }`.
- Check current theme: `JBColor.isBright()` returns `true` for light themes.
- Generic UI colors: `UIUtil.getContextHelpForeground()`, `UIUtil.getLabelForeground()`, `UIUtil.getPanelBackground()`, etc.
### Borders, Insets, and Spacing
- Always create via `JBUI.Borders.empty(top, left, bottom, right)` and `JBUI.insets()` — DPI-aware and auto-update on zoom.
- Use `JBUI.scale(int)` for any pixel dimension to ensure proper HiDPI scaling.
### Icons
- **Reuse platform icons**: browse at https://intellij-icons.jetbrains.design. Access via `AllIcons.*` constants.
- Custom icons: SVG files in `resources/icons/`. Load via `IconLoader.getIcon("/icons/foo.svg", MyClass::class.java)`.
- Organize in an `icons` package or a `*Icons` object with `@JvmField` on each constant.
- **Sizing**: actions/nodes = 16×16, tool window = 13×13 (classic) or 20×20 + 16×16 compact (New UI), editor gutter = 12×12 (classic) / 14×14 (New UI).
- **Dark variants**: `icon.svg` + `icon_dark.svg`. HiDPI: `icon@2x.svg` + `icon@2x_dark.svg`.
- **New UI support**: place New UI icons in `expui/` directory, create `*IconMappings.json`, register via `com.intellij.iconMapper` extension point. New UI icon colors: light `#6C707E`, dark `#CED0D6`.
### Notifications
- Declare in module XML: `<notificationGroup id="Kilo Code" displayType="BALLOON"/>`.
- Show: `Notification("Kilo Code", "message", NotificationType.INFORMATION).notify(project)`.
- Add actions: `.addAction(NotificationAction.createSimpleExpiring("Label") { ... })`.
- Sticky (user must dismiss): `displayType="STICKY_BALLOON"` + `.setSuggestionType(true)`.
- Tool-window-bound: `displayType="TOOL_WINDOW" toolWindowId="Kilo Code"`.
- Prefer non-modal notifications over `Messages.show*()` dialogs.
### Popups
- Use `JBPopupFactory.getInstance()` for lightweight floating UI (no chrome, auto-dismiss on focus loss).
- `createComponentPopupBuilder(component, focusable)` for arbitrary Swing content; `createPopupChooserBuilder(list)` for item selection; `createActionGroupPopup()` for action menus.
- Show with `showInBestPositionFor(editor)`, `showUnderneathOf(component)`, or `showInCenterOf(component)`.
### Lists and Trees
- `JBList` not `JList` — adds empty text, busy indicator, tooltip truncation.
- `Tree` not `JTree` — adds wide selection painting, auto-scroll on DnD.
- Custom renderers: `ColoredListCellRenderer` / `ColoredTreeCellRenderer``append()` for styled text, `setIcon()` for icons.
- Speed search: `ListSpeedSearch(list)` / `TreeSpeedSearch(tree)`.
- Editable list with add/remove/reorder toolbar: `ToolbarDecorator.createDecorator(list).setAddAction { }.setRemoveAction { }.createPanel()`.
@@ -1,17 +1,110 @@
plugins {
alias(libs.plugins.rpc)
alias(libs.plugins.kotlin)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.openapi.generator)
}
kotlin {
jvmToolchain(21)
}
val generatedApi = layout.buildDirectory.dir("generated/openapi/src/main/kotlin")
sourceSets {
main {
resources.srcDir(layout.buildDirectory.dir("generated/cli"))
kotlin.srcDir(generatedApi)
}
}
openApiGenerate {
generatorName.set("kotlin")
library.set("jvm-okhttp4")
inputSpec.set("${rootDir}/../sdk/openapi.json")
outputDir.set(layout.buildDirectory.dir("generated/openapi").get().asFile.absolutePath)
packageName.set("ai.kilocode.jetbrains.api")
apiPackage.set("ai.kilocode.jetbrains.api.client")
modelPackage.set("ai.kilocode.jetbrains.api.model")
configOptions.set(mapOf(
"serializationLibrary" to "moshi",
"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",
))
// Normalise OpenAPI 3.1 → 3.0-compatible patterns
openapiNormalizer.set(mapOf(
"SIMPLIFY_ANYOF_STRING_AND_ENUM_STRING" to "true",
"SIMPLIFY_ONEOF_ANYOF" to "true",
))
generateApiTests.set(false)
generateModelTests.set(false)
generateApiDocumentation.set(false)
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 {
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)
}
}
}
tasks.named("compileKotlin") {
dependsOn(fixGeneratedApi)
}
val cliDir = layout.buildDirectory.dir("generated/cli/cli")
val production = providers.gradleProperty("production").map { it.toBoolean() }.orElse(false)
@@ -61,8 +154,14 @@ tasks.processResources {
dependencies {
intellijPlatform {
intellijIdea(libs.versions.intellij.platform)
bundledModule("intellij.platform.kernel.backend")
bundledModule("intellij.platform.rpc.backend")
bundledModule("intellij.platform.backend")
}
implementation(project(":shared"))
implementation(libs.okhttp)
implementation(libs.okhttp.sse)
implementation(libs.moshi)
implementation(libs.moshi.kotlin)
}
@@ -0,0 +1,42 @@
@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()
}
@@ -0,0 +1,14 @@
@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()
}
}
}
@@ -0,0 +1,325 @@
package ai.kilocode.server
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
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
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
import okhttp3.Request
import okhttp3.Response
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.AtomicReference
sealed class ConnectionState {
data object Disconnected : ConnectionState()
data object Connecting : ConnectionState()
data class Connected(val port: Int, val password: String) : ConnectionState()
data class Error(val message: String) : ConnectionState()
}
data class SseEvent(val type: String, val data: String)
/**
* App-level service managing the CLI server connection.
*
* Uses two separate OkHttp clients mirroring the VS Code architecture:
* - [apiClient]: no call/read timeout — used for the generated API client and SSE
* - [healthClient]: 3 s timeout — used only for `/global/health` polling
*
* The generated [DefaultApi] is configured with [apiClient] and exposed via [api]
* for typed access to all CLI server endpoints.
*/
@Service(Service.Level.APP)
class KiloConnectionService(private val cs: CoroutineScope) : Disposable {
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
private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""")
}
private val _state = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val state: StateFlow<ConnectionState> = _state.asStateFlow()
private val _events = MutableSharedFlow<SseEvent>(extraBufferCapacity = 64)
val events: SharedFlow<SseEvent> = _events.asSharedFlow()
/** Generated API client — null when disconnected. */
var api: DefaultApi? = null
private set
private var apiClient: OkHttpClient? = null
private var healthClient: OkHttpClient? = null
private var port = 0
private var password = ""
private val source = AtomicReference<EventSource?>(null)
private val lastEvent = AtomicLong(0L)
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()
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. */
suspend fun restart() {
LOG.info("restart: initiated — tearing down current connection")
teardown()
LOG.info("restart: teardown complete — spawning new CLI process")
open()
LOG.info("restart: open() returned — CLI process started")
}
/** Kill the CLI process, re-extract the binary from JAR, and restart. */
suspend fun reinstall() {
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)")
open()
LOG.info("reinstall: open() returned — CLI process started with fresh binary")
}
/**
* Full teardown: cancel all jobs, close SSE, shutdown HTTP clients, kill process.
*
* 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)")
reconnectJob?.cancel()
heartbeatJob?.cancel()
healthJob?.cancel()
processJob?.cancel()
LOG.info("teardown: closing SSE event source")
source.getAndSet(null)?.cancel()
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")
}
private suspend fun open() {
source.getAndSet(null)?.cancel()
close()
processJob?.cancel()
healthJob?.cancel()
setState(ConnectionState.Connecting)
val cli = service<ServerManager>()
val result = cli.init()
if (result is ServerManager.ServerState.Error) {
setState(ConnectionState.Error(result.message))
return
}
val ready = result as ServerManager.ServerState.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)
apiClient = ac
healthClient = hc
// Configure generated API client with the no-timeout api client
api = DefaultApi(basePath = "http://127.0.0.1:$port", client = ac)
startSse()
startHeartbeatWatcher()
healthJob = healthLoop()
cli.process()?.let { proc ->
processJob = monitorProcess(proc)
}
}
private fun startSse() {
val http = apiClient ?: return
val request = Request.Builder()
.url("http://127.0.0.1:$port/global/event")
.header("Accept", "text/event-stream")
.build()
val factory = EventSources.createFactory(
http.newBuilder()
.callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
)
source.set(factory.newEventSource(request, listener))
LOG.info("SSE: connecting to port $port")
}
private val listener = object : EventSourceListener() {
override fun onOpen(src: EventSource, response: Response) {
LOG.info("SSE: connected")
setState(ConnectionState.Connected(port, password))
lastEvent.set(System.currentTimeMillis())
}
override fun onEvent(src: EventSource, id: String?, type: String?, data: String) {
lastEvent.set(System.currentTimeMillis())
val kind = type ?: extractType(data)
cs.launch { _events.emit(SseEvent(type = kind, data = data)) }
}
override fun onClosed(src: EventSource) {
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")
} else {
LOG.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect")
}
setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})"))
scheduleReconnect()
}
}
private fun scheduleReconnect() {
if (reconnectJob?.isActive == true) return
reconnectJob = cs.launch {
delay(RECONNECT_DELAY_MS)
if (!isActive) return@launch
val cli = service<ServerManager>()
val proc = cli.process()
if (proc?.isAlive == true) {
LOG.info("SSE: reconnecting")
source.getAndSet(null)?.cancel()
setState(ConnectionState.Connecting)
startSse()
return@launch
}
LOG.warn("CLI process not running — restarting")
open()
}
}
private fun startHeartbeatWatcher() {
heartbeatJob?.cancel()
heartbeatJob = cs.launch {
while (isActive) {
delay(1_000)
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")
source.getAndSet(null)?.cancel()
scheduleReconnect()
}
}
}
}
private fun healthLoop() = cs.launch(Dispatchers.IO) {
while (isActive) {
delay(HEALTH_POLL_INTERVAL_MS)
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")
source.getAndSet(null)?.cancel()
scheduleReconnect()
}
}
}
private fun checkHealth(): Boolean {
val http = healthClient ?: return false
return try {
val req = Request.Builder()
.url("http://127.0.0.1:$port/global/health")
.build()
http.newCall(req).execute().use { it.isSuccessful }
} catch (e: Exception) {
LOG.info("Health check exception: ${e.message}")
false
}
}
private fun monitorProcess(proc: Process) = cs.launch(Dispatchers.IO) {
proc.waitFor()
service<ServerManager>().exited(proc)
val code = proc.exitValue()
LOG.warn("CLI process exited with code $code")
source.getAndSet(null)?.cancel()
setState(ConnectionState.Error("CLI process exited with code $code"))
scheduleReconnect()
}
private fun close() {
api = null
apiClient?.let { KiloHttpClients.shutdown(it) }
apiClient = null
healthClient?.let { KiloHttpClients.shutdown(it) }
healthClient = null
}
private fun setState(next: ConnectionState) {
_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 =
TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown"
override fun dispose() {
source.getAndSet(null)?.cancel()
heartbeatJob?.cancel()
healthJob?.cancel()
processJob?.cancel()
reconnectJob?.cancel()
close()
setState(ConnectionState.Disconnected)
LOG.info("KiloConnectionService disposed")
}
}
@@ -0,0 +1,58 @@
package ai.kilocode.server
import okhttp3.ConnectionPool
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.util.Base64
import java.util.concurrent.TimeUnit
/**
* Factory for the two OkHttp clients used by the plugin.
*
* Mirrors the VS Code architecture:
* - [api] client has no call/read timeout (streaming ops like prompt/SSE can run long)
* - [health] client has a short 3 s timeout and a small dedicated connection pool
*
* Both clients bundle Basic Auth via an interceptor and are fully independent
* of any IntelliJ-platform-provided HTTP stack.
*/
object KiloHttpClients {
private const val CONNECT_TIMEOUT_MS = 10_000L
private const val HEALTH_TIMEOUT_MS = 3_000L
/** API client — no call/read timeout (SSE and long-running ops). */
fun api(password: String): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(auth(password))
.connectTimeout(CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(0, TimeUnit.MILLISECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
/** Health client — short timeout, dedicated connection pool. */
fun health(password: String): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(auth(password))
.connectTimeout(HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.connectionPool(ConnectionPool(1, 30, TimeUnit.SECONDS))
.build()
/** Shut down both dispatcher and connection pool for the given client. */
fun shutdown(client: OkHttpClient) {
client.dispatcher.executorService.shutdown()
client.connectionPool.evictAll()
}
private fun auth(password: String): Interceptor {
val header = "Basic ${Base64.getEncoder().encodeToString("kilo:$password".toByteArray())}"
return Interceptor { chain ->
chain.proceed(
chain.request().newBuilder()
.header("Authorization", header)
.build()
)
}
}
}
@@ -0,0 +1,80 @@
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)
}
}
@@ -0,0 +1,298 @@
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) }
}
}
@@ -1,6 +1,11 @@
<idea-plugin>
<dependencies>
<module name="intellij.platform.backend"/>
<module name="intellij.platform.kernel.backend"/>
<module name="kilo.jetbrains.shared"/>
</dependencies>
<extensions defaultExtensionNs="com.intellij">
<platform.rpc.backend.remoteApiProvider implementation="ai.kilocode.rpc.KiloProjectRpcApiProvider"/>
</extensions>
</idea-plugin>
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.rpc)
alias(libs.plugins.kotlin)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
@@ -0,0 +1,140 @@
@file:Suppress("UnstableApiUsage")
package ai.kilocode
import ai.kilocode.rpc.KiloProjectRpcApi
import ai.kilocode.rpc.dto.ConnectionStateDto
import ai.kilocode.rpc.dto.ConnectionStatusDto
import ai.kilocode.rpc.dto.HealthDto
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
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/**
* Frontend project-level 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.
*/
@Service(Service.Level.PROJECT)
class KiloApiService(
private val project: Project,
private val cs: CoroutineScope,
) {
companion object {
private val LOG = Logger.getInstance(KiloApiService::class.java)
private val init = ConnectionStateDto(ConnectionStatusDto.DISCONNECTED)
}
private val started = AtomicBoolean(false)
/** CLI version string from the last successful health check, or null if unknown. */
@Volatile
var version: String? = null
private set
val state: StateFlow<ConnectionStateDto> = flow {
durable {
KiloProjectRpcApi.getInstance()
.state(project.projectId())
.collect { emit(it) }
}
}.stateIn(cs, SharingStarted.Eagerly, init)
fun connect() {
if (!started.compareAndSet(false, true)) return
cs.launch {
durable {
KiloProjectRpcApi.getInstance().connect(project.projectId())
}
}
}
/** One-shot health check. Returns null on failure. */
suspend fun health(): HealthDto? = try {
durable { KiloProjectRpcApi.getInstance().health(project.projectId()) }
} catch (e: Exception) {
LOG.warn("health check failed", e)
null
}
/** Kill the CLI process and restart it. */
suspend fun restart() {
LOG.info("restart: resetting state and sending RPC")
started.set(false)
version = null
durable { KiloProjectRpcApi.getInstance().restart(project.projectId()) }
LOG.info("restart: RPC returned — backend restart complete")
}
/** Kill the CLI process, re-extract the binary, and restart. */
suspend fun reinstall() {
LOG.info("reinstall: resetting state and sending RPC")
started.set(false)
version = null
durable { KiloProjectRpcApi.getInstance().reinstall(project.projectId()) }
LOG.info("reinstall: RPC returned — backend reinstall complete")
}
/** Fire-and-forget restart from non-suspend context (e.g. action handlers). */
fun restartAsync() {
LOG.info("restartAsync: launching restart")
cs.launch { restart() }
}
/** Fire-and-forget reinstall from non-suspend context (e.g. action handlers). */
fun reinstallAsync() {
LOG.info("reinstallAsync: launching reinstall")
cs.launch { reinstall() }
}
/** Fetch the CLI version and cache it. Call once after connection is established. */
fun fetchVersionAsync() {
cs.launch {
LOG.info("fetchVersion: requesting health check")
val dto = health()
if (dto == null) {
LOG.warn("fetchVersion: health check returned null — version not available")
return@launch
}
version = dto.version
LOG.info("fetchVersion: CLI version is ${dto.version}")
}
}
fun watch(fn: (String) -> Unit): Job {
val mgr = ToolWindowManager.getInstance(project)
return cs.launch {
state.collect { next ->
// Fetch CLI version when we become connected
if (next.status == ConnectionStatusDto.CONNECTED) fetchVersionAsync()
mgr.invokeLater {
fn(text(next))
}
}
}
}
private fun text(state: ConnectionStateDto): String =
when (state.status) {
ConnectionStatusDto.DISCONNECTED -> KiloBundle.message("toolwindow.status.disconnected")
ConnectionStatusDto.CONNECTING -> KiloBundle.message("toolwindow.status.connecting")
ConnectionStatusDto.CONNECTED -> KiloBundle.message("toolwindow.status.connected")
ConnectionStatusDto.ERROR -> KiloBundle.message(
"toolwindow.status.error",
state.error ?: KiloBundle.message("toolwindow.error.unknown"),
)
}
}
@@ -0,0 +1,12 @@
package ai.kilocode
import com.intellij.DynamicBundle
import org.jetbrains.annotations.PropertyKey
private const val BUNDLE = "messages.KiloBundle"
object KiloBundle : DynamicBundle(BUNDLE) {
fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any): String {
return getMessage(key, *params)
}
}
@@ -0,0 +1,65 @@
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()
}
}
@@ -0,0 +1,36 @@
package ai.kilocode.actions
import com.intellij.openapi.actionSystem.ActionGroup
import com.intellij.openapi.actionSystem.ActionManager
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.ui.popup.JBPopupFactory
/**
* Gear icon action placed in the Kilo tool window title bar.
*
* Looks up [Kilo.SettingsGroup] from [ActionManager] and shows it
* as a popup. The group composition is declared in
* `kilo.jetbrains.frontend.xml`.
*/
class KiloSettingsAction : AnAction() {
companion object {
const val GROUP_ID = "Kilo.SettingsGroup"
}
override fun actionPerformed(e: AnActionEvent) {
val component = e.inputEvent?.component ?: return
val group = ActionManager.getInstance().getAction(GROUP_ID) as? ActionGroup ?: return
JBPopupFactory.getInstance()
.createActionGroupPopup(
null,
group,
e.dataContext,
JBPopupFactory.ActionSelectionAid.SPEEDSEARCH,
true, // showDisabledActions — StatusInfoAction is always disabled
)
.showUnderneathOf(component)
}
}
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,18 @@
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
}
}
@@ -0,0 +1,31 @@
package ai.kilocode.actions
import ai.kilocode.KiloApiService
import ai.kilocode.KiloBundle
import ai.kilocode.rpc.dto.ConnectionStatusDto
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.components.service
/**
* Non-interactive info row at the bottom of the settings popup showing
* connection status and CLI version (from the last health check).
*/
class StatusInfoAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
// intentionally non-actionable
}
override fun update(e: AnActionEvent) {
val svc = e.project?.service<KiloApiService>() ?: return
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")
}
val ver = svc.version?.let { " · $it" } ?: ""
e.presentation.text = "$status$ver"
e.presentation.isEnabled = false
}
}
@@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M512 0H0V512H512V0Z" fill="black"/>
<path d="M322 377H377V421H307.857L278 391.143V322H322V377ZM421 307.857L391.143 278H322V322L377 322V377H421V307.857ZM234 278H190V322H234V278ZM91 391.143L120.857 421H234V377H135V278H91V391.143ZM371.172 189.999V120.856L341.315 90.9995H278V135H327.172V189.999H278V233.999H421V189.999H371.172ZM135 91H91V233.999H135V184.5H190V233.999H234V184.5L190 140.5H135V91ZM234 91H190V140.5H234V91Z" fill="#FAF74F"/>
</svg>

After

Width:  |  Height:  |  Size: 558 B

@@ -0,0 +1,4 @@
<svg width="64" height="64" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M512 0H0V512H512V0Z" fill="black"/>
<path d="M512 512H0V0H512V512ZM322.783 322.784H278.261V392.747L308.472 422.958H378.435V378.437H322.782L322.783 322.784ZM422.957 308.474L392.746 278.263H322.783V322.784H378.435L378.435 378.437H422.957L422.957 308.474ZM233.739 278.263H189.217V322.784H233.739V278.263ZM89.0435 392.747L119.254 422.958H233.739V378.437H133.565V278.263H89.043L89.0435 392.747ZM372.538 189.217V119.254L342.327 89.0435H278.261V133.565H328.017V189.217H278.261V233.739H422.957V189.217H372.538ZM133.565 89.0435H89.0435V233.739H133.565V183.652H189.218V233.739H233.74V183.652L189.218 139.13H133.565V89.0435ZM233.739 89.0435H189.217L189.218 139.13H233.739V89.0435Z" fill="#FAF74F"/>
</svg>

After

Width:  |  Height:  |  Size: 810 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 16H0V0H16V16ZM10.09 10.09H8.7V12.27L9.64 13.22H11.83V11.83H10.09L10.09 10.09ZM13.22 9.64L12.27 8.7H10.09V10.09H11.83L11.83 11.83H13.22L13.22 9.64ZM7.3 8.7H5.91V10.09H7.3V8.7ZM2.78 12.27L3.73 13.22H7.3V11.83H4.17V8.7H2.78L2.78 12.27ZM11.64 5.91V3.73L10.7 2.78H8.7V4.17H10.25V5.91H8.7V7.3H13.22V5.91H11.64ZM4.17 2.78H2.78V7.3H4.17V5.74H5.91V7.3H7.3V5.74L5.91 4.35H4.17V2.78ZM7.3 2.78H5.91L5.91 4.35H7.3V2.78Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 20H0V0H20V20ZM12.61 12.61H10.87V15.34L12.05 16.52H14.78V14.78H12.61L12.61 12.61ZM16.52 12.05L15.34 10.87H12.61V12.61H14.78L14.78 14.78H16.52L16.52 12.05ZM9.13 10.87H7.39V12.61H9.13V10.87ZM3.48 15.34L4.66 16.52H9.13V14.78H5.22V10.87H3.48L3.48 15.34ZM14.55 7.39V4.66L13.37 3.48H10.87V5.22H12.81V7.39H10.87V9.13H16.52V7.39H14.55ZM5.22 3.48H3.48V9.13H5.22V7.17H7.39V9.13H9.13V7.17L7.39 5.43H5.22V3.48ZM9.13 3.48H7.39L7.39 5.43H9.13V3.48Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 612 B

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 20H0V0H20V20ZM12.61 12.61H10.87V15.34L12.05 16.52H14.78V14.78H12.61L12.61 12.61ZM16.52 12.05L15.34 10.87H12.61V12.61H14.78L14.78 14.78H16.52L16.52 12.05ZM9.13 10.87H7.39V12.61H9.13V10.87ZM3.48 15.34L4.66 16.52H9.13V14.78H5.22V10.87H3.48L3.48 15.34ZM14.55 7.39V4.66L13.37 3.48H10.87V5.22H12.81V7.39H10.87V9.13H16.52V7.39H14.55ZM5.22 3.48H3.48V9.13H5.22V7.17H7.39V9.13H9.13V7.17L7.39 5.43H5.22V3.48ZM9.13 3.48H7.39L7.39 5.43H9.13V3.48Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 612 B

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 16H0V0H16V16ZM10.09 10.09H8.7V12.27L9.64 13.22H11.83V11.83H10.09L10.09 10.09ZM13.22 9.64L12.27 8.7H10.09V10.09H11.83L11.83 11.83H13.22L13.22 9.64ZM7.3 8.7H5.91V10.09H7.3V8.7ZM2.78 12.27L3.73 13.22H7.3V11.83H4.17V8.7H2.78L2.78 12.27ZM11.64 5.91V3.73L10.7 2.78H8.7V4.17H10.25V5.91H8.7V7.3H13.22V5.91H11.64ZM4.17 2.78H2.78V7.3H4.17V5.74H5.91V7.3H7.3V5.74L5.91 4.35H4.17V2.78ZM7.3 2.78H5.91L5.91 4.35H7.3V2.78Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

@@ -3,4 +3,35 @@
<module name="intellij.platform.frontend"/>
<module name="kilo.jetbrains.shared"/>
</dependencies>
<resource-bundle>messages.KiloBundle</resource-bundle>
<extensions defaultExtensionNs="com.intellij">
<toolWindow id="Kilo Code"
anchor="left"
icon="/icons/kilo.svg"
factoryClass="ai.kilocode.KiloToolWindowFactory"/>
</extensions>
<actions>
<action id="Kilo.Restart"
class="ai.kilocode.actions.RestartKiloAction"/>
<action id="Kilo.Reinstall"
class="ai.kilocode.actions.ReinstallKiloAction"/>
<action id="Kilo.StatusInfo"
class="ai.kilocode.actions.StatusInfoAction"/>
<group id="Kilo.SettingsGroup">
<reference ref="Kilo.Restart"/>
<reference ref="Kilo.Reinstall"/>
<separator/>
<reference ref="Kilo.StatusInfo"/>
</group>
<action id="Kilo.Settings"
class="ai.kilocode.actions.KiloSettingsAction"
icon="AllIcons.General.GearPlain"/>
</actions>
</idea-plugin>
@@ -0,0 +1,19 @@
toolwindow.status.disconnected=Status: Disconnected
toolwindow.status.connecting=Status: Connecting...
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.disconnected.short=Disconnected
toolwindow.status.error.short=Error
action.Kilo.Settings.text=Settings
action.Kilo.Settings.description=Kilo Code settings
action.Kilo.SettingsGroup.text=Settings
action.Kilo.SettingsGroup.description=Kilo Code settings
action.Kilo.Restart.text=Restart Kilo
action.Kilo.Restart.description=Kill and restart the CLI process
action.Kilo.Reinstall.text=Reinstall Kilo
action.Kilo.Reinstall.description=Re-extract the CLI binary and restart
@@ -1,12 +1,24 @@
[versions]
intellij-platform = "2025.3"
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"
okhttp = "4.12.0"
moshi = "1.15.1"
openapi-generator = "7.12.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" }
[plugins]
intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" }
rpc = { id = "rpc", version.ref = "intellij-rpc-plugin" }
kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin-jvm-plugin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin-serialization-plugin" }
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin-jvm-plugin" }
openapi-generator = { id = "org.openapi.generator", version.ref = "openapi-generator" }
@@ -1,5 +1,7 @@
plugins {
alias(libs.plugins.rpc)
alias(libs.plugins.kotlin)
alias(libs.plugins.kotlin.serialization)
}
kotlin {
@@ -0,0 +1,42 @@
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)
}
@@ -0,0 +1,17 @@
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,9 @@
package ai.kilocode.rpc.dto
import kotlinx.serialization.Serializable
@Serializable
data class HealthDto(
val healthy: Boolean,
val version: String,
)