Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
db41227
feat(jetbrains): run configs from worktree editor
kirillk Aug 20, 2026
0af7cd6
fix(jetbrains): rebase nested run config paths onto worktree
kirillk Aug 20, 2026
715e07f
fix(jetbrains): harden worktree run controls
kirillk Aug 21, 2026
dd900b7
docs: add jetbrains split-mode root plan
kirillk Aug 21, 2026
11ba989
feat(jetbrains): build and rebuild actions in worktree run popup
kirillk Aug 21, 2026
0342514
Merge remote-tracking branch 'origin/main' into investigate-jetbrains…
kirillk Aug 23, 2026
9162a87
fix(jetbrains): address worktree run review feedback
kirillk Aug 23, 2026
e43dd7f
fix(jetbrains): harden worktree run lifecycle and share the state stream
kirillk Aug 24, 2026
ab7fca9
fix(jetbrains): localize worktree run actions
kirillk Aug 24, 2026
432f66e
chore: merge main into worktree run branch
kirillk Aug 25, 2026
1747e05
fix(jetbrains): close worktree run release race and project identity …
kirillk Aug 25, 2026
2cf15cb
Merge remote-tracking branch 'origin/main' into investigate-jetbrains…
kirillk Aug 26, 2026
441fc80
fix(jetbrains): fake KiloRunService in AgentManagerPanelTest
kirillk Aug 26, 2026
b967e00
Merge remote-tracking branch 'origin/main' into investigate-jetbrains…
kirillk Aug 26, 2026
a63ba4c
Merge remote-tracking branch 'origin/main' into investigate-jetbrains…
kirillk Aug 27, 2026
b09f78c
Merge remote-tracking branch 'origin/main' into investigate-jetbrains…
kirillk Aug 28, 2026
c65cb69
fix(jetbrains): clarify worktree build and run controls
kirillk Aug 28, 2026
ce412e1
Merge branch 'main' into investigate-jetbrains-agent-manager-run-feature
kirillk Aug 28, 2026
5f86ac2
fix(jetbrains): isolate worktree removal tests from live RPC
kirillk Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/jetbrains-split-mode-run-configs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Fix worktree stats, pull request badges, and GitHub CLI status not loading when the IDE runs in split mode or remote development.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-worktree-header-run-dropdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": patch
---

Relabel the worktree editor's Run button to Build/Run with a dropdown arrow, move Open next to it, and make Terminal an icon-only button.
5 changes: 5 additions & 0 deletions .changeset/jetbrains-worktree-run-configs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-jetbrains": minor
---

Run IDE run configurations per worktree from the JetBrains Agent Manager: the worktree editor header gains a Run dropdown that starts supported run configurations (Gradle and command-line style types) inside the worktree, shows and stops running processes, and opens their output in the Run tool window. Build and Rebuild actions compile the worktree with the project's build tool. Stopping behaves like the IDE's own Stop button, including a second press that force-kills processes that support it. The popup hints to open the worktree in a new frame for full run and debug support.
6 changes: 6 additions & 0 deletions packages/kilo-jetbrains/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,12 @@ For the full release process (resolve version, pin verification, prepare, change
- **Corrupt IDE extraction**: if `runIdeBackend` or `runIdeSplitMode` fails before startup with `coroutinesJavaAgentFile` / `Collection contains no element matching the predicate`, the extracted IDE under `.intellijPlatform/ides/` is likely incomplete. Health check: `ls .intellijPlatform/ides/*/lib/*.jar | wc -l` should be in the hundreds. Repair by removing `.intellijPlatform/ides`, `.intellijPlatform/localPlatformArtifacts`, `.intellijPlatform/layoutIndex`, and `.intellijPlatform/coroutines-javaagent.jar`, then rerun the Gradle task.
- **Run in monolithic sandbox**: `./gradlew runIde` — launches sandboxed IntelliJ with the plugin. Does not build or bundle CLI binaries; the backend downloads the pinned release at connect time.

### Stopping a Sandbox Run

Quit the sandbox IDE from inside it (File → Exit) instead of pressing Stop on the Gradle run tab. Stop on an external-system configuration only calls `CancellationTokenSource.cancel()` through the Gradle tooling API — the IDE never learns the forked JVM's pid, sends it no signal, and `ExternalSystemProcessHandler` does not implement `KillableProcess`, so there is no force-kill escalation either. Quitting the sandbox IDE lets it run its real shutdown sequence and the `JavaExec` task then completes on its own. Cancelling the build instead is what leaves the orphaned Java processes noted above.

Do not start a second `runIde*` task for a checkout while a sandbox IDE launched from that same checkout is still running. All `runIde*` tasks share one sandbox container per checkout (`.intellijPlatform/sandbox/kilo.jetbrains/<ide>/plugins_runIde*`), and `prepareSandbox` rewrites the running IDE's own plugin jars. The IDE then attempts a hot reload that cannot succeed and reports `Failed to unload modified plugins: Kilo Code`.

### CLI/SDK Change Awareness

- JetBrains runtime behavior normally depends on the downloaded CLI release pinned by `packages/kilo-jetbrains/package.json`; local `packages/opencode/` changes are used only with `kilo.cli.pinned=false` repo CLI mode.
Expand Down
8 changes: 7 additions & 1 deletion packages/kilo-jetbrains/backend/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import normalization.NormalizeOpenApiSpecTask
import org.gradle.api.GradleException
import org.gradle.api.tasks.Exec
import org.gradle.api.tasks.WriteProperties
import org.jetbrains.intellij.platform.gradle.TestFrameworkType

plugins {
alias(libs.plugins.rpc)
Expand Down Expand Up @@ -203,6 +204,7 @@ dependencies {
bundledModule("intellij.platform.kernel.backend")
bundledModule("intellij.platform.rpc.backend")
bundledModule("intellij.platform.backend")
testFramework(TestFrameworkType.Platform)
}

implementation(project(":shared"))
Expand All @@ -212,10 +214,14 @@ dependencies {
implementation(libs.kotlinx.serialization.json)

testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(kotlin("test"))
testImplementation(libs.junit)
testRuntimeOnly(libs.junit.vintage.engine)
}

tasks.test {
// BasePlatformTestCase uses JUnit 3 test naming (test prefix), discovered by the
// vintage engine via JUnit Platform; plain JUnit 5 tests keep running unchanged.
useJUnitPlatform()
jvmArgs("-Didea.force.use.core.classloader=true")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package ai.kilocode.backend.rpc

import ai.kilocode.backend.run.WorktreeRunManager
import ai.kilocode.log.KiloLog
import ai.kilocode.rpc.KiloRunRpcApi
import ai.kilocode.rpc.dto.RunConfigListDto
import ai.kilocode.rpc.dto.RunResultDto
import ai.kilocode.rpc.dto.RunStateDto
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.openapi.util.io.FileUtil
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import java.nio.file.Path

/**
* Backend implementation of [KiloRunRpcApi]. Resolves the open project owning [directory]
* (the main repository root of the Agent Manager surface) and delegates to its
* project-level [WorktreeRunManager].
*/
class KiloRunRpcApiImpl : KiloRunRpcApi {

companion object {
private val LOG = KiloLog.create(KiloRunRpcApiImpl::class.java)
}

override suspend fun configs(directory: String): RunConfigListDto {
val project = resolve(directory) ?: return RunConfigListDto(error = "no open project for $directory")
return project.getService(WorktreeRunManager::class.java).configs()
}

override suspend fun run(directory: String, id: String, worktree: String): RunResultDto {
val project = resolve(directory) ?: return RunResultDto(error = "no open project for $directory")
return project.getService(WorktreeRunManager::class.java).run(id, worktree)
}

override suspend fun build(directory: String, worktree: String, clean: Boolean): RunResultDto {
val project = resolve(directory) ?: return RunResultDto(error = "no open project for $directory")
return project.getService(WorktreeRunManager::class.java).build(worktree, clean)
}

override suspend fun stop(directory: String, id: String, worktree: String): Boolean {
val project = resolve(directory) ?: return false
return project.getService(WorktreeRunManager::class.java).stop(id, worktree)
}

override suspend fun focus(directory: String, id: String, worktree: String): Boolean {
val project = resolve(directory) ?: return false
return project.getService(WorktreeRunManager::class.java).focus(id, worktree)
}

override suspend fun release(directory: String, worktree: String): Boolean {
val project = resolve(directory) ?: return false
return project.getService(WorktreeRunManager::class.java).release(worktree)
}

override suspend fun states(directory: String): Flow<List<RunStateDto>> {
val project = resolve(directory) ?: run {
LOG.warn("worktree run states: no open project for $directory")
return flowOf(emptyList())
}
return project.getService(WorktreeRunManager::class.java).states
}

/**
* Maps [directory] to its open project by filesystem identity. Uses [FileUtil.pathsEqual], which
* already treats trailing slashes as equal and honors platform case sensitivity, after a
* best-effort [Path.normalize] to collapse `.`/`..`. Deliberately not the workspace URL decoder,
* which would null out on a lone `%` and compare case-sensitively.
*/
private fun resolve(directory: String): Project? {
val target = norm(directory)
return ProjectManager.getInstance().openProjects.firstOrNull {
!it.isDefault && !it.isDisposed &&
(FileUtil.pathsEqual(norm(it.basePath), target) || FileUtil.pathsEqual(norm(it.presentableUrl), target))
}
}

private fun norm(path: String?): String? =
path?.let { runCatching { Path.of(it).normalize().toString() }.getOrDefault(it) }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@file:Suppress("UnstableApiUsage")

package ai.kilocode.backend.rpc

import ai.kilocode.rpc.KiloRunRpcApi
import com.intellij.platform.rpc.backend.RemoteApiProvider
import fleet.rpc.remoteApiDescriptor

internal class KiloRunRpcApiProvider : RemoteApiProvider {
override fun RemoteApiProvider.Sink.remoteApis() {
remoteApi(remoteApiDescriptor<KiloRunRpcApi>()) {
KiloRunRpcApiImpl()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
package ai.kilocode.backend.run

import ai.kilocode.log.KiloLog
import com.intellij.execution.CommonProgramRunConfigurationParameters
import com.intellij.execution.RunManager
import com.intellij.execution.RunnerAndConfigurationSettings
import com.intellij.execution.configurations.ModuleBasedConfiguration
import com.intellij.execution.configurations.RunConfiguration
import com.intellij.openapi.externalSystem.model.ProjectSystemId
import com.intellij.openapi.externalSystem.model.execution.ExternalSystemTaskExecutionSettings
import com.intellij.openapi.externalSystem.service.execution.ExternalSystemRunConfiguration
import java.nio.file.Path

/**
* Decides which run configurations can be transplanted into a git worktree and builds the
* transient per-worktree clone that the platform execution pipeline runs.
*
* Supported:
* - External-system (Gradle) configurations: the clone's external project path is mapped onto
* the worktree, so the worktree's own wrapper builds and runs the worktree's code (the Gradle
* plugin explicitly handles unlinked project paths by falling back to the path's wrapper).
* - Command-line style configurations implementing [CommonProgramRunConfigurationParameters]:
* the clone's working directory is mapped onto the worktree and WORKTREE_PATH/REPO_PATH env
* vars are injected (same contract as the VS Code Agent Manager run scripts).
*
* Paths are rebased rather than replaced, because both fields commonly point at a subproject
* (`<repo>/packages/kilo-jetbrains`) rather than the repository root; see [rebase].
*
* Module-classpath configurations (plain JVM Application, JUnit, ...) are excluded: even with
* a worktree working directory they would execute the main checkout's compiled classes.
*
* The adapter also synthesizes the worktree build ([buildSettings]). A real IDE Build Project is
* impossible here: `ProjectTaskManager` describes work as [com.intellij.openapi.module.Module]
* instances, which only exist for an open project, so a worktree directory can never be its target.
* Running the external system's own build tasks against the worktree copy of a linked root is the
* reachable equivalent, and relies on the same unlinked-path support as the transplanted configs.
*/
internal object WorktreeRunAdapter {
const val WORKTREE_ENV = "WORKTREE_PATH"
const val REPO_ENV = "REPO_PATH"

/** Gradle's own debug switch, read by its always-injected `JvmDebugInit` script. */
const val DEBUGGER_ENV = "DEBUGGER_ENABLED"

/**
* Build tasks per external system id, mirroring what the IDE's delegated build runs.
*
* `classes`/`testClasses` compile main and test sources without packaging or running tests,
* which is what Build Project does; unqualified names run in the root project and every
* subproject. `build` is deliberately not used — it also runs tests and checks.
*
* Rebuild prepends `clean`. The IDE instead injects `outputs.upToDateWhen { false }` on
* `AbstractCompile`, but that init script is generated from imported per-module Gradle paths,
* which an unlinked worktree path does not have.
*/
private val TASKS = mapOf("GRADLE" to listOf("classes", "testClasses"))

private const val CLEAN_TASK = "clean"

private val LOG = KiloLog.create(WorktreeRunAdapter::class.java)

/**
* Serialized project-root macro. Values loaded from disk are already expanded by
* `RunnerAndConfigurationSettingsImpl.readExternal`, but a field edited in the current
* session can still hold the raw macro, which would expand against the main checkout.
*/
private const val PROJECT_MACRO = "\$PROJECT_DIR\$"

fun supports(config: RunConfiguration): Boolean {
if (config is ExternalSystemRunConfiguration) return true
if (config !is CommonProgramRunConfigurationParameters) return false
return config !is ModuleBasedConfiguration<*, *>
}

/**
* Builds a transient per-worktree clone of [settings] named `"<name> [label]"`. The clone is
* never registered in [RunManager]; reusing the same instance per (config, worktree) key gives
* natural restart semantics via the platform's `restartRunProfile`. Returns null when the
* configuration type is not supported.
*/
fun transplant(
manager: RunManager,
settings: RunnerAndConfigurationSettings,
worktree: String,
repo: String,
label: String,
): RunnerAndConfigurationSettings? {
val source = settings.configuration
if (!supports(source)) return null
// ExternalSystemRunConfiguration.clone() returns null when its factory is missing or the
// serialization round trip fails; treat that as unsupported instead of crashing the run.
val clone = source.clone() ?: run {
LOG.warn("worktree run: clone failed for ${source.name}")
return null
}
clone.name = "${source.name} [$label]"
// A "Build project" pre-step would build the main checkout, not the worktree.
clone.beforeRunTasks = emptyList()
// Restart on re-run: the platform stops the previous process of the same settings first.
clone.isAllowRunningInParallel = false
when (clone) {
is ExternalSystemRunConfiguration -> {
clone.settings.externalProjectPath = rebase(clone.settings.externalProjectPath, repo, worktree)
clone.settings.env = clone.settings.env + env(worktree, repo)
}
is CommonProgramRunConfigurationParameters -> {
clone.workingDirectory = rebase(clone.workingDirectory, repo, worktree)
clone.envs = clone.envs + env(worktree, repo)
}
}
val result = manager.createConfiguration(clone, settings.factory)
result.isActivateToolWindowBeforeRun = true
return result
}

/** Whether [system] has a known build task mapping, i.e. whether its roots can be built. */
fun buildable(system: ProjectSystemId): Boolean = TASKS.containsKey(system.id)

/** Build tasks for [system]; empty when unknown. [clean] prepends `clean` for Rebuild. */
fun buildTasks(system: ProjectSystemId, clean: Boolean): List<String> {
val tasks = TASKS[system.id] ?: return emptyList()
return if (clean) listOf(CLEAN_TASK) + tasks else tasks
}

/**
* Task settings that build [root] — a linked external project root of the main checkout — inside
* [worktree]. The path is rebased exactly like a transplanted configuration's, so a root nested
* at `<repo>/packages/kilo-jetbrains` builds `<worktree>/packages/kilo-jetbrains`, and a root
* that is the repository itself builds the worktree root.
*/
fun buildSettings(
system: ProjectSystemId,
root: String,
worktree: String,
repo: String,
clean: Boolean,
): ExternalSystemTaskExecutionSettings {
val settings = ExternalSystemTaskExecutionSettings()
settings.externalSystemIdString = system.id
settings.externalProjectPath = rebase(root, repo, worktree)
settings.taskNames = buildTasks(system, clean)
settings.env = env(worktree, repo)
return settings
}

/**
* Maps a configured path onto the worktree so nested projects keep working:
* `<repo>/packages/kilo-jetbrains` becomes `<worktree>/packages/kilo-jetbrains`, which keeps
* subproject task names such as `:runIdeSplitMode` resolvable.
*
* - blank, the repo root, or the bare project macro resolve to the worktree root
* - relative paths resolve against the worktree
* - absolute paths already inside [worktree] are kept, so managed worktrees living under
* `<repo>/.kilo/worktrees/<name>` are never nested a second time
* - absolute paths under [repo] are rebased onto [worktree]
* - absolute paths outside [repo] are kept as configured, since they are not part of the
* transplanted tree (an external tool or data directory)
*/
fun rebase(path: String?, repo: String, worktree: String): String {
val raw = path?.trim().orEmpty()
val root = Path.of(worktree).normalize()
if (raw.isEmpty() || raw == PROJECT_MACRO) return worktree
if (raw.startsWith(PROJECT_MACRO)) {
val rest = raw.substring(PROJECT_MACRO.length).trimStart('/', '\\')
return if (rest.isEmpty()) root.toString() else root.resolve(Path.of(rest)).normalize().toString()
}
val target = runCatching { Path.of(raw) }.getOrNull() ?: return raw
if (!target.isAbsolute) return root.resolve(target).normalize().toString()
val normalized = target.normalize()
if (normalized.startsWith(root)) return normalized.toString()
val main = Path.of(repo).normalize()
if (!normalized.startsWith(main)) return raw
val rel = main.relativize(normalized)
return if (rel.toString().isEmpty()) root.toString() else root.resolve(rel).normalize().toString()
}

private fun env(worktree: String, repo: String) = mapOf(
WORKTREE_ENV to worktree,
REPO_ENV to repo,
// Neutralize an inherited Gradle debug session. When the IDE itself was launched by
// "Debug" on a Gradle task, its own environment carries DEBUGGER_ENABLED/DEBUGGER_ID, and
// ExternalSystemTaskExecutionSettings.isPassParentEnvs is true by default, so a worktree
// execution inherits them. Gradle always injects the JvmDebugInit script, which then
// instruments every forked start task and reads the idea.debugger.dispatch.port system
// property that only a real debug session sets — failing the task outright. Worktree
// executions always use the Run executor, so debugging is never wanted here. An explicit
// "false" is required: omitting the key cannot unset an inherited value.
DEBUGGER_ENV to "false",
)
}
Loading
Loading