diff --git a/packages/kilo-jetbrains/backend/build.gradle.kts b/packages/kilo-jetbrains/backend/build.gradle.kts index a8a13c67af1..e6710433666 100644 --- a/packages/kilo-jetbrains/backend/build.gradle.kts +++ b/packages/kilo-jetbrains/backend/build.gradle.kts @@ -2,18 +2,88 @@ 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. +// - Boolean const enums: `enum class Foo(val value: kotlin.Boolean) { TRUE("true") }` → fix string→boolean +val fixGeneratedApi by tasks.registering { + dependsOn("openApiGenerate") + val dir = generatedApi + doLast { + dir.get().asFile.walkTopDown().filter { it.extension == "kt" }.forEach { file -> + var text = file.readText() + var changed = false + // Fix: enum Xxx(val value: kotlin.Boolean) { @Json(name = "true") TRUE("true") } + // → enum Xxx(val value: kotlin.Boolean) { @Json(name = "true") TRUE(true) } + val boolEnum = Regex( + """(enum class \w+\(val value: kotlin\.Boolean\) \{[^}]*?@Json\(name = ")(true|false)("\) \w+\()"(true|false)"(\))""" + ) + val replaced = boolEnum.replace(text) { m -> + "${m.groupValues[1]}${m.groupValues[2]}${m.groupValues[3]}${m.groupValues[4]}${m.groupValues[5]}" + } + if (replaced != text) { + text = replaced + changed = true + } + if (changed) 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) @@ -71,4 +141,6 @@ dependencies { 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/KiloBackendRpcApi.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloBackendRpcApi.kt deleted file mode 100644 index 2124ea3d2f0..00000000000 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloBackendRpcApi.kt +++ /dev/null @@ -1,18 +0,0 @@ -@file:Suppress("UnstableApiUsage") - -package ai.kilocode.rpc - -import ai.kilocode.rpc.dto.ConnectionStateDto -import ai.kilocode.server.KiloConnectionService -import com.intellij.openapi.components.service -import kotlinx.coroutines.flow.Flow - -class KiloBackendRpcApi : KiloRpcApi { - override suspend fun connect() { - service().connect() - } - - override suspend fun state(): Flow { - return service().stream() - } -} 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 00000000000..37e410699fc --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiImpl.kt @@ -0,0 +1,36 @@ +@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() +} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloBackendRpcApiProvider.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiProvider.kt similarity index 58% rename from packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloBackendRpcApiProvider.kt rename to packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiProvider.kt index 2f21af34a39..6910d0b0181 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloBackendRpcApiProvider.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApiProvider.kt @@ -5,10 +5,10 @@ package ai.kilocode.rpc import com.intellij.platform.rpc.backend.RemoteApiProvider import fleet.rpc.remoteApiDescriptor -internal class KiloBackendRpcApiProvider : RemoteApiProvider { +internal class KiloProjectRpcApiProvider : RemoteApiProvider { override fun RemoteApiProvider.Sink.remoteApis() { - remoteApi(remoteApiDescriptor()) { - KiloBackendRpcApi() + 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 index b50580c7f1e..6b2f12d58c4 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -17,16 +18,15 @@ 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 kotlinx.coroutines.flow.map import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import okhttp3.sse.EventSource import okhttp3.sse.EventSourceListener import okhttp3.sse.EventSources -import java.util.Base64 import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference @@ -40,6 +40,16 @@ sealed class 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 { @@ -47,7 +57,6 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable { 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 HEALTH_TIMEOUT_MS = 3_000L private const val RECONNECT_DELAY_MS = 250L private val TYPE_REGEX = Regex(""""type"\s*:\s*"([^"]+)"""") } @@ -58,7 +67,12 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable { private val _events = MutableSharedFlow(extraBufferCapacity = 64) val events: SharedFlow = _events.asSharedFlow() - private var client: OkHttpClient? = null + /** 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 = "" @@ -73,7 +87,6 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable { suspend fun connect() { if (_state.value is ConnectionState.Connected || _state.value is ConnectionState.Connecting) return - open() } @@ -82,32 +95,29 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable { close() processJob?.cancel() healthJob?.cancel() - + setState(ConnectionState.Connecting) val cli = service() - val processState = cli.init() + val result = cli.init() - if (processState is ServerManager.ServerState.Error) { - setState(ConnectionState.Error(processState.message)) + if (result is ServerManager.ServerState.Error) { + setState(ConnectionState.Error(result.message)) return } - val ready = processState as ServerManager.ServerState.Ready + val ready = result as ServerManager.ServerState.Ready port = ready.port password = ready.password - client = OkHttpClient.Builder() - .addInterceptor { chain -> - val auth = Base64.getEncoder().encodeToString("kilo:$password".toByteArray()) - chain.proceed( - chain.request().newBuilder() - .header("Authorization", "Basic $auth") - .build() - ) - } - .callTimeout(HEALTH_TIMEOUT_MS, TimeUnit.MILLISECONDS) - .build() + // 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() @@ -118,7 +128,7 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable { } private fun startSse() { - val http = client ?: return + val http = apiClient ?: return val request = Request.Builder() .url("http://127.0.0.1:$port/global/event") .header("Accept", "text/event-stream") @@ -143,8 +153,8 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable { override fun onEvent(src: EventSource, id: String?, type: String?, data: String) { lastEvent.set(System.currentTimeMillis()) - val eventType = type ?: extractType(data) - cs.launch { _events.emit(SseEvent(type = eventType, data = data)) } + val kind = type ?: extractType(data) + cs.launch { _events.emit(SseEvent(type = kind, data = data)) } } override fun onClosed(src: EventSource) { @@ -215,7 +225,7 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable { } private fun checkHealth(): Boolean { - val http = client ?: return false + val http = healthClient ?: return false return try { val req = Request.Builder() .url("http://127.0.0.1:$port/global/health") @@ -238,25 +248,24 @@ class KiloConnectionService(private val cs: CoroutineScope) : Disposable { } private fun close() { - client?.let { http -> - http.dispatcher.executorService.shutdown() - http.connectionPool.evictAll() - } - client = null + 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 { - return when (state) { + 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" 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 00000000000..134b435ce1c --- /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 00000000000..87ae9a3fabe --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/server/KiloProjectService.kt @@ -0,0 +1,63 @@ +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.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, +) { + 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() + + /** + * 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 ?: throw IllegalStateException("Not connected") + val response = client.globalHealth() + 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 index 86d379b36b9..cca7e62bd86 100644 --- 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 @@ -78,6 +78,7 @@ class ServerManager(private val cs: CoroutineScope) : Disposable { 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) } @@ -136,13 +137,16 @@ class ServerManager(private val cs: CoroutineScope) : Disposable { put("KILO_APP_NAME", "kilo-code") } - val builder = ProcessBuilder(cli.absolutePath, "serve", "--port", "0") + val cmd = listOf(cli.absolutePath, "serve", "--port", "0") + val builder = ProcessBuilder(cmd) builder.environment().clear() builder.environment().putAll(env) builder.redirectErrorStream(false) - LOG.info("Spawning: ${cli.absolutePath} serve --port 0") + 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) 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 edec1fa93a6..d72b0528c2f 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 @@ -6,6 +6,6 @@ - + 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 index d3f5c42c808..6e4436c39d8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloApiService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/KiloApiService.kt @@ -2,12 +2,14 @@ package ai.kilocode -import ai.kilocode.rpc.KiloRpcApi +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.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 @@ -18,6 +20,13 @@ 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, @@ -31,7 +40,9 @@ class KiloApiService( val state: StateFlow = flow { durable { - KiloRpcApi.getInstance().state().collect { emit(it) } + KiloProjectRpcApi.getInstance() + .state(project.projectId()) + .collect { emit(it) } } }.stateIn(cs, SharingStarted.Eagerly, init) @@ -39,11 +50,18 @@ class KiloApiService( if (!started.compareAndSet(false, true)) return cs.launch { durable { - KiloRpcApi.getInstance().connect() + 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 (_: Exception) { + null + } + fun watch(fn: (String) -> Unit): Job { val mgr = ToolWindowManager.getInstance(project) return cs.launch { @@ -55,8 +73,8 @@ class KiloApiService( } } - private fun text(state: ConnectionStateDto): String { - return when (state.status) { + 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") @@ -65,5 +83,4 @@ class KiloApiService( state.error ?: KiloBundle.message("toolwindow.error.unknown"), ) } - } } diff --git a/packages/kilo-jetbrains/gradle/libs.versions.toml b/packages/kilo-jetbrains/gradle/libs.versions.toml index b7192649d44..e092ab8aa57 100644 --- a/packages/kilo-jetbrains/gradle/libs.versions.toml +++ b/packages/kilo-jetbrains/gradle/libs.versions.toml @@ -6,10 +6,14 @@ 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" } @@ -17,3 +21,4 @@ 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/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 00000000000..c5a677c9626 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloProjectRpcApi.kt @@ -0,0 +1,36 @@ +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 +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloRpcApi.kt deleted file mode 100644 index fa5c1b2b127..00000000000 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloRpcApi.kt +++ /dev/null @@ -1,21 +0,0 @@ -package ai.kilocode.rpc - -import ai.kilocode.rpc.dto.ConnectionStateDto -import com.intellij.platform.rpc.RemoteApiProviderService -import fleet.rpc.RemoteApi -import fleet.rpc.Rpc -import fleet.rpc.remoteApiDescriptor -import kotlinx.coroutines.flow.Flow - -@Rpc -interface KiloRpcApi : RemoteApi { - companion object { - suspend fun getInstance(): KiloRpcApi { - return RemoteApiProviderService.resolve(remoteApiDescriptor()) - } - } - - suspend fun connect() - - suspend fun state(): Flow -} 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 00000000000..d6272fc7e51 --- /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, +)