diff --git a/packages/kilo-jetbrains/.gitignore b/packages/kilo-jetbrains/.gitignore index 8eaae9e5aa..c4bc302c02 100644 --- a/packages/kilo-jetbrains/.gitignore +++ b/packages/kilo-jetbrains/.gitignore @@ -2,4 +2,15 @@ build/ .intellijPlatform/ .ai/ +**/.kilo +.classpath +.project +.settings/ +.kotlin/ +bin/ +**/.classpath +**/.project +**/.settings/ +**/.kotlin/ +**/bin/ !gradle/wrapper/gradle-wrapper.jar diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md new file mode 100644 index 0000000000..d884b225f0 --- /dev/null +++ b/packages/kilo-jetbrains/AGENTS.md @@ -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 `` 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 `` 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 ``, not ``. The allowed top-level registration tags are limited; keep module XMLs focused on ``, ``, ``, ``, ``, and ``. +- 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`, 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 `` (or ``). +- **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()` 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` `` entries ↔ module XML descriptors (`kilo.jetbrains.{shared,frontend,backend}.xml`) +- Service classes ↔ ``/`` 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: ``. +- 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()`. diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index 296cd4e8d8..192b1437d7 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -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) } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiImpl.kt new file mode 100644 index 0000000000..8af77e89aa --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiImpl.kt @@ -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 = + 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() +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiProvider.kt new file mode 100644 index 0000000000..6910d0b018 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiProvider.kt @@ -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()) { + KiloProjectRpcApiImpl() + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloConnectionService.kt new file mode 100644 index 0000000000..11d506225f --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloConnectionService.kt @@ -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.Disconnected) + val state: StateFlow = _state.asStateFlow() + + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _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(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().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().stop() + LOG.info("teardown: complete") + } + + private suspend fun open() { + source.getAndSet(null)?.cancel() + close() + processJob?.cancel() + healthJob?.cancel() + + setState(ConnectionState.Connecting) + + val cli = service() + 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() + 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().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") + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloHttpClients.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloHttpClients.kt new file mode 100644 index 0000000000..134b435ce1 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloHttpClients.kt @@ -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() + ) + } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloProjectService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloProjectService.kt new file mode 100644 index 0000000000..4de0e4a9ca --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloProjectService.kt @@ -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 + 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) + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/ServerManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/ServerManager.kt new file mode 100644 index 0000000000..95bf4a0135 --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/ServerManager.kt @@ -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? = 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 { + 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) } + } +} diff --git a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml index 220ca388a9..d72b0528c2 100644 --- a/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml +++ b/packages/kilo-jetbrains/backend/src/main/resources/kilo.jetbrains.backend.xml @@ -1,6 +1,11 @@ + + + + + diff --git a/packages/kilo-jetbrains/frontend/build.gradle.kts b/packages/kilo-jetbrains/frontend/build.gradle.kts index 1219093259..8643bc6eb9 100644 --- a/packages/kilo-jetbrains/frontend/build.gradle.kts +++ b/packages/kilo-jetbrains/frontend/build.gradle.kts @@ -1,6 +1,7 @@ plugins { + alias(libs.plugins.rpc) alias(libs.plugins.kotlin) - alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlin.serialization) } kotlin { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloApiService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloApiService.kt new file mode 100644 index 0000000000..57f75eef78 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloApiService.kt @@ -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 = 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"), + ) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloBundle.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloBundle.kt new file mode 100644 index 0000000000..bf71e23ba4 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloBundle.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloToolWindowFactory.kt new file mode 100644 index 0000000000..21aae45737 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloToolWindowFactory.kt @@ -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() + 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() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/KiloSettingsAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/KiloSettingsAction.kt new file mode 100644 index 0000000000..0b775d9a0c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/KiloSettingsAction.kt @@ -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) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/ReinstallKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/ReinstallKiloAction.kt new file mode 100644 index 0000000000..2ff0084ee9 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/ReinstallKiloAction.kt @@ -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()?.reinstallAsync() + } + + override fun update(e: AnActionEvent) { + val state = e.project?.service()?.state?.value + e.presentation.isEnabled = state?.status != ConnectionStatusDto.CONNECTING + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/RestartKiloAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/RestartKiloAction.kt new file mode 100644 index 0000000000..5cb2af149e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/RestartKiloAction.kt @@ -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()?.restartAsync() + } + + override fun update(e: AnActionEvent) { + val state = e.project?.service()?.state?.value + e.presentation.isEnabled = state?.status != ConnectionStatusDto.CONNECTING + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/StatusInfoAction.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/StatusInfoAction.kt new file mode 100644 index 0000000000..d9a0fc9351 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/actions/StatusInfoAction.kt @@ -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() ?: 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 + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-content.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-content.svg new file mode 100644 index 0000000000..3c44cd2f56 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-content.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-content_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-content_dark.svg new file mode 100644 index 0000000000..1b0773f264 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo-content_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo.svg new file mode 100644 index 0000000000..6262b080fe --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo@20x20.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo@20x20.svg new file mode 100644 index 0000000000..c8d6d27cba --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo@20x20.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo@20x20_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo@20x20_dark.svg new file mode 100644 index 0000000000..9dccff1eb8 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo@20x20_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo_dark.svg b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo_dark.svg new file mode 100644 index 0000000000..b993c2131d --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/icons/kilo_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index 58d7b183a3..0d9063dd27 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -3,4 +3,35 @@ + + messages.KiloBundle + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties new file mode 100644 index 0000000000..edb1029efc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -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 diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index 63bbebf0e7..e092ab8aa5 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -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" } diff --git a/packages/kilo-jetbrains/shared/build.gradle.kts b/packages/kilo-jetbrains/shared/build.gradle.kts index 6e02f97926..cc14cbfdc7 100644 --- a/packages/kilo-jetbrains/shared/build.gradle.kts +++ b/packages/kilo-jetbrains/shared/build.gradle.kts @@ -1,5 +1,7 @@ plugins { + alias(libs.plugins.rpc) alias(libs.plugins.kotlin) + alias(libs.plugins.kotlin.serialization) } kotlin { diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt new file mode 100644 index 0000000000..311b1af1f4 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt @@ -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 { + companion object { + suspend fun getInstance(): KiloProjectRpcApi { + return RemoteApiProviderService.resolve(remoteApiDescriptor()) + } + } + + /** Ensure the CLI backend is running and connected. */ + suspend fun connect(projectId: ProjectId) + + /** Observe connection state changes. */ + suspend fun state(projectId: ProjectId): Flow + + /** 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) +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConnectionStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConnectionStateDto.kt new file mode 100644 index 0000000000..24b02dc93f --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ConnectionStateDto.kt @@ -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, +) diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/HealthDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/HealthDto.kt new file mode 100644 index 0000000000..d6272fc7e5 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/HealthDto.kt @@ -0,0 +1,9 @@ +package ai.kilocode.rpc.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class HealthDto( + val healthy: Boolean, + val version: String, +)