From b92677a58e3ddd1769b83dd91be818d38fde4362 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Thu, 30 Apr 2026 17:40:40 -0400 Subject: [PATCH 01/30] fix(cli): restore source file when reverting a move across folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Git's default rename detection in 'diff --cached --name-only' collapses a file move (A/foo -> B/foo) into a single destination entry. The snapshot revert iterates this list and deletes files that aren't in the pre-step tree — so B/foo was removed but A/foo was never restored, losing data. Pass --no-renames so both the source and destination paths appear in the patch, matching diffFull()'s behavior. Revert now deletes the new location and restores the original. Fixes #9741 --- .changeset/revert-file-moves.md | 6 + packages/kilo-docs/source-links.md | 4 +- packages/opencode/src/snapshot/index.ts | 7 +- .../kilocode/snapshot-revert-move.test.ts | 157 ++++++++++++++++++ 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 .changeset/revert-file-moves.md create mode 100644 packages/opencode/test/kilocode/snapshot-revert-move.test.ts diff --git a/.changeset/revert-file-moves.md b/.changeset/revert-file-moves.md new file mode 100644 index 00000000000..e1b3cbe7a7b --- /dev/null +++ b/.changeset/revert-file-moves.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Fix Revert losing data when the agent moves files between folders. Previously, reverting a task that moved a file would delete it from its new location without restoring the original — now both sides of the move are tracked and the file is correctly restored to its source folder. diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index d80641de90a..87599a8c86a 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -62,6 +62,8 @@ - +- + - - diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 6ac5b5b633a..9ccf512b7c9 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -338,12 +338,17 @@ export const layer: Layer.Layer< return yield* locked( Effect.gen(function* () { yield* add() + // kilocode_change start - --no-renames so file moves list both source and destination paths. + // Without this, Git's default rename detection collapses a move (A/foo -> B/foo) to + // just the destination. revert() then deletes B/foo but never restores A/foo. + // See https://github.com/Kilo-Org/kilocode/issues/9741. const result = yield* git( - [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], + [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--no-renames", "--name-only", hash, "--", "."])], { cwd: state.directory, }, ) + // kilocode_change end if (result.code !== 0) { log.warn("failed to get diff", { hash, exitCode: result.code }) return { hash, files: [] } diff --git a/packages/opencode/test/kilocode/snapshot-revert-move.test.ts b/packages/opencode/test/kilocode/snapshot-revert-move.test.ts new file mode 100644 index 00000000000..52e69dd2d25 --- /dev/null +++ b/packages/opencode/test/kilocode/snapshot-revert-move.test.ts @@ -0,0 +1,157 @@ +import { afterEach, test, expect } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { Effect } from "effect" +import { Snapshot } from "../../src/snapshot" +import { Instance } from "../../src/project/instance" +import { Filesystem } from "../../src/util" +import { provideInstance, tmpdir } from "../fixture/fixture" + +// Git always outputs /-separated paths internally. Snapshot.patch() joins them +// with path.join (which produces \ on Windows) then normalizes back to /. +const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") + +afterEach(async () => { + await Instance.disposeAll() +}) + +function run(dir: string, body: (snapshot: Snapshot.Interface) => Effect.Effect) { + return Effect.runPromise( + Effect.gen(function* () { + const snap = yield* Snapshot.Service + return yield* body(snap) + }).pipe(provideInstance(dir), Effect.provide(Snapshot.defaultLayer)), + ) +} + +// Regression test for https://github.com/Kilo-Org/kilocode/issues/9741 +// +// When the agent moves a file between folders (A/foo.ts -> B/foo.ts), revert must +// restore the original file to its source folder AND delete it from the destination. +// Git's default rename detection collapses a move to a single destination entry in +// `--name-only` output, which caused revert to only delete the destination while +// silently losing the source. The fix passes `--no-renames` so both paths appear. +test("revert restores original file after move across folders", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.mkdir(path.join(dir, "A"), { recursive: true }) + await Filesystem.write(`${dir}/A/foo.txt`, "original content") + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await run(tmp.path, (snap) => snap.track()) + expect(before).toBeTruthy() + + // Simulate "AI moves A/foo.txt -> B/foo.txt". + await fs.mkdir(path.join(tmp.path, "B"), { recursive: true }) + await fs.rename(path.join(tmp.path, "A", "foo.txt"), path.join(tmp.path, "B", "foo.txt")) + + const patch = await run(tmp.path, (snap) => snap.patch(before!)) + + // Both source and destination paths must be in the patch list so revert + // knows to restore the source and delete the destination. + expect(patch.files).toContain(fwd(tmp.path, "A", "foo.txt")) + expect(patch.files).toContain(fwd(tmp.path, "B", "foo.txt")) + + await run(tmp.path, (snap) => snap.revert([patch])) + + // Source must be restored with original content intact. + expect(await fs.readFile(path.join(tmp.path, "A", "foo.txt"), "utf-8")).toBe("original content") + + // Destination must be gone. + const destExists = await fs + .access(path.join(tmp.path, "B", "foo.txt")) + .then(() => true) + .catch(() => false) + expect(destExists).toBe(false) + }, + }) +}) + +// Real sessions can split the delete and the create across different agent steps +// (e.g. a write to B/foo.txt in one step, a delete of A/foo.txt in the next). +// revert() must work across multiple patches from different hashes. +test("revert restores original file across multi-step move", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await fs.mkdir(path.join(dir, "A"), { recursive: true }) + await Filesystem.write(`${dir}/A/foo.txt`, "original") + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const base = await run(tmp.path, (snap) => snap.track()) + expect(base).toBeTruthy() + + // Step 1: agent creates the new file at B/foo.txt. + await fs.mkdir(path.join(tmp.path, "B"), { recursive: true }) + await Filesystem.write(`${tmp.path}/B/foo.txt`, "original") + const patch1 = await run(tmp.path, (snap) => snap.patch(base!)) + const afterStep1 = await run(tmp.path, (snap) => snap.track()) + + // Step 2: agent deletes the source A/foo.txt. + await fs.unlink(path.join(tmp.path, "A", "foo.txt")) + const patch2 = await run(tmp.path, (snap) => snap.patch(afterStep1!)) + + // Each patch's files list should be scoped to what changed in that step. + expect(patch1.files).toContain(fwd(tmp.path, "B", "foo.txt")) + expect(patch2.files).toContain(fwd(tmp.path, "A", "foo.txt")) + + // Reverting both patches (in order, oldest first) must end with A/foo.txt restored + // and B/foo.txt removed. + await run(tmp.path, (snap) => snap.revert([patch1, patch2])) + + expect(await fs.readFile(path.join(tmp.path, "A", "foo.txt"), "utf-8")).toBe("original") + const destExists = await fs + .access(path.join(tmp.path, "B", "foo.txt")) + .then(() => true) + .catch(() => false) + expect(destExists).toBe(false) + }, + }) +}) + +// A plain rename within the same folder (no directory change) is the same Git +// operation. Without --no-renames this was also broken. +test("revert restores original file after rename within a folder", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Filesystem.write(`${dir}/old.txt`, "keep me") + await $`git add .`.cwd(dir).quiet() + await $`git commit --no-gpg-sign -m init`.cwd(dir).quiet() + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await run(tmp.path, (snap) => snap.track()) + expect(before).toBeTruthy() + + await fs.rename(path.join(tmp.path, "old.txt"), path.join(tmp.path, "new.txt")) + + const patch = await run(tmp.path, (snap) => snap.patch(before!)) + expect(patch.files).toContain(fwd(tmp.path, "old.txt")) + expect(patch.files).toContain(fwd(tmp.path, "new.txt")) + + await run(tmp.path, (snap) => snap.revert([patch])) + + expect(await fs.readFile(path.join(tmp.path, "old.txt"), "utf-8")).toBe("keep me") + const newExists = await fs + .access(path.join(tmp.path, "new.txt")) + .then(() => true) + .catch(() => false) + expect(newExists).toBe(false) + }, + }) +}) From 8db7b685837e015dc922825f03641a221e5becf7 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Sun, 14 Jun 2026 21:48:49 -0400 Subject: [PATCH 02/30] fix(cli): restore moved files during snapshot revert --- .changeset/revert-file-moves.md | 5 +++++ packages/opencode/src/snapshot/index.ts | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 .changeset/revert-file-moves.md diff --git a/.changeset/revert-file-moves.md b/.changeset/revert-file-moves.md new file mode 100644 index 00000000000..52e0a08868e --- /dev/null +++ b/.changeset/revert-file-moves.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Restore files to their original paths when reverting a task that moved or renamed them. diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 2189036abcd..372b5d93da6 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -420,7 +420,12 @@ export const layer: Layer.Layer = Effect.gen(function* () { yield* add() const result = yield* git( - [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], + // kilocode_change start + [ + ...quote, + ...args(["diff", "--cached", "--no-ext-diff", "--no-renames", "--name-only", hash, "--", "."]), + ], + // kilocode_change end { cwd: state.directory, }, From b0ce1b0b1212370944cd48017ef92ab4f4100e16 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Sun, 14 Jun 2026 23:04:33 -0400 Subject: [PATCH 03/30] test(cli): cover reverting moved files --- .../kilocode/snapshot-revert-move.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 packages/opencode/test/kilocode/snapshot-revert-move.test.ts diff --git a/packages/opencode/test/kilocode/snapshot-revert-move.test.ts b/packages/opencode/test/kilocode/snapshot-revert-move.test.ts new file mode 100644 index 00000000000..965c79090ce --- /dev/null +++ b/packages/opencode/test/kilocode/snapshot-revert-move.test.ts @@ -0,0 +1,42 @@ +import { expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import fs from "fs/promises" +import path from "path" +import { Effect, Layer } from "effect" +import { Snapshot } from "../../src/snapshot" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer)) +const fwd = (...parts: string[]) => path.join(...parts).replaceAll("\\", "/") +const write = (file: string, content: string) => AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content)) +const read = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file)) +const exists = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file)) +const mkdir = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir)) + +it.instance( + "restores both paths after moving a file", + Effect.gen(function* () { + const tmp = yield* TestInstance + const snapshot = yield* Snapshot.Service + const source = path.join(tmp.directory, "source", "file.txt") + const destination = path.join(tmp.directory, "moved folder", "file.txt") + + yield* write(source, "original content") + const before = yield* snapshot.track() + expect(before).toBeTruthy() + if (!before) throw new Error("snapshot tracking failed") + + yield* mkdir(path.dirname(destination)) + yield* Effect.promise(() => fs.rename(source, destination)) + + const patch = yield* snapshot.patch(before) + expect(patch.files).toContain(fwd(source)) + expect(patch.files).toContain(fwd(destination)) + + yield* snapshot.revert([patch]) + expect(yield* read(source)).toBe("original content") + expect(yield* exists(destination)).toBe(false) + }), + { git: true }, +) From af370bd56b11f9a6595199afd53f380b23a121df Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:21:19 +0000 Subject: [PATCH 04/30] ci(jetbrains): run inside pre-built container, drop manual setup steps Switch the JetBrains unit job to run inside ghcr.io/kilo-org/build/jetbrains:24.04, which already contains Bun, Java 21, and the Gradle 9.4.1 distribution pre-cached in GRADLE_USER_HOME. The three manual setup steps (bun, java, gradle) are no longer needed and are removed. A 'Mark workspace as git-safe' step is added after checkout because container jobs mount the workspace from the host with different file ownership. Git exit-128 errors otherwise occur when Gradle spawns a 'git tag' subprocess (build.gradle.kts:85). Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/workflows/test-jetbrains.yml | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test-jetbrains.yml b/.github/workflows/test-jetbrains.yml index 7fde0ec1ea3..06d033a3d0c 100644 --- a/.github/workflows/test-jetbrains.yml +++ b/.github/workflows/test-jetbrains.yml @@ -41,6 +41,8 @@ jobs: needs: changes if: github.event_name == 'workflow_dispatch' || needs.changes.outputs.jetbrains == 'true' runs-on: blacksmith-4vcpu-ubuntu-2404 + container: + image: ghcr.io/kilo-org/build/jetbrains:24.04 defaults: run: shell: bash @@ -50,19 +52,8 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - - name: Setup Bun - uses: ./.github/actions/setup-bun - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 - with: - cache-read-only: ${{ github.ref != 'refs/heads/main' }} + - name: Mark workspace as git-safe + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Run JetBrains unit tests run: bun script/test-ci.ts From 0b267c6bff371f0b573962f0f368ab9c81f39bb7 Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:03:05 +0000 Subject: [PATCH 05/30] ci(jetbrains): install bun dependencies inside container The container image has bun pre-installed but not the project's node_modules. Gradle's generateOpenApiSpec task runs 'bun dev generate' which needs drizzle-orm and other packages from node_modules. Add an explicit 'bun install' step after checkout. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/workflows/test-jetbrains.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-jetbrains.yml b/.github/workflows/test-jetbrains.yml index 06d033a3d0c..8469b98776a 100644 --- a/.github/workflows/test-jetbrains.yml +++ b/.github/workflows/test-jetbrains.yml @@ -55,6 +55,9 @@ jobs: - name: Mark workspace as git-safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Install dependencies + run: bun install + - name: Run JetBrains unit tests run: bun script/test-ci.ts working-directory: packages/kilo-jetbrains From c1bef8604a6e80e9890f56d207d44431e3c598fd Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:17:00 +0000 Subject: [PATCH 06/30] ci(jetbrains): install font libs needed by JBR inside container The JetBrains Runtime bundled with IntelliJ ships libfontmanager.so but expects libfreetype.so.6 and libfontconfig1 on the host system. The container image currently lacks these, causing UnsatisfiedLinkError in any test that initialises Swing font metrics. Install them explicitly until the updated container image (which will include these packages in the Dockerfile) is available. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/workflows/test-jetbrains.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-jetbrains.yml b/.github/workflows/test-jetbrains.yml index 8469b98776a..7392f7bab18 100644 --- a/.github/workflows/test-jetbrains.yml +++ b/.github/workflows/test-jetbrains.yml @@ -55,6 +55,9 @@ jobs: - name: Mark workspace as git-safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - name: Install font libraries for JBR + run: apt-get install -y --no-install-recommends libfreetype6 libfontconfig1 fonts-dejavu-core + - name: Install dependencies run: bun install From f40302719b71e7955360470eb19ad209d5d366fc Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:15:24 +0000 Subject: [PATCH 07/30] ci(jetbrains): refresh apt indexes before installing font libraries Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/workflows/test-jetbrains.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-jetbrains.yml b/.github/workflows/test-jetbrains.yml index 7392f7bab18..44d5f7d1f82 100644 --- a/.github/workflows/test-jetbrains.yml +++ b/.github/workflows/test-jetbrains.yml @@ -56,7 +56,10 @@ jobs: run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - name: Install font libraries for JBR - run: apt-get install -y --no-install-recommends libfreetype6 libfontconfig1 fonts-dejavu-core + run: | + apt-get update + apt-get install -y --no-install-recommends libfreetype6 libfontconfig1 fonts-dejavu-core + rm -rf /var/lib/apt/lists/* - name: Install dependencies run: bun install From 68c7755f2642190fd948691d37a50fbf97b9b23d Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:02:01 +0000 Subject: [PATCH 08/30] ci(jetbrains): rely on image-provided JBR libraries Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .github/workflows/test-jetbrains.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/test-jetbrains.yml b/.github/workflows/test-jetbrains.yml index 44d5f7d1f82..8469b98776a 100644 --- a/.github/workflows/test-jetbrains.yml +++ b/.github/workflows/test-jetbrains.yml @@ -55,12 +55,6 @@ jobs: - name: Mark workspace as git-safe run: git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Install font libraries for JBR - run: | - apt-get update - apt-get install -y --no-install-recommends libfreetype6 libfontconfig1 fonts-dejavu-core - rm -rf /var/lib/apt/lists/* - - name: Install dependencies run: bun install From f02deb55170c5038803edc1af34276048d37b038 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:57:12 +0000 Subject: [PATCH 09/30] feat(vscode): add top-level announcement notification --- .../webview-ui/src/context/notifications.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/webview-ui/src/context/notifications.tsx b/packages/kilo-vscode/webview-ui/src/context/notifications.tsx index 8c722585e6a..28b61656dd4 100644 --- a/packages/kilo-vscode/webview-ui/src/context/notifications.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/notifications.tsx @@ -11,6 +11,18 @@ import { import { useVSCode } from "./vscode" import type { KilocodeNotification, ExtensionMessage } from "../types/messages" +// Static notifications always shown unconditionally (not fetched from API, not dismissable) +const STATIC_NOTIFICATIONS: KilocodeNotification[] = [ + { + id: "star-giveaway-june-2026", + title: "GitHub Star Giveaway", + message: "GitHub Star Giveaway: $500 to 2 people who star us by June 24th", + action: { actionText: "Star us on GitHub", actionURL: "https://github.com/Kilo-Org/kilocode/" }, + }, +] + +const STATIC_IDS = new Set(STATIC_NOTIFICATIONS.map((n) => n.id)) + interface NotificationsContextValue { notifications: Accessor filteredNotifications: Accessor @@ -53,10 +65,13 @@ export const NotificationsProvider: ParentComponent = (props) => { const filteredNotifications = createMemo(() => { const dismissed = dismissedIds() - return notifications().filter((n) => !dismissed.includes(n.id)) + const api = notifications().filter((n) => !dismissed.includes(n.id)) + return [...STATIC_NOTIFICATIONS, ...api] }) const dismiss = (id: string) => { + // Static notifications are always shown and cannot be dismissed + if (STATIC_IDS.has(id)) return setDismissedIds((prev) => (prev.includes(id) ? prev : [...prev, id])) vscode.postMessage({ type: "dismissNotification", notificationId: id }) } From 1e0df0cbc483527c44accd3815679a2d376180d8 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Wed, 17 Jun 2026 17:20:54 +0000 Subject: [PATCH 10/30] release(jetbrains): v7.0.1-rc.11 --- packages/kilo-jetbrains/CHANGELOG.md | 8 ++++++++ packages/kilo-jetbrains/gradle.properties | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 12eb27370d9..1061a9a4836 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -32,6 +32,14 @@ ## [Unreleased] +## [7.0.1-rc.11] - 2026-06-17 + +### Changed +- refactor(cli): fork TUI tips by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11365 +- release(jetbrains): v7.0.1-rc.10 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/11374 +- ci(jetbrains): install deps before publish build by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11378 + + ## [7.0.1-rc.10] - 2026-06-17 ### Added diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index c87de87457e..a1cdd86dc7a 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.0.1-rc.10 +kilo.jetbrains.version=7.0.1-rc.11 org.gradle.configuration-cache=true org.gradle.caching=true org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=512m From 4592ddd4bebfffec09518c08521dff4794a106f6 Mon Sep 17 00:00:00 2001 From: Kirill Kalishev Date: Wed, 17 Jun 2026 13:21:44 -0400 Subject: [PATCH 11/30] docs(jetbrains): edit changelog for v7.0.1-rc.11 --- packages/kilo-jetbrains/CHANGELOG.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 1061a9a4836..cb9893acfd3 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -34,11 +34,18 @@ ## [7.0.1-rc.11] - 2026-06-17 -### Changed -- refactor(cli): fork TUI tips by @markijbema in https://github.com/Kilo-Org/kilocode/pull/11365 -- release(jetbrains): v7.0.1-rc.10 by @kilo-maintainer[bot] in https://github.com/Kilo-Org/kilocode/pull/11374 -- ci(jetbrains): install deps before publish build by @kirillk in https://github.com/Kilo-Org/kilocode/pull/11378 +### Added +- Provider settings management, including provider catalog sections, provider descriptions, provider settings actions, disconnect flows, provider auth handling, and provider/model picker improvements. +- Session copy controls for chat messages. + +### Fixed + +- Cap JetBrains prompt input growth and hide soft wrap glyphs in the prompt field. +- Keep JetBrains provider toolbars and authentication overlays fixed, and improve provider API key dialog sizing. +- Clean up restartless unload behavior. +- Silence interrupted session notifications across clients. +- Always deny tool calls for system agents. ## [7.0.1-rc.10] - 2026-06-17 From 7cd018af3a324818dd30d39c1bbf81894b4c12f7 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:37:44 +0000 Subject: [PATCH 12/30] fix(vscode): update star giveaway notification message and make dismissible --- .../webview-ui/src/context/notifications.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/context/notifications.tsx b/packages/kilo-vscode/webview-ui/src/context/notifications.tsx index 28b61656dd4..eaf21849ad9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/notifications.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/notifications.tsx @@ -11,18 +11,17 @@ import { import { useVSCode } from "./vscode" import type { KilocodeNotification, ExtensionMessage } from "../types/messages" -// Static notifications always shown unconditionally (not fetched from API, not dismissable) +// Static notifications always shown unconditionally (not fetched from API) const STATIC_NOTIFICATIONS: KilocodeNotification[] = [ { id: "star-giveaway-june-2026", title: "GitHub Star Giveaway", - message: "GitHub Star Giveaway: $500 to 2 people who star us by June 24th", - action: { actionText: "Star us on GitHub", actionURL: "https://github.com/Kilo-Org/kilocode/" }, + message: + "We're giving away $500 of AI Credits when we reach 25,000 stars on GitHub. Support us:", + action: { actionText: "github.com/Kilo-Org/kilocode", actionURL: "https://github.com/Kilo-Org/kilocode/" }, }, ] -const STATIC_IDS = new Set(STATIC_NOTIFICATIONS.map((n) => n.id)) - interface NotificationsContextValue { notifications: Accessor filteredNotifications: Accessor @@ -65,13 +64,11 @@ export const NotificationsProvider: ParentComponent = (props) => { const filteredNotifications = createMemo(() => { const dismissed = dismissedIds() - const api = notifications().filter((n) => !dismissed.includes(n.id)) - return [...STATIC_NOTIFICATIONS, ...api] + const all = [...STATIC_NOTIFICATIONS, ...notifications()] + return all.filter((n) => !dismissed.includes(n.id)) }) const dismiss = (id: string) => { - // Static notifications are always shown and cannot be dismissed - if (STATIC_IDS.has(id)) return setDismissedIds((prev) => (prev.includes(id) ? prev : [...prev, id])) vscode.postMessage({ type: "dismissNotification", notificationId: id }) } From ab1c90b68cff4c68ab5995368a5fa454a22afc79 Mon Sep 17 00:00:00 2001 From: noobezlol Date: Mon, 1 Jun 2026 13:54:43 +0530 Subject: [PATCH 13/30] fix(cli): handle string permission migration --- .../opencode/src/kilocode/config/config.ts | 18 +++-- packages/opencode/test/config/config.test.ts | 65 +++++++++++++++++++ 2 files changed, 78 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index 5621f65d837..5f9ad6a493f 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -2,7 +2,7 @@ import path from "path" import { pathToFileURL } from "url" import { existsSync } from "fs" import { Effect, Schema } from "effect" -import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser" +import { applyEdits, findNodeAtLocation, modify, parse as parseJsonc, parseTree } from "jsonc-parser" import { mergeDeep } from "remeda" import * as Log from "@opencode-ai/core/util/log" import { Global } from "@opencode-ai/core/global" @@ -373,16 +373,24 @@ export namespace KilocodeConfig { .catch(() => "{}") if (target.endsWith(".jsonc")) { - const edits = modify(text, ["permission", "bash"], "allow", { - formattingOptions: { insertSpaces: true, tabSize: 2 }, - }) + const tree = parseTree(text) + const permission = tree && findNodeAtLocation(tree, ["permission"]) + const edits = modify( + text, + permission && permission.type !== "object" ? ["permission"] : ["permission", "bash"], + permission && permission.type !== "object" ? { "*": permission.value, bash: "allow" } : "allow", + { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }, + ) await Bun.write(target, applyEdits(text, edits)) log.info("migrated bash permission to allow for existing user", { path: target }) return } const data = parseJsonc(text) ?? {} - const merged = { ...data, permission: { ...data.permission, bash: "allow" } } + const permission = isRecord(data.permission) ? data.permission : { "*": data.permission } + const merged = { ...data, permission: { ...permission, bash: "allow" } } await Bun.write(target, JSON.stringify(merged, null, 2)) log.info("migrated bash permission to allow for existing user", { path: target }) } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index e29d3b2c1a0..1cd6a6ceb44 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -4,6 +4,7 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" +import { KilocodeConfig } from "../../src/kilocode/config/config" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { InstanceRef } from "../../src/effect/instance-ref" @@ -371,6 +372,70 @@ test("updates global config and omits empty shell key in jsonc", async () => { } }) +test("migrates string-form global permission in jsonc without throwing", async () => { + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "kilo.jsonc"), + `{ + "$schema": "https://app.kilo.ai/config.json", + "permission": "allow" +}`, + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear(true) + + try { + await KilocodeConfig.migrateBashPermission() + + const file = path.join(globalTmp.path, "kilo.jsonc") + const writtenConfig = await Filesystem.readText(file) + const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file) + expect(parsed.permission?.["*"]).toBe("allow") + expect(parsed.permission?.bash).toBe("allow") + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + +test("migrates object-form global permission in jsonc", async () => { + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "kilo.jsonc"), + `{ + "$schema": "https://app.kilo.ai/config.json", + "permission": { + "read": "allow" + } +}`, + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear(true) + + try { + await KilocodeConfig.migrateBashPermission() + + const file = path.join(globalTmp.path, "kilo.jsonc") + const writtenConfig = await Filesystem.readText(file) + const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file) + expect(parsed.permission?.read).toBe("allow") + expect(parsed.permission?.bash).toBe("allow") + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + test("loads formatter boolean config", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 941007b68dfa9500ad604620ae861accf2a74643 Mon Sep 17 00:00:00 2001 From: noobezlol Date: Mon, 1 Jun 2026 17:18:44 +0530 Subject: [PATCH 14/30] test(cli): cover json permission migration --- .changeset/fix-string-permission-migration.md | 5 +++ packages/opencode/test/config/config.test.ts | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 .changeset/fix-string-permission-migration.md diff --git a/.changeset/fix-string-permission-migration.md b/.changeset/fix-string-permission-migration.md new file mode 100644 index 00000000000..99683ef8ecf --- /dev/null +++ b/.changeset/fix-string-permission-migration.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Handle string-form permission values when migrating bash permissions. diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 1cd6a6ceb44..cf441a8a16c 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -403,6 +403,37 @@ test("migrates string-form global permission in jsonc without throwing", async ( } }) +test("migrates string-form global permission in json without throwing", async () => { + await using globalTmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "kilo.json"), + JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", + permission: "allow", + }), + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = globalTmp.path + await clear(true) + + try { + await KilocodeConfig.migrateBashPermission() + + const writtenConfig = await Filesystem.readJson(path.join(globalTmp.path, "kilo.json")) + expect(writtenConfig.permission).toEqual({ + "*": "allow", + bash: "allow", + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear(true) + } +}) + test("migrates object-form global permission in jsonc", async () => { await using globalTmp = await tmpdir({ init: async (dir) => { From 4ff5df916012555c561737fee9e6eb5d03929623 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 17 Jun 2026 10:07:07 -0300 Subject: [PATCH 15/30] refactor: move test to kilo folder --- packages/opencode/test/config/config.test.ts | 96 ---------------- .../test/kilocode/config/config.test.ts | 105 +++++++++++++++++- 2 files changed, 104 insertions(+), 97 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index cf441a8a16c..e29d3b2c1a0 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -4,7 +4,6 @@ import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" -import { KilocodeConfig } from "../../src/kilocode/config/config" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { InstanceRef } from "../../src/effect/instance-ref" @@ -372,101 +371,6 @@ test("updates global config and omits empty shell key in jsonc", async () => { } }) -test("migrates string-form global permission in jsonc without throwing", async () => { - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "kilo.jsonc"), - `{ - "$schema": "https://app.kilo.ai/config.json", - "permission": "allow" -}`, - ) - }, - }) - - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - await clear(true) - - try { - await KilocodeConfig.migrateBashPermission() - - const file = path.join(globalTmp.path, "kilo.jsonc") - const writtenConfig = await Filesystem.readText(file) - const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file) - expect(parsed.permission?.["*"]).toBe("allow") - expect(parsed.permission?.bash).toBe("allow") - } finally { - ;(Global.Path as { config: string }).config = prev - await clear(true) - } -}) - -test("migrates string-form global permission in json without throwing", async () => { - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "kilo.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - permission: "allow", - }), - ) - }, - }) - - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - await clear(true) - - try { - await KilocodeConfig.migrateBashPermission() - - const writtenConfig = await Filesystem.readJson(path.join(globalTmp.path, "kilo.json")) - expect(writtenConfig.permission).toEqual({ - "*": "allow", - bash: "allow", - }) - } finally { - ;(Global.Path as { config: string }).config = prev - await clear(true) - } -}) - -test("migrates object-form global permission in jsonc", async () => { - await using globalTmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "kilo.jsonc"), - `{ - "$schema": "https://app.kilo.ai/config.json", - "permission": { - "read": "allow" - } -}`, - ) - }, - }) - - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = globalTmp.path - await clear(true) - - try { - await KilocodeConfig.migrateBashPermission() - - const file = path.join(globalTmp.path, "kilo.jsonc") - const writtenConfig = await Filesystem.readText(file) - const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file) - expect(parsed.permission?.read).toBe("allow") - expect(parsed.permission?.bash).toBe("allow") - } finally { - ;(Global.Path as { config: string }).config = prev - await clear(true) - } -}) - test("loads formatter boolean config", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index ba8a366b1d0..5bc45290c36 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -1,4 +1,3 @@ -// kilocode_change - new file import { afterEach, describe, expect, test } from "bun:test" import { Effect, Layer, Option, Schema } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" @@ -12,6 +11,7 @@ import { Account } from "../../../src/account/account" import { Auth } from "../../../src/auth" import { Config } from "../../../src/config/config" import { ConfigMarkdown } from "../../../src/config/markdown" +import { ConfigParse } from "../../../src/config/parse" import { Env } from "../../../src/env" import { KiloIndexing } from "../../../src/kilocode/indexing" import { KilocodeConfig } from "../../../src/kilocode/config/config" @@ -404,3 +404,106 @@ describe("agent config", () => { } }) }) + +describe("bash permission migration", () => { + test("migrates string-form global permission in jsonc without throwing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "kilo.jsonc"), + `{ + "$schema": "https://app.kilo.ai/config.json", + "permission": "allow" +}`, + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear() + await disposeAllInstances() + + try { + await KilocodeConfig.migrateBashPermission() + + const file = path.join(tmp.path, "kilo.jsonc") + const text = await Filesystem.readText(file) + const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(text, file), file) + expect(parsed.permission?.["*"]).toBe("allow") + expect(parsed.permission?.bash).toBe("allow") + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("migrates string-form global permission in json without throwing", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "kilo.json"), + JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", + permission: "allow", + }), + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear() + await disposeAllInstances() + + try { + await KilocodeConfig.migrateBashPermission() + + const parsed = await Filesystem.readJson(path.join(tmp.path, "kilo.json")) + expect(parsed.permission).toEqual({ + "*": "allow", + bash: "allow", + }) + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test("migrates object-form global permission in jsonc", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "kilo.jsonc"), + `{ + "$schema": "https://app.kilo.ai/config.json", + "permission": { + "read": "allow" + } +}`, + ) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear() + await disposeAllInstances() + + try { + await KilocodeConfig.migrateBashPermission() + + const file = path.join(tmp.path, "kilo.jsonc") + const text = await Filesystem.readText(file) + const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(text, file), file) + expect(parsed.permission?.read).toBe("allow") + expect(parsed.permission?.bash).toBe("allow") + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) +}) From 7e1deaf1cf3f4887e4a76c1e019d403b854d8e07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Wed, 17 Jun 2026 13:20:49 -0300 Subject: [PATCH 16/30] refactor: fix permission bypass --- .../opencode/src/kilocode/config/config.ts | 20 +-- .../test/kilocode/config/config.test.ts | 122 +++++++++--------- 2 files changed, 67 insertions(+), 75 deletions(-) diff --git a/packages/opencode/src/kilocode/config/config.ts b/packages/opencode/src/kilocode/config/config.ts index 5f9ad6a493f..7cf632ffdcf 100644 --- a/packages/opencode/src/kilocode/config/config.ts +++ b/packages/opencode/src/kilocode/config/config.ts @@ -2,7 +2,7 @@ import path from "path" import { pathToFileURL } from "url" import { existsSync } from "fs" import { Effect, Schema } from "effect" -import { applyEdits, findNodeAtLocation, modify, parse as parseJsonc, parseTree } from "jsonc-parser" +import { applyEdits, modify, parse as parseJsonc } from "jsonc-parser" import { mergeDeep } from "remeda" import * as Log from "@opencode-ai/core/util/log" import { Global } from "@opencode-ai/core/global" @@ -353,7 +353,7 @@ export namespace KilocodeConfig { .catch(() => "") const data = parseJsonc(text) ?? {} configs.push({ file, data }) - if (isRecord(data.permission) && data.permission.bash) return + if (typeof data.permission === "string" || (isRecord(data.permission) && data.permission.bash)) return } // A schema-only file is generated for editor completion. It does not mean @@ -373,24 +373,16 @@ export namespace KilocodeConfig { .catch(() => "{}") if (target.endsWith(".jsonc")) { - const tree = parseTree(text) - const permission = tree && findNodeAtLocation(tree, ["permission"]) - const edits = modify( - text, - permission && permission.type !== "object" ? ["permission"] : ["permission", "bash"], - permission && permission.type !== "object" ? { "*": permission.value, bash: "allow" } : "allow", - { - formattingOptions: { insertSpaces: true, tabSize: 2 }, - }, - ) + const edits = modify(text, ["permission", "bash"], "allow", { + formattingOptions: { insertSpaces: true, tabSize: 2 }, + }) await Bun.write(target, applyEdits(text, edits)) log.info("migrated bash permission to allow for existing user", { path: target }) return } const data = parseJsonc(text) ?? {} - const permission = isRecord(data.permission) ? data.permission : { "*": data.permission } - const merged = { ...data, permission: { ...permission, bash: "allow" } } + const merged = { ...data, permission: { ...data.permission, bash: "allow" } } await Bun.write(target, JSON.stringify(merged, null, 2)) log.info("migrated bash permission to allow for existing user", { path: target }) } diff --git a/packages/opencode/test/kilocode/config/config.test.ts b/packages/opencode/test/kilocode/config/config.test.ts index 5bc45290c36..b90c126aaf0 100644 --- a/packages/opencode/test/kilocode/config/config.test.ts +++ b/packages/opencode/test/kilocode/config/config.test.ts @@ -406,71 +406,71 @@ describe("agent config", () => { }) describe("bash permission migration", () => { - test("migrates string-form global permission in jsonc without throwing", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "kilo.jsonc"), - `{ + for (const action of ["allow", "ask", "deny"] as const) { + test(`preserves string-form ${action} permission in jsonc`, async () => { + const input = `{ "$schema": "https://app.kilo.ai/config.json", - "permission": "allow" -}`, - ) - }, - }) - - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = tmp.path - await clear() - await disposeAllInstances() - - try { - await KilocodeConfig.migrateBashPermission() - - const file = path.join(tmp.path, "kilo.jsonc") - const text = await Filesystem.readText(file) - const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(text, file), file) - expect(parsed.permission?.["*"]).toBe("allow") - expect(parsed.permission?.bash).toBe("allow") - } finally { - ;(Global.Path as { config: string }).config = prev - await clear() - await disposeAllInstances() - } - }) - - test("migrates string-form global permission in json without throwing", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "kilo.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - permission: "allow", - }), - ) - }, - }) - - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = tmp.path - await clear() - await disposeAllInstances() - - try { - await KilocodeConfig.migrateBashPermission() - - const parsed = await Filesystem.readJson(path.join(tmp.path, "kilo.json")) - expect(parsed.permission).toEqual({ - "*": "allow", - bash: "allow", + "permission": "${action}" +}` + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "kilo.jsonc"), input) + }, }) - } finally { - ;(Global.Path as { config: string }).config = prev + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path await clear() await disposeAllInstances() - } - }) + + try { + await KilocodeConfig.migrateBashPermission() + + const file = path.join(tmp.path, "kilo.jsonc") + const text = await Filesystem.readText(file) + const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(text, file), file) + expect(text).toBe(input) + expect(parsed.permission?.["*"]).toBe(action) + expect(parsed.permission?.bash).toBeUndefined() + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + + test(`preserves string-form ${action} permission in json`, async () => { + const input = JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", + permission: action, + }) + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "kilo.json"), input) + }, + }) + + const prev = Global.Path.config + ;(Global.Path as { config: string }).config = tmp.path + await clear() + await disposeAllInstances() + + try { + await KilocodeConfig.migrateBashPermission() + + const file = path.join(tmp.path, "kilo.json") + const text = await Filesystem.readText(file) + const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(text, file), file) + expect(text).toBe(input) + expect(parsed.permission?.["*"]).toBe(action) + expect(parsed.permission?.bash).toBeUndefined() + } finally { + ;(Global.Path as { config: string }).config = prev + await clear() + await disposeAllInstances() + } + }) + } test("migrates object-form global permission in jsonc", async () => { await using tmp = await tmpdir({ From 89b1829199405e864f4f8d074ded7f2e5daed380 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Wed, 17 Jun 2026 16:33:08 -0400 Subject: [PATCH 17/30] fix(cli): declare console diff dependency --- .github/workflows/typecheck.yml | 3 +++ bun.lock | 1 + packages/kilo-console/package.json | 1 + 3 files changed, 5 insertions(+) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index b4d0da57a9f..9931242f133 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -21,6 +21,9 @@ jobs: # kilocode_change start - name: Run TypeScript typecheck run: bun turbo typecheck --filter='!@kilocode/kilo-jetbrains' + + - name: Build Kilo Console + run: bun run --cwd packages/kilo-console build # kilocode_change end # kilocode_change start diff --git a/bun.lock b/bun.lock index 2c0e923620f..fe48c54bd73 100644 --- a/bun.lock +++ b/bun.lock @@ -108,6 +108,7 @@ "@kilocode/kilo-web-ui": "workspace:*", "@kilocode/sdk": "workspace:*", "@lottiefiles/dotlottie-web": "0.74.0", + "@pierre/diffs": "catalog:", "@solidjs/router": "catalog:", "ghostty-web": "0.4.0", "solid-js": "catalog:", diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 999c2cfa2f7..b002dd1077d 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -14,6 +14,7 @@ "@kilocode/kilo-web-ui": "workspace:*", "@kilocode/sdk": "workspace:*", "@lottiefiles/dotlottie-web": "0.74.0", + "@pierre/diffs": "catalog:", "@solidjs/router": "catalog:", "ghostty-web": "0.4.0", "solid-js": "catalog:", From 9ec1beb6e1e32c1f349da6e17b21fa3f2c5e57ae Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Wed, 17 Jun 2026 17:07:10 -0400 Subject: [PATCH 18/30] ci: cache console production build --- .github/workflows/typecheck.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 9931242f133..11ef4e1ab75 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -23,7 +23,7 @@ jobs: run: bun turbo typecheck --filter='!@kilocode/kilo-jetbrains' - name: Build Kilo Console - run: bun run --cwd packages/kilo-console build + run: bun turbo build --filter=@kilocode/kilo-console # kilocode_change end # kilocode_change start From 42617c0009a0a1a7ddf78ea35947670331321762 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:50:16 +0000 Subject: [PATCH 19/30] chore: update source-links.md with notifications.tsx URLs --- packages/kilo-docs/source-links.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index 49c8e1c151a..5a30e68d6ed 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -81,6 +81,8 @@ - +- + - - From ba0f49ac452747afa461aa9babf73bbd3b296a1d Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:50:38 +0000 Subject: [PATCH 20/30] style: fix prettier formatting in notifications.tsx --- packages/kilo-vscode/webview-ui/src/context/notifications.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/context/notifications.tsx b/packages/kilo-vscode/webview-ui/src/context/notifications.tsx index eaf21849ad9..67deb6f99c9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/notifications.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/notifications.tsx @@ -16,8 +16,7 @@ const STATIC_NOTIFICATIONS: KilocodeNotification[] = [ { id: "star-giveaway-june-2026", title: "GitHub Star Giveaway", - message: - "We're giving away $500 of AI Credits when we reach 25,000 stars on GitHub. Support us:", + message: "We're giving away $500 of AI Credits when we reach 25,000 stars on GitHub. Support us:", action: { actionText: "github.com/Kilo-Org/kilocode", actionURL: "https://github.com/Kilo-Org/kilocode/" }, }, ] From 9bb8a0ddf62bea686b8e2f046d4fb22da3d36bfd Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Thu, 18 Jun 2026 07:14:15 +0000 Subject: [PATCH 21/30] test(kilo-vscode): add settle function to visual regression tests Introduce a `settle` helper function to improve the stability of visual regression tests. The function ensures that fonts are loaded, animation frames have processed, and specific dynamic elements (like code blocks) have finished rendering before snapshots are taken. This helps reduce flakiness caused by asynchronous rendering in Storybook. Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- .../tests/visual-regression.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index 2fa3fc5c4af..4a205600376 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -45,6 +45,26 @@ async function disableAnimations(page: Page) { }) } +async function settle(page: Page) { + const frames = () => + page.evaluate( + () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }), + ) + + await page.evaluate(async () => { + await document.fonts.ready + }) + await frames() + await page.waitForFunction(() => { + const root = document.querySelector("#storybook-root") + return root && !root.querySelector('pre > code[data-lang]:not([data-lang="mermaid"])') + }) + await frames() +} + // Stories to skip from visual regression (add IDs here if needed) // Spinner animation captures at an indeterminate frame, causing flaky diffs. // Permission dock config-preloaded has non-deterministic toggle rendering. @@ -97,6 +117,7 @@ for (const story of stories) { ) await disableAnimations(page) await page.waitForSelector("#storybook-root *", { state: "attached" }) + await settle(page) const [component, variant] = story.id.split("--") const root = page.locator("#storybook-root") From 3d26a7c52bc26080c658170424736c3319c42d22 Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Thu, 18 Jun 2026 07:19:39 +0000 Subject: [PATCH 22/30] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 49ae8cbc98c..f532c00b9fa 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-qzU+msL1HicrkrYHNCitcutUqTFUatMArEuGgf/gOoE=", - "aarch64-linux": "sha256-GebxO5jgwnsNbvBdrbOlJ6u9C3/r6J7Q8WbzBv+wmhI=", - "aarch64-darwin": "sha256-NtAQ6QiL+iGaDxuFyvBiMFvBoEgBb5pK8b8xgkRdKx0=", - "x86_64-darwin": "sha256-rfxQmcEFsPD7AzSCBUZNxkNQUnkPnoN8a4shieEK1+Q=" + "x86_64-linux": "sha256-0BmSpfTtd6HSwwpFoOO2xv8WFiGG2jWTYh+wWGVXrQI=", + "aarch64-linux": "sha256-7xIsN/OSYYfe480/AISZclzc0mdw6GHzp9Iyj6gvsMA=", + "aarch64-darwin": "sha256-/qiT20m9l3Wr5UHpfh6VDhTuu2RmIbUkzngxMHgpL8g=", + "x86_64-darwin": "sha256-MkRgwFs/YVIXz1P+707godlSxwsTL2wv7JrZAGaYRMQ=" } } From ee3846fe24f77d7393279ab3a08123ea65ede5c0 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 10:11:30 +0200 Subject: [PATCH 23/30] docs(kilo-docs): remove managed indexing docs --- .../getting-started/switching-from-cline.md | 4 +- packages/kilo-docs/lib/nav/deploy-secure.ts | 1 - packages/kilo-docs/mappingplan.md | 1 - .../adoption-dashboard/for-team-leads.md | 6 +- .../improving-your-score.md | 4 +- .../kilo-docs/pages/deploy-secure/index.md | 12 +- .../pages/deploy-secure/managed-indexing.md | 121 ------------------ packages/kilo-docs/pages/index.tsx | 1 - packages/kilo-docs/previous-docs-redirects.js | 8 +- 9 files changed, 15 insertions(+), 143 deletions(-) delete mode 100644 packages/kilo-docs/pages/deploy-secure/managed-indexing.md diff --git a/packages/kilo-docs/docs/getting-started/switching-from-cline.md b/packages/kilo-docs/docs/getting-started/switching-from-cline.md index 8f04bb864ca..2c7dcd6be0c 100644 --- a/packages/kilo-docs/docs/getting-started/switching-from-cline.md +++ b/packages/kilo-docs/docs/getting-started/switching-from-cline.md @@ -202,9 +202,9 @@ One-click deployments from directly within Kilo. Go from code to production with Automatically analyzes your PRs using your choice of AI model. Reviews happen the moment a PR is opened or updated, covering performance, security, style, and test coverage. -### Managed Indexing +### Codebase Indexing -Semantic search across your repositories using cloud-hosted embeddings. Kilo indexes your codebase to deliver more relevant, context-aware responses. +Semantic search across your repositories using configurable embedding providers and vector stores. Kilo indexes your codebase to deliver more relevant, context-aware responses. ### Autocomplete diff --git a/packages/kilo-docs/lib/nav/deploy-secure.ts b/packages/kilo-docs/lib/nav/deploy-secure.ts index 7c41f137996..5120c5f5fb2 100644 --- a/packages/kilo-docs/lib/nav/deploy-secure.ts +++ b/packages/kilo-docs/lib/nav/deploy-secure.ts @@ -6,7 +6,6 @@ export const DeploySecureNav: NavSection[] = [ links: [ { href: "/deploy-secure", children: "Overview" }, { href: "/deploy-secure/deploy", children: "Deploy" }, - { href: "/deploy-secure/managed-indexing", children: "Managed Indexing" }, ], }, { diff --git a/packages/kilo-docs/mappingplan.md b/packages/kilo-docs/mappingplan.md index d94063a4f67..7131580834f 100644 --- a/packages/kilo-docs/mappingplan.md +++ b/packages/kilo-docs/mappingplan.md @@ -113,7 +113,6 @@ | New Item | Existing Page(s) | |---|---| | Deploy | `advanced-usage/deploy` | -| Managed Indexing | `advanced-usage/managed-indexing` | | Security Reviews | `contributing/architecture/security-reviews` (move out of contributing) | --- diff --git a/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md b/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md index 5bba0acd3f9..7ae258de9f1 100644 --- a/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md +++ b/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md @@ -71,7 +71,7 @@ Low Depth indicates that developers may be trying AI but not trusting or shippin **Actions:** -1. Enable [Managed Indexing](/docs/deploy-secure/managed-indexing) to improve context quality +1. Enable [Codebase Indexing](/docs/customize/context/codebase-indexing) to improve context quality 2. Review whether suggestions are relevant to your codebase 3. Introduce chained workflows to increase multi-stage usage @@ -119,7 +119,7 @@ Use the score tiers as milestones: **For Depth:** - "Chain Challenge" — Complete one feature using plan → build → review -- Managed Indexing rollout — Enable better context for the whole team +- Codebase Indexing rollout - Enable better context for the whole team - Deploy previews — Validate AI output before merging **For Coverage:** @@ -170,7 +170,7 @@ The AI Adoption Score is designed to be quotable: > > **Key Actions Taken:** > -> - Enabled Managed Indexing for better AI context +> - Enabled Codebase Indexing for better AI context > - Introduced Code Reviews for all PRs > - Onboarded 3 inactive team members > diff --git a/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md b/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md index 126a8adc156..949329076f5 100644 --- a/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md +++ b/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md @@ -73,7 +73,7 @@ Linking coding → review → deploy actions significantly boosts your Depth sco If acceptance rates are low, the issue is often context. The AI is making suggestions without understanding your codebase. -**Action:** Enable [Managed Indexing](/docs/deploy-secure/managed-indexing) to give the model vector-backed search across your repository. +**Action:** Enable [Codebase Indexing](/docs/customize/context/codebase-indexing) to give the model vector-backed search across your repository. Better context leads to: @@ -171,7 +171,7 @@ Other ways to spread usage: 1. Identify your most active users and learn what they're doing 2. Introduce Code Reviews to spread usage -3. Enable Managed Indexing for better context +3. Enable Codebase Indexing for better context 4. Set a monthly score goal (e.g., "reach 55 by next month") ### If You're at 51–75 (Growing Adoption) diff --git a/packages/kilo-docs/pages/deploy-secure/index.md b/packages/kilo-docs/pages/deploy-secure/index.md index d929c64349e..91a52cfdac1 100644 --- a/packages/kilo-docs/pages/deploy-secure/index.md +++ b/packages/kilo-docs/pages/deploy-secure/index.md @@ -32,15 +32,6 @@ Ship your applications with one-click deployment: - Real-time log streaming - Deployment history with one-click rollbacks -## Managed Indexing - -Fast, scalable code indexing for better AI context: - -- [**Managed Indexing**](/docs/deploy-secure/managed-indexing) — Cloud-based code indexing -- Improved context for large codebases -- Faster initial indexing times -- Reduced local resource usage - ## Security Reviews AI-powered dependency vulnerability triage for your codebase: @@ -61,8 +52,7 @@ AI-powered dependency vulnerability triage for your codebase: 1. Enable [GitHub Integration](/docs/deploy-secure/deploy#prerequisites) for deployments 2. Set up your first [deployment](/docs/deploy-secure/deploy) in the dashboard -3. Configure [managed indexing](/docs/deploy-secure/managed-indexing) for large projects -4. Enable the [Security Agent](/docs/deploy-secure/security-reviews) to triage your Dependabot alerts +3. Enable the [Security Agent](/docs/deploy-secure/security-reviews) to triage your Dependabot alerts ## Best Practices diff --git a/packages/kilo-docs/pages/deploy-secure/managed-indexing.md b/packages/kilo-docs/pages/deploy-secure/managed-indexing.md deleted file mode 100644 index 1b799c40af2..00000000000 --- a/packages/kilo-docs/pages/deploy-secure/managed-indexing.md +++ /dev/null @@ -1,121 +0,0 @@ ---- -title: "Managed Indexing" -description: "Cloud-managed codebase indexing" ---- - -# Managed Indexing - -Kilo's **Managed Indexing** feature provides semantic search across your repositories using cloud-hosted embeddings. When enabled, Kilo indexes your codebase to deliver more relevant, context-aware responses during development. - ---- - -## What Managed Indexing Enables - -- Semantic search across your entire codebase -- More accurate and context-aware AI responses -- Git-aware indexing that tracks your base branch and feature branch changes -- Shared indexes for teams and enterprise accounts -- Cost-effective cloud storage with automatic cleanup of stale indexes - ---- - -## Prerequisites - -Before enabling Managed Indexing: - -- **Your workspace must be a Git repository** - Indexing requires a Git repository root directory. Non-Git folders will not be indexed. - -- **Available credit balance** - If your balance reaches zero, managed indexing will be disabled and the extension will revert to local indexing (if configured). - ---- - -## Cost - -- **Currently free during beta** -- **Pricing coming soon** — A daily usage fee for index storage will be deducted from your AI credit balance. You will be charged per GB per day. -- **Embedding model** — Uses `mistralai/codestral-embed-2505` which currently charges $0.15/M input tokens. - ---- - -## How to Enable - -Codebase Indexing is rolling out across our users. It will automatically engage unless your repository root is configured to opt out. - -1. Create a `.kilocode/config.json` file in the root of your repository (if it doesn't already exist). -2. Add the following configuration: - -```json -{ - "project": { - "managedIndexingEnabled": false - } -} -``` - -### Configuration Options - -| Field | Type | Required | Description | -|---|---|---|---| -| `project.id` | string | No | Custom name for your project. Defaults to the name from your Git origin remote. | -| `project.baseBranch` | string | No | Specifies your base branch if it isn't `main`, `master`, `dev`, or `develop`. | -| `project.managedIndexingEnabled` | boolean | No | Set to `false` to disable indexing for individual project repositories. Defaults to `true`. | - -Organization-wide indexing is enabled for any organization that has a credit balance. If you want to disable indexing for a specific repository, set `managedIndexingEnabled` to `false` in the config file. - ---- - -## How Managed Indexing Works - -- **Base branch** — Indexed in its entirety -- **Feature branches** — Only changes from the base branch are indexed -- **Detached HEAD states** — Not indexed -- **Storage** — Embeddings are stored in Kilo Cloud. Your actual code is never stored, only the vector embeddings. -- **Team sharing** — For teams and enterprise accounts, indexes are shared among all team members. - -### Index Retention - -Indexes are stored for **7 days**. If a branch or repository index hasn't been updated within that window, it will be garbage collected. The next time you open the project in VS Code with Kilo running, it will be re-indexed automatically. - -This retention policy keeps costs minimal by only maintaining indexes for actively used code. - ---- - -## Managing Your Indexes - -A minimal UI is available at [app.kilo.ai](https://app.kilo.ai) to: - -- View the size and status of your indexed projects -- Delete old branches & projects. - ---- - -## Migration from Local Indexing - -Enabling managed indexing will **replace local self-hosted indexing entirely**. If you have already configured local indexing for a workspace it will take precedence until you disable it. - -### Automatic Reversion - -If your credit balance reaches zero, the extension will automatically revert to local indexing (if previously configured). - ---- - -## Perfect For - -Managed Indexing is ideal for: - -- **Developers wanting smarter, context-aware AI assistance** -- **Teams needing shared semantic search across repositories** -- **Large codebases where finding relevant code is difficult** -- **Organizations wanting centralized index management** - ---- - -## Limitations and Guidance - -- **Git repository required** — Only Git repository root directories can be indexed. We plan to extend this in the future. -- **Detached HEAD not supported** — Commits in detached HEAD state will not be indexed. -- **7-day retention** — Unused indexes are automatically removed after 7 days. -- **Beta capacity** — During beta, indexing capacity may be limited for very large repositories. -- **Organization indexing** — Shared organization indexes currently require contacting support. diff --git a/packages/kilo-docs/pages/index.tsx b/packages/kilo-docs/pages/index.tsx index f95b39d715f..62d29dafc20 100644 --- a/packages/kilo-docs/pages/index.tsx +++ b/packages/kilo-docs/pages/index.tsx @@ -187,7 +187,6 @@ const categories = [ ), links: [ { title: "Deploy", href: "/deploy-secure" }, - { title: "Managed Indexing", href: "/deploy-secure" }, { title: "Security Reviews", href: "/deploy-secure" }, ], }, diff --git a/packages/kilo-docs/previous-docs-redirects.js b/packages/kilo-docs/previous-docs-redirects.js index e9bb1472ce8..cd3f4d6b326 100644 --- a/packages/kilo-docs/previous-docs-redirects.js +++ b/packages/kilo-docs/previous-docs-redirects.js @@ -613,7 +613,13 @@ module.exports = [ }, { source: "/docs/advanced-usage/managed-indexing", - destination: "/docs/deploy-secure/managed-indexing", + destination: "/docs/customize/context/codebase-indexing", + basePath: false, + permanent: true, + }, + { + source: "/docs/deploy-secure/managed-indexing", + destination: "/docs/customize/context/codebase-indexing", basePath: false, permanent: true, }, From 146c4f4396fd45c6bfc567f9b3ee1de46e26a3b6 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 10:16:16 +0200 Subject: [PATCH 24/30] fix(vscode): avoid animating shell output panels --- .changeset/steady-shell-expansion.md | 5 +++++ packages/kilo-ui/src/components/message-part.tsx | 1 - packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts | 5 +++-- 3 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 .changeset/steady-shell-expansion.md diff --git a/.changeset/steady-shell-expansion.md b/.changeset/steady-shell-expansion.md new file mode 100644 index 00000000000..0e4fbb09616 --- /dev/null +++ b/.changeset/steady-shell-expansion.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent large highlighted shell outputs from blanking the VS Code webview when expanded. diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 9f8b88cb758..700f419f527 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -2235,7 +2235,6 @@ ToolRegistry.register({ defaultOpen={props.defaultOpen ?? true} onOpenChange={setOpen} allowPendingToggle - animated trigger={
diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index f84657ba977..2772e0b6e3c 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -201,8 +201,9 @@ describe("Bash tool static terminal preview (source)", () => { expect(block).toContain("BashHighlightedOutput") }) - it("animates expanded bash details", () => { - expect(block).toMatch(/allowPendingToggle\s+animated\s+trigger=/) + it("does not animate expanded bash details", () => { + expect(block).toMatch(/allowPendingToggle\s+trigger=/) + expect(block).not.toMatch(/allowPendingToggle\s+animated/) }) it("BashHighlightedOutput syntax highlights the command next to the prompt", () => { From 911e31dc426d42ffac1db02e1de3bdfaa53af4ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Jun 2026 08:21:29 +0000 Subject: [PATCH 25/30] chore: update kilo-vscode visual regression baselines --- .../agentmanager/diff-panel-with-diffs-chromium-linux.png | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png index 390e57e4890..0438ce9395f 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b9d8327c530c9d071b5eb07ebbf48441ea8d64b8a4cd7201665a8fa037ca82ed -size 50349 +oid sha256:e8318f427b6c0ff71d4cce5c8ac03ba4778f99fda5723d24d23c38471523b622 +size 52203 From 9994679cfeab97b387a4e1711560fb8935be3bed Mon Sep 17 00:00:00 2001 From: markijbema <624143+markijbema@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:25:49 +0000 Subject: [PATCH 26/30] test(vscode): fail fast when screenshots do not settle Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- packages/kilo-vscode/tests/visual-regression.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index 4a205600376..7eb8febaf1f 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -58,10 +58,14 @@ async function settle(page: Page) { await document.fonts.ready }) await frames() - await page.waitForFunction(() => { - const root = document.querySelector("#storybook-root") - return root && !root.querySelector('pre > code[data-lang]:not([data-lang="mermaid"])') - }) + await page.waitForFunction( + () => { + const root = document.querySelector("#storybook-root") + return root && !root.querySelector('pre > code[data-lang]:not([data-lang="mermaid"])') + }, + undefined, + { timeout: 5_000 }, + ) await frames() } From f35d9c5fa3017dcc35d260b5669daf87411419f5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 10:37:06 +0200 Subject: [PATCH 27/30] fix(vscode): stop moved active sessions --- .changeset/stop-moved-agent-sessions.md | 5 + packages/kilo-vscode/src/KiloProvider.ts | 176 ++++++++++-- .../kilo-vscode/src/kilo-provider/abort.ts | 43 ++- packages/kilo-vscode/src/session-status.ts | 20 +- packages/kilo-vscode/tests/unit/abort.test.ts | 67 ++++- .../unit/kilo-provider-load-messages.test.ts | 253 +++++++++++++++++- 6 files changed, 534 insertions(+), 30 deletions(-) create mode 100644 .changeset/stop-moved-agent-sessions.md diff --git a/.changeset/stop-moved-agent-sessions.md b/.changeset/stop-moved-agent-sessions.md new file mode 100644 index 00000000000..ee125bf2d8f --- /dev/null +++ b/.changeset/stop-moved-agent-sessions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep the Stop button working when an active session moves between the workspace and an Agent Manager worktree. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 3ef1f944182..7a4ed5d12c3 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -72,7 +72,13 @@ import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-pa import { childID } from "./kilo-provider/task-session" import { VisibleTaskStreams } from "./kilo-provider/visible-task-streams" import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" -import { abortSession } from "./kilo-provider/abort" +import { + abortSession, + resolveAbortDirectories, + resolveActiveSessionStatus, + updateActiveSessionDirectory, + type ActiveSessionDirectories, +} from "./kilo-provider/abort" import { buildAutocompleteSettingsMessage, validAutocompleteSetting, @@ -310,6 +316,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private syncedChildSessions: Set = new Set() private sessionStatusMap = new Map() // Latest status used for destructive config warnings. private sessionDirectories = new Map() // Per-session directory overrides, such as Agent Manager worktrees. + private activeSessionDirectories: ActiveSessionDirectories = new Map() + private sessionStatusRevision = 0 + private sessionStatusRevisions = new Map>() private projectID: string | undefined // Current workspace project ID used to filter sessions. private loadMessagesAbort: AbortController | null = null // Current load request cancellation. private lastReconciledAt = new Map() // Per-session focus-mode reconcile timestamp. @@ -657,10 +666,28 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * When set, all operations for this session use this directory instead of the workspace root. */ public setSessionDirectory(sessionId: string, directory: string): void { + const status = this.sessionStatusMap.get(sessionId) + if (status && status !== "idle" && !this.activeSessionDirectories.has(sessionId)) { + updateActiveSessionDirectory({ + active: this.activeSessionDirectories, + sessionID: sessionId, + status: { type: "busy" }, + dir: this.getWorkspaceDirectory(sessionId), + }) + } this.sessionDirectories.set(sessionId, directory) } public clearSessionDirectory(sessionId: string): void { + const status = this.sessionStatusMap.get(sessionId) + if (status && status !== "idle" && !this.activeSessionDirectories.has(sessionId)) { + updateActiveSessionDirectory({ + active: this.activeSessionDirectories, + sessionID: sessionId, + status: { type: "busy" }, + dir: this.getWorkspaceDirectory(sessionId), + }) + } this.sessionDirectories.delete(sessionId) } @@ -1513,17 +1540,28 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) .catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e)) this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) }) + const revision = this.sessionStatusRevision this.client.session .status({ directory: dir }) .then((r) => { if (!r.data || signal?.aborted) return for (const [sid, info] of Object.entries(r.data) as [string, SessionStatus][]) { - if (!this.trackedSessionIds.has(sid)) continue + if (!this.trackedSessionIds.has(sid) || this.statusChanged(sid, dir, revision)) continue + this.markStatus(sid, dir) + updateActiveSessionDirectory({ + active: this.activeSessionDirectories, + sessionID: sid, + status: info, + dir, + }) + const status = resolveActiveSessionStatus(this.activeSessionDirectories, sid) ?? info + this.sessionStatusMap.set(sid, status.type) this.postMessage({ type: "sessionStatus", sessionID: sid, - status: info.type, - ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}), + status: status.type, + ...(status.type === "retry" ? { attempt: status.attempt, message: status.message, next: status.next } : {}), + ...(status.type === "offline" ? { message: status.message } : {}), }) } }) @@ -1755,6 +1793,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibleTaskStreams.delete(sessionID) this.syncedChildSessions.delete(sessionID) this.sessionDirectories.delete(sessionID) + this.activeSessionDirectories.delete(sessionID) + this.sessionStatusRevisions.delete(sessionID) this.lastReconciledAt.delete(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { @@ -2192,6 +2232,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(message) } + private markStatus(sessionID: string, dir?: string): void { + this.sessionStatusRevision += 1 + const revisions = this.sessionStatusRevisions.get(sessionID) ?? new Map() + const key = dir ? ([...revisions.keys()].find((entry) => entry && sameDirectory(entry, dir)) ?? dir) : "" + revisions.set(key, this.sessionStatusRevision) + this.sessionStatusRevisions.set(sessionID, revisions) + } + + private statusChanged(sessionID: string, dir: string, revision: number): boolean { + const revisions = this.sessionStatusRevisions.get(sessionID) + if (!revisions) return false + return [...revisions].some(([entry, current]) => current > revision && (!entry || sameDirectory(entry, dir))) + } + /** * Seed sessionStatusMap with current session statuses on connect. * Without this, the Settings panel (which has no tracked sessions) would see @@ -2204,7 +2258,24 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async seedSessionStatusMap(reconcile = true): Promise { if (!this.client || this.connectionState !== "connected") return const dir = this.getWorkspaceDirectory() - await seedSessionStatuses(this.client, dir, this.sessionStatusMap, (msg) => this.postMessage(msg), reconcile) + const revision = this.sessionStatusRevision + await seedSessionStatuses( + this.client, + dir, + this.sessionStatusMap, + (msg) => this.postMessage(msg), + reconcile, + (sessionID, status) => { + if (this.statusChanged(sessionID, dir, revision)) return + updateActiveSessionDirectory({ + active: this.activeSessionDirectories, + sessionID, + status, + dir, + }) + return resolveActiveSessionStatus(this.activeSessionDirectories, sessionID) ?? status + }, + ) } /** @@ -2754,23 +2825,55 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private async handleAbort(sessionID?: string): Promise { - if (!this.client) { - return - } + const client = this.client + if (!client) return const targetSessionID = sessionID || this.currentSession?.id - if (!targetSessionID) { - return - } + if (!targetSessionID) return - try { - await abortSession({ - client: this.client, + const dirs = resolveAbortDirectories( + this.activeSessionDirectories, + targetSessionID, + this.getWorkspaceDirectory(targetSessionID), + ) + const results = await Promise.allSettled( + dirs.map((dir) => + abortSession({ + client, + sessionID: targetSessionID, + dir, + }), + ), + ) + const failures = results.flatMap((result, index) => + result.status === "rejected" ? [{ dir: dirs[index], error: result.reason }] : [], + ) + for (const [index, result] of results.entries()) { + if (result.status !== "fulfilled") continue + const dir = dirs[index] + if (!dir) continue + this.markStatus(targetSessionID, dir) + updateActiveSessionDirectory({ + active: this.activeSessionDirectories, sessionID: targetSessionID, - dir: this.getWorkspaceDirectory(targetSessionID), + status: { type: "idle" }, + dir, }) - } catch (error) { - console.error("[Kilo New] KiloProvider: Failed to abort session:", error) + } + const status = resolveActiveSessionStatus(this.activeSessionDirectories, targetSessionID) ?? { + type: "idle" as const, + } + this.sessionStatusMap.set(targetSessionID, status.type) + this.streams.flush(targetSessionID) + this.postMessage({ + type: "sessionStatus", + sessionID: targetSessionID, + status: status.type, + ...(status.type === "retry" ? { attempt: status.attempt, message: status.message, next: status.next } : {}), + ...(status.type === "offline" ? { message: status.message } : {}), + }) + if (failures.length > 0) { + console.error("[Kilo New] KiloProvider: Failed to abort session in one or more directories:", failures) } } @@ -3155,8 +3258,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // busy-session warning on Save. if (event.type === "session.status") { const sid = event.properties.sessionID - this.sessionStatusMap.set(sid, event.properties.status.type) - const msg = mapSSEEventToWebviewMessage(event, sid) + this.markStatus(sid, directory) + updateActiveSessionDirectory({ + active: this.activeSessionDirectories, + sessionID: sid, + status: event.properties.status, + dir: directory, + }) + const status = resolveActiveSessionStatus(this.activeSessionDirectories, sid) ?? event.properties.status + this.sessionStatusMap.set(sid, status.type) + const msg = mapSSEEventToWebviewMessage({ ...event, properties: { ...event.properties, status } }, sid) if (msg) { this.streams.flush(sid) this.postMessage(msg) @@ -3188,6 +3299,31 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (event.type === "server.instance.disposed") { const props = event.properties as Record | null const dir = typeof props?.directory === "string" ? props.directory : undefined + if (dir) { + for (const sessionID of [...this.activeSessionDirectories.keys()]) { + const entries = this.activeSessionDirectories.get(sessionID) + if (![...(entries?.keys() ?? [])].some((entry) => sameDirectory(entry, dir))) continue + this.markStatus(sessionID, dir) + updateActiveSessionDirectory({ + active: this.activeSessionDirectories, + sessionID, + status: { type: "idle" }, + dir, + }) + const status = resolveActiveSessionStatus(this.activeSessionDirectories, sessionID) ?? { + type: "idle" as const, + } + this.sessionStatusMap.set(sessionID, status.type) + this.streams.flush(sessionID) + this.postMessage({ + type: "sessionStatus", + sessionID, + status: status.type, + ...(status.type === "retry" ? { attempt: status.attempt, message: status.message, next: status.next } : {}), + ...(status.type === "offline" ? { message: status.message } : {}), + }) + } + } if (dir && !sameDirectory(dir, this.getWorkspaceDirectory())) return void this.reloadAfterAuthChange() return @@ -3600,6 +3736,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.clear() this.syncedChildSessions.clear() this.sessionDirectories.clear() + this.activeSessionDirectories.clear() + this.sessionStatusRevisions.clear() this.sessionStatusMap.clear() this.ignoreController?.dispose() this.chatAutocomplete?.dispose() diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index 8d3d00676e9..6efc2a6e7f6 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -1,4 +1,45 @@ -import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" +import { sameDirectory } from "../kilo-provider-utils" + +export type ActiveSessionDirectories = Map> + +export function updateActiveSessionDirectory(input: { + active: ActiveSessionDirectories + sessionID: string + status: SessionStatus + dir?: string +}) { + const target = input.dir + if (!target) return + const entries = input.active.get(input.sessionID) + if (input.status.type === "idle") { + if (!entries) return + for (const dir of entries.keys()) { + if (sameDirectory(dir, target)) entries.delete(dir) + } + if (entries.size === 0) input.active.delete(input.sessionID) + return + } + if (!entries) { + input.active.set(input.sessionID, new Map([[target, input.status]])) + return + } + for (const dir of entries.keys()) { + if (sameDirectory(dir, target)) entries.delete(dir) + } + entries.set(target, input.status) +} + +export function resolveActiveSessionStatus(active: ActiveSessionDirectories, sessionID: string) { + const statuses = [...(active.get(sessionID)?.values() ?? [])] + return statuses[statuses.length - 1] +} + +export function resolveAbortDirectories(active: ActiveSessionDirectories, sessionID: string, fallback: string) { + const dirs = [...(active.get(sessionID)?.keys() ?? [])] + if (!dirs.some((dir) => sameDirectory(dir, fallback))) dirs.push(fallback) + return dirs +} export async function abortSession(input: { client: KiloClient; sessionID: string; dir: string }) { await input.client.session.abort({ sessionID: input.sessionID, directory: input.dir }, { throwOnError: true }) diff --git a/packages/kilo-vscode/src/session-status.ts b/packages/kilo-vscode/src/session-status.ts index 67856f81f54..b4d73ef1ff5 100644 --- a/packages/kilo-vscode/src/session-status.ts +++ b/packages/kilo-vscode/src/session-status.ts @@ -16,6 +16,7 @@ export async function seedSessionStatuses( map: Map, post: (msg: unknown) => void, reconcile = true, + update?: (sessionID: string, status: SessionStatus, source: "snapshot" | "reconcile") => SessionStatus | undefined, ): Promise { try { const result = await client.session.status({ directory: dir }) @@ -24,12 +25,14 @@ export async function seedSessionStatuses( // Seed/update entries the server knows about for (const [sid, info] of Object.entries(active) as [string, SessionStatus][]) { - map.set(sid, info.type) + const status = update ? update(sid, info, "snapshot") : info + if (!status) continue + map.set(sid, status.type) post({ type: "sessionStatus", sessionID: sid, - status: info.type, - ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}), + status: status.type, + ...(status.type === "retry" ? { attempt: status.attempt, message: status.message, next: status.next } : {}), }) } @@ -40,8 +43,15 @@ export async function seedSessionStatuses( if (reconcile) { for (const [sid, status] of map) { if (status !== "idle" && !active[sid]) { - map.set(sid, "idle") - post({ type: "sessionStatus", sessionID: sid, status: "idle" }) + const next = update ? update(sid, { type: "idle" }, "reconcile") : { type: "idle" as const } + if (!next) continue + map.set(sid, next.type) + post({ + type: "sessionStatus", + sessionID: sid, + status: next.type, + ...(next.type === "retry" ? { attempt: next.attempt, message: next.message, next: next.next } : {}), + }) } } } diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index 126333e6a4e..c00f1a902e5 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test" -import type { KiloClient } from "@kilocode/sdk/v2/client" -import { abortSession } from "../../src/kilo-provider/abort" +import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" +import { + abortSession, + resolveAbortDirectories, + updateActiveSessionDirectory, + type ActiveSessionDirectories, +} from "../../src/kilo-provider/abort" function client(calls: unknown[], fail = false) { return { @@ -14,6 +19,64 @@ function client(calls: unknown[], fail = false) { } as unknown as KiloClient } +describe("active session directories", () => { + function update(active: ActiveSessionDirectories, type: SessionStatus["type"], dir?: string) { + const status: SessionStatus = + type === "retry" + ? { type, attempt: 1, message: "retrying", next: 1 } + : type === "offline" + ? { type, requestID: "request_1", message: "offline" } + : { type } + updateActiveSessionDirectory({ active, sessionID: "session_1", status, dir }) + } + + it("includes the active owner and current session directory", () => { + const active: ActiveSessionDirectories = new Map() + update(active, "busy", "/repo/source") + + expect(resolveAbortDirectories(active, "session_1", "/repo/worktree")).toEqual(["/repo/source", "/repo/worktree"]) + }) + + it("falls back to the current directory after the active turn becomes idle", () => { + const active: ActiveSessionDirectories = new Map() + update(active, "busy", "/repo/source") + update(active, "idle", "/repo/source") + + expect(resolveAbortDirectories(active, "session_1", "/repo/worktree")).toEqual(["/repo/worktree"]) + }) + + it("retains active directories when an unrelated instance reports idle", () => { + const active: ActiveSessionDirectories = new Map() + update(active, "retry", "/repo/source") + update(active, "idle", "/repo/worktree") + update(active, "idle") + + expect(resolveAbortDirectories(active, "session_1", "/repo/worktree")).toEqual(["/repo/source", "/repo/worktree"]) + }) + + it("tracks concurrent instances and ignores delayed idle events from the old one", () => { + const active: ActiveSessionDirectories = new Map() + update(active, "busy", "/repo/source") + update(active, "offline", "/repo/worktree") + + expect(resolveAbortDirectories(active, "session_1", "/repo/fallback")).toEqual([ + "/repo/source", + "/repo/worktree", + "/repo/fallback", + ]) + + update(active, "idle", "/repo/source") + expect(resolveAbortDirectories(active, "session_1", "/repo/fallback")).toEqual(["/repo/worktree", "/repo/fallback"]) + }) + + it("deduplicates the current directory when it is already active", () => { + const active: ActiveSessionDirectories = new Map() + update(active, "busy", "/repo/worktree") + + expect(resolveAbortDirectories(active, "session_1", "/repo/worktree/.")).toEqual(["/repo/worktree"]) + }) +}) + describe("abortSession", () => { it("calls session.abort with the session id and directory", async () => { const calls: unknown[] = [] diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 3145932a2ca..45ef844f146 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect } from "bun:test" +import { describe, it, expect, spyOn } from "bun:test" +import type { SessionStatus } from "@kilocode/sdk/v2/client" import type { PartUpdate } from "../../src/shared/stream-messages" // vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) @@ -44,19 +45,29 @@ function createClient(options?: { deleteDeferred?: Deferred sessionData?: unknown sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }> + statusData?: Record + statusDeferred?: Deferred<{ data: Record }> + abortFailures?: string[] }) { const calls: { before?: string; limit?: number }[] = [] const stopped: { sessionID: string; directory?: string }[] = [] + const aborted: { sessionID: string; directory?: string }[] = [] return { calls, stopped, + aborted, session: { list: async () => ({ data: [] }), get: async (params: { sessionID: string; directory?: string }) => { if (options?.sessionGet) return options.sessionGet(params) return { data: options?.sessionData ?? null } }, - status: async () => ({ data: {} }), + status: async () => options?.statusDeferred?.promise ?? { data: options?.statusData ?? {} }, + abort: async (params: { sessionID: string; directory?: string }) => { + aborted.push(params) + if (params.directory && options?.abortFailures?.includes(params.directory)) throw new Error("abort failed") + return { data: true } + }, messages: async (params: { before?: string; limit?: number }) => { calls.push({ before: params.before, limit: params.limit }) if (options?.messagesDeferred) return options.messagesDeferred.promise @@ -115,10 +126,14 @@ type ProviderInternals = { currentSession: { id: string; directory?: string } | null contextSessionID: string | undefined sessionDirectories: Map + activeSessionDirectories: Map> trackedSessionIds: Set streams: { push: (msg: PartUpdate) => void } stopCurrentSessionProcesses: (next?: string) => void - handleEvent: (event: unknown) => void + handleEvent: (event: unknown, directory?: string) => void + handleAbort: (sid?: string) => Promise + seedSessionStatusMap: (reconcile?: boolean) => Promise + refreshSessionDetails: (sid: string, dir: string, signal?: AbortSignal) => void handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise handleDeleteSession: (sid: string) => Promise } @@ -137,6 +152,238 @@ function makeProvider(client: ReturnType) { return { provider, internal, sent } } +describe("KiloProvider.handleAbort", () => { + it("keeps the active owner when a running session moves to a worktree", async () => { + const client = createClient() + const { provider, internal, sent } = makeProvider(client) + internal.handleEvent({ + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }) + provider.setSessionDirectory("s1", "/repo/worktree") + + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo" }, + { sessionID: "s1", directory: "/repo/worktree" }, + ]) + expect(sent.at(-1)).toMatchObject({ type: "sessionStatus", sessionID: "s1", status: "idle" }) + }) + + it("preserves a directory-qualified owner instead of inferring the current directory", async () => { + const client = createClient() + const { provider, internal } = makeProvider(client) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }, + "/repo/source", + ) + provider.setSessionDirectory("s1", "/repo/worktree") + + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo/source" }, + { sessionID: "s1", directory: "/repo/worktree" }, + ]) + }) + + it("attempts every owner when one abort request fails", async () => { + const error = spyOn(console, "error").mockImplementation(() => {}) + const client = createClient({ abortFailures: ["/repo"] }) + const { provider, internal, sent } = makeProvider(client) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }, + "/repo", + ) + provider.setSessionDirectory("s1", "/repo/worktree") + + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo" }, + { sessionID: "s1", directory: "/repo/worktree" }, + ]) + expect(sent.at(-1)).toMatchObject({ type: "sessionStatus", sessionID: "s1", status: "busy" }) + expect(error).toHaveBeenCalledTimes(1) + error.mockRestore() + }) + + it("restores the remaining owner status when another owner reports idle", () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "offline", requestID: "request_1", message: "offline" } }, + }, + "/repo/source", + ) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "retry", attempt: 1, message: "retry", next: 1 } }, + }, + "/repo/worktree", + ) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "idle" } }, + }, + "/repo/worktree", + ) + + const statuses = sent.flatMap((msg) => + typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionStatus" + ? [(msg as { status: string }).status] + : [], + ) + expect(statuses).toEqual(["offline", "retry", "offline"]) + + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "idle" } }, + }, + "/repo/source", + ) + expect( + sent.flatMap((msg) => + typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionStatus" + ? [(msg as { status: string }).status] + : [], + ), + ).toEqual(["offline", "retry", "offline", "idle"]) + }) + + it("restores the remaining owner when an active instance is disposed", () => { + const client = createClient() + const { internal, sent } = makeProvider(client) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "offline", requestID: "request_1", message: "offline" } }, + }, + "/repo/source", + ) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "retry", attempt: 1, message: "retry", next: 1 } }, + }, + "/repo/worktree", + ) + + internal.handleEvent({ + type: "server.instance.disposed", + properties: { directory: "/repo/worktree" }, + }) + + expect( + sent.flatMap((msg) => + typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionStatus" + ? [(msg as { status: string }).status] + : [], + ), + ).toEqual(["offline", "retry", "offline"]) + }) + + it("records an active owner discovered during status seeding", async () => { + const client = createClient({ statusData: { s1: { type: "busy" } } }) + const { provider, internal } = makeProvider(client) + provider.setSessionDirectory("s1", "/repo/worktree") + + await internal.seedSessionStatusMap() + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo" }, + { sessionID: "s1", directory: "/repo/worktree" }, + ]) + }) + + it("records a worktree owner discovered while refreshing session details", async () => { + const status = defer<{ data: Record }>() + const client = createClient({ statusDeferred: status }) + const { provider, internal } = makeProvider(client) + provider.setSessionDirectory("s1", "/repo/worktree") + internal.trackedSessionIds.add("s1") + + internal.refreshSessionDetails("s1", "/repo/worktree") + status.resolve({ data: { s1: { type: "busy" } } }) + await Bun.sleep(0) + provider.clearSessionDirectory("s1") + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo/worktree" }, + { sessionID: "s1", directory: "/repo" }, + ]) + }) + + it("does not overwrite a newer busy event with a stale status seed", async () => { + const status = defer<{ data: Record }>() + const client = createClient({ statusDeferred: status }) + const { provider, internal, sent } = makeProvider(client) + provider.setSessionDirectory("s1", "/repo/worktree") + + const seed = internal.seedSessionStatusMap() + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }, + "/repo", + ) + status.resolve({ data: {} }) + await seed + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo" }, + { sessionID: "s1", directory: "/repo/worktree" }, + ]) + expect( + sent.flatMap((msg) => + typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionStatus" + ? [(msg as { status: string }).status] + : [], + ), + ).toEqual(["busy", "idle"]) + }) + + it("keeps a seeded owner when another directory reports a newer status", async () => { + const status = defer<{ data: Record }>() + const client = createClient({ statusDeferred: status }) + const { provider, internal } = makeProvider(client) + provider.setSessionDirectory("s1", "/repo/worktree") + + const seed = internal.seedSessionStatusMap() + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }, + "/repo/worktree", + ) + status.resolve({ data: { s1: { type: "busy" } } }) + await seed + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo/worktree" }, + { sessionID: "s1", directory: "/repo" }, + ]) + }) +}) + describe("KiloProvider.handleLoadMessages / focus mode freshness", () => { it("stops background processes for the previous session when switching sessions", async () => { const client = createClient({ From d43df066d8993d2f8cf74d106f70d47f941afbf1 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 10:54:34 +0200 Subject: [PATCH 28/30] refactor(vscode): minimize moved session abort fix --- packages/kilo-vscode/src/KiloProvider.ts | 153 +++--------- .../kilo-vscode/src/kilo-provider/abort.ts | 32 +-- packages/kilo-vscode/src/session-status.ts | 20 +- packages/kilo-vscode/tests/unit/abort.test.ts | 48 +--- .../unit/kilo-provider-load-messages.test.ts | 222 ++---------------- 5 files changed, 82 insertions(+), 393 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 7a4ed5d12c3..06130e85e79 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -75,7 +75,6 @@ import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" import { abortSession, resolveAbortDirectories, - resolveActiveSessionStatus, updateActiveSessionDirectory, type ActiveSessionDirectories, } from "./kilo-provider/abort" @@ -317,8 +316,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private sessionStatusMap = new Map() // Latest status used for destructive config warnings. private sessionDirectories = new Map() // Per-session directory overrides, such as Agent Manager worktrees. private activeSessionDirectories: ActiveSessionDirectories = new Map() - private sessionStatusRevision = 0 - private sessionStatusRevisions = new Map>() private projectID: string | undefined // Current workspace project ID used to filter sessions. private loadMessagesAbort: AbortController | null = null // Current load request cancellation. private lastReconciledAt = new Map() // Per-session focus-mode reconcile timestamp. @@ -666,31 +663,26 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * When set, all operations for this session use this directory instead of the workspace root. */ public setSessionDirectory(sessionId: string, directory: string): void { - const status = this.sessionStatusMap.get(sessionId) - if (status && status !== "idle" && !this.activeSessionDirectories.has(sessionId)) { - updateActiveSessionDirectory({ - active: this.activeSessionDirectories, - sessionID: sessionId, - status: { type: "busy" }, - dir: this.getWorkspaceDirectory(sessionId), - }) - } + this.preserveDirectory(sessionId) this.sessionDirectories.set(sessionId, directory) } public clearSessionDirectory(sessionId: string): void { - const status = this.sessionStatusMap.get(sessionId) - if (status && status !== "idle" && !this.activeSessionDirectories.has(sessionId)) { - updateActiveSessionDirectory({ - active: this.activeSessionDirectories, - sessionID: sessionId, - status: { type: "busy" }, - dir: this.getWorkspaceDirectory(sessionId), - }) - } + this.preserveDirectory(sessionId) this.sessionDirectories.delete(sessionId) } + private preserveDirectory(sessionID: string): void { + const status = this.sessionStatusMap.get(sessionID) + if (!status || status === "idle" || this.activeSessionDirectories.has(sessionID)) return + updateActiveSessionDirectory({ + active: this.activeSessionDirectories, + sessionID, + status, + dir: this.getWorkspaceDirectory(sessionID), + }) + } + /** Exposes the session→directory map so callers outside the webview can resolve worktree paths. */ public getSessionDirectories(): ReadonlyMap { return this.sessionDirectories @@ -1540,28 +1532,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) .catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e)) this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) }) - const revision = this.sessionStatusRevision this.client.session .status({ directory: dir }) .then((r) => { if (!r.data || signal?.aborted) return for (const [sid, info] of Object.entries(r.data) as [string, SessionStatus][]) { - if (!this.trackedSessionIds.has(sid) || this.statusChanged(sid, dir, revision)) continue - this.markStatus(sid, dir) - updateActiveSessionDirectory({ - active: this.activeSessionDirectories, - sessionID: sid, - status: info, - dir, - }) - const status = resolveActiveSessionStatus(this.activeSessionDirectories, sid) ?? info - this.sessionStatusMap.set(sid, status.type) + if (!this.trackedSessionIds.has(sid)) continue this.postMessage({ type: "sessionStatus", sessionID: sid, - status: status.type, - ...(status.type === "retry" ? { attempt: status.attempt, message: status.message, next: status.next } : {}), - ...(status.type === "offline" ? { message: status.message } : {}), + status: info.type, + ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}), }) } }) @@ -1794,7 +1775,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.syncedChildSessions.delete(sessionID) this.sessionDirectories.delete(sessionID) this.activeSessionDirectories.delete(sessionID) - this.sessionStatusRevisions.delete(sessionID) this.lastReconciledAt.delete(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { @@ -2232,20 +2212,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage(message) } - private markStatus(sessionID: string, dir?: string): void { - this.sessionStatusRevision += 1 - const revisions = this.sessionStatusRevisions.get(sessionID) ?? new Map() - const key = dir ? ([...revisions.keys()].find((entry) => entry && sameDirectory(entry, dir)) ?? dir) : "" - revisions.set(key, this.sessionStatusRevision) - this.sessionStatusRevisions.set(sessionID, revisions) - } - - private statusChanged(sessionID: string, dir: string, revision: number): boolean { - const revisions = this.sessionStatusRevisions.get(sessionID) - if (!revisions) return false - return [...revisions].some(([entry, current]) => current > revision && (!entry || sameDirectory(entry, dir))) - } - /** * Seed sessionStatusMap with current session statuses on connect. * Without this, the Settings panel (which has no tracked sessions) would see @@ -2258,24 +2224,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private async seedSessionStatusMap(reconcile = true): Promise { if (!this.client || this.connectionState !== "connected") return const dir = this.getWorkspaceDirectory() - const revision = this.sessionStatusRevision - await seedSessionStatuses( - this.client, - dir, - this.sessionStatusMap, - (msg) => this.postMessage(msg), - reconcile, - (sessionID, status) => { - if (this.statusChanged(sessionID, dir, revision)) return - updateActiveSessionDirectory({ - active: this.activeSessionDirectories, - sessionID, - status, - dir, - }) - return resolveActiveSessionStatus(this.activeSessionDirectories, sessionID) ?? status - }, - ) + await seedSessionStatuses(this.client, dir, this.sessionStatusMap, (msg) => this.postMessage(msg), reconcile) } /** @@ -2828,19 +2777,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const client = this.client if (!client) return - const targetSessionID = sessionID || this.currentSession?.id - if (!targetSessionID) return + const target = sessionID || this.currentSession?.id + if (!target) return - const dirs = resolveAbortDirectories( - this.activeSessionDirectories, - targetSessionID, - this.getWorkspaceDirectory(targetSessionID), - ) + const known = this.activeSessionDirectories.has(target) + const dirs = resolveAbortDirectories(this.activeSessionDirectories, target, this.getWorkspaceDirectory(target)) const results = await Promise.allSettled( dirs.map((dir) => abortSession({ client, - sessionID: targetSessionID, + sessionID: target, dir, }), ), @@ -2848,30 +2794,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const failures = results.flatMap((result, index) => result.status === "rejected" ? [{ dir: dirs[index], error: result.reason }] : [], ) - for (const [index, result] of results.entries()) { - if (result.status !== "fulfilled") continue - const dir = dirs[index] - if (!dir) continue - this.markStatus(targetSessionID, dir) - updateActiveSessionDirectory({ - active: this.activeSessionDirectories, - sessionID: targetSessionID, - status: { type: "idle" }, - dir, - }) + if (known && failures.length === 0) { + this.activeSessionDirectories.delete(target) + this.sessionStatusMap.set(target, "idle") + this.streams.flush(target) + this.postMessage({ type: "sessionStatus", sessionID: target, status: "idle" }) } - const status = resolveActiveSessionStatus(this.activeSessionDirectories, targetSessionID) ?? { - type: "idle" as const, - } - this.sessionStatusMap.set(targetSessionID, status.type) - this.streams.flush(targetSessionID) - this.postMessage({ - type: "sessionStatus", - sessionID: targetSessionID, - status: status.type, - ...(status.type === "retry" ? { attempt: status.attempt, message: status.message, next: status.next } : {}), - ...(status.type === "offline" ? { message: status.message } : {}), - }) if (failures.length > 0) { console.error("[Kilo New] KiloProvider: Failed to abort session in one or more directories:", failures) } @@ -3258,16 +3186,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // busy-session warning on Save. if (event.type === "session.status") { const sid = event.properties.sessionID - this.markStatus(sid, directory) + this.sessionStatusMap.set(sid, event.properties.status.type) updateActiveSessionDirectory({ active: this.activeSessionDirectories, sessionID: sid, - status: event.properties.status, + status: event.properties.status.type, dir: directory, }) - const status = resolveActiveSessionStatus(this.activeSessionDirectories, sid) ?? event.properties.status - this.sessionStatusMap.set(sid, status.type) - const msg = mapSSEEventToWebviewMessage({ ...event, properties: { ...event.properties, status } }, sid) + const msg = mapSSEEventToWebviewMessage(event, sid) if (msg) { this.streams.flush(sid) this.postMessage(msg) @@ -3301,27 +3227,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const dir = typeof props?.directory === "string" ? props.directory : undefined if (dir) { for (const sessionID of [...this.activeSessionDirectories.keys()]) { - const entries = this.activeSessionDirectories.get(sessionID) - if (![...(entries?.keys() ?? [])].some((entry) => sameDirectory(entry, dir))) continue - this.markStatus(sessionID, dir) updateActiveSessionDirectory({ active: this.activeSessionDirectories, sessionID, - status: { type: "idle" }, + status: "idle", dir, }) - const status = resolveActiveSessionStatus(this.activeSessionDirectories, sessionID) ?? { - type: "idle" as const, - } - this.sessionStatusMap.set(sessionID, status.type) - this.streams.flush(sessionID) - this.postMessage({ - type: "sessionStatus", - sessionID, - status: status.type, - ...(status.type === "retry" ? { attempt: status.attempt, message: status.message, next: status.next } : {}), - ...(status.type === "offline" ? { message: status.message } : {}), - }) + if (!this.activeSessionDirectories.has(sessionID)) this.sessionStatusMap.set(sessionID, "idle") } } if (dir && !sameDirectory(dir, this.getWorkspaceDirectory())) return @@ -3737,7 +3649,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.syncedChildSessions.clear() this.sessionDirectories.clear() this.activeSessionDirectories.clear() - this.sessionStatusRevisions.clear() this.sessionStatusMap.clear() this.ignoreController?.dispose() this.chatAutocomplete?.dispose() diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index 6efc2a6e7f6..fa0eeb3edfe 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -1,42 +1,34 @@ import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" import { sameDirectory } from "../kilo-provider-utils" -export type ActiveSessionDirectories = Map> +export type ActiveSessionDirectories = Map> export function updateActiveSessionDirectory(input: { active: ActiveSessionDirectories sessionID: string - status: SessionStatus + status: SessionStatus["type"] dir?: string }) { const target = input.dir if (!target) return - const entries = input.active.get(input.sessionID) - if (input.status.type === "idle") { - if (!entries) return - for (const dir of entries.keys()) { - if (sameDirectory(dir, target)) entries.delete(dir) + const dirs = input.active.get(input.sessionID) + if (input.status === "idle") { + if (!dirs) return + for (const dir of dirs) { + if (sameDirectory(dir, target)) dirs.delete(dir) } - if (entries.size === 0) input.active.delete(input.sessionID) + if (dirs.size === 0) input.active.delete(input.sessionID) return } - if (!entries) { - input.active.set(input.sessionID, new Map([[target, input.status]])) + if (!dirs) { + input.active.set(input.sessionID, new Set([target])) return } - for (const dir of entries.keys()) { - if (sameDirectory(dir, target)) entries.delete(dir) - } - entries.set(target, input.status) -} - -export function resolveActiveSessionStatus(active: ActiveSessionDirectories, sessionID: string) { - const statuses = [...(active.get(sessionID)?.values() ?? [])] - return statuses[statuses.length - 1] + if (![...dirs].some((dir) => sameDirectory(dir, target))) dirs.add(target) } export function resolveAbortDirectories(active: ActiveSessionDirectories, sessionID: string, fallback: string) { - const dirs = [...(active.get(sessionID)?.keys() ?? [])] + const dirs = [...(active.get(sessionID) ?? [])] if (!dirs.some((dir) => sameDirectory(dir, fallback))) dirs.push(fallback) return dirs } diff --git a/packages/kilo-vscode/src/session-status.ts b/packages/kilo-vscode/src/session-status.ts index b4d73ef1ff5..67856f81f54 100644 --- a/packages/kilo-vscode/src/session-status.ts +++ b/packages/kilo-vscode/src/session-status.ts @@ -16,7 +16,6 @@ export async function seedSessionStatuses( map: Map, post: (msg: unknown) => void, reconcile = true, - update?: (sessionID: string, status: SessionStatus, source: "snapshot" | "reconcile") => SessionStatus | undefined, ): Promise { try { const result = await client.session.status({ directory: dir }) @@ -25,14 +24,12 @@ export async function seedSessionStatuses( // Seed/update entries the server knows about for (const [sid, info] of Object.entries(active) as [string, SessionStatus][]) { - const status = update ? update(sid, info, "snapshot") : info - if (!status) continue - map.set(sid, status.type) + map.set(sid, info.type) post({ type: "sessionStatus", sessionID: sid, - status: status.type, - ...(status.type === "retry" ? { attempt: status.attempt, message: status.message, next: status.next } : {}), + status: info.type, + ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}), }) } @@ -43,15 +40,8 @@ export async function seedSessionStatuses( if (reconcile) { for (const [sid, status] of map) { if (status !== "idle" && !active[sid]) { - const next = update ? update(sid, { type: "idle" }, "reconcile") : { type: "idle" as const } - if (!next) continue - map.set(sid, next.type) - post({ - type: "sessionStatus", - sessionID: sid, - status: next.type, - ...(next.type === "retry" ? { attempt: next.attempt, message: next.message, next: next.next } : {}), - }) + map.set(sid, "idle") + post({ type: "sessionStatus", sessionID: sid, status: "idle" }) } } } diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index c00f1a902e5..1c07d9c02b0 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" +import type { KiloClient } from "@kilocode/sdk/v2/client" import { abortSession, resolveAbortDirectories, @@ -20,56 +20,26 @@ function client(calls: unknown[], fail = false) { } describe("active session directories", () => { - function update(active: ActiveSessionDirectories, type: SessionStatus["type"], dir?: string) { - const status: SessionStatus = - type === "retry" - ? { type, attempt: 1, message: "retrying", next: 1 } - : type === "offline" - ? { type, requestID: "request_1", message: "offline" } - : { type } + function update(active: ActiveSessionDirectories, status: "busy" | "idle", dir: string) { updateActiveSessionDirectory({ active, sessionID: "session_1", status, dir }) } - it("includes the active owner and current session directory", () => { + it("includes the active owner and current mapped directory", () => { const active: ActiveSessionDirectories = new Map() - update(active, "busy", "/repo/source") + update(active, "busy", "/repo") - expect(resolveAbortDirectories(active, "session_1", "/repo/worktree")).toEqual(["/repo/source", "/repo/worktree"]) + expect(resolveAbortDirectories(active, "session_1", "/repo/worktree")).toEqual(["/repo", "/repo/worktree"]) }) - it("falls back to the current directory after the active turn becomes idle", () => { + it("removes an owner when its instance becomes idle", () => { const active: ActiveSessionDirectories = new Map() - update(active, "busy", "/repo/source") - update(active, "idle", "/repo/source") + update(active, "busy", "/repo") + update(active, "idle", "/repo") expect(resolveAbortDirectories(active, "session_1", "/repo/worktree")).toEqual(["/repo/worktree"]) }) - it("retains active directories when an unrelated instance reports idle", () => { - const active: ActiveSessionDirectories = new Map() - update(active, "retry", "/repo/source") - update(active, "idle", "/repo/worktree") - update(active, "idle") - - expect(resolveAbortDirectories(active, "session_1", "/repo/worktree")).toEqual(["/repo/source", "/repo/worktree"]) - }) - - it("tracks concurrent instances and ignores delayed idle events from the old one", () => { - const active: ActiveSessionDirectories = new Map() - update(active, "busy", "/repo/source") - update(active, "offline", "/repo/worktree") - - expect(resolveAbortDirectories(active, "session_1", "/repo/fallback")).toEqual([ - "/repo/source", - "/repo/worktree", - "/repo/fallback", - ]) - - update(active, "idle", "/repo/source") - expect(resolveAbortDirectories(active, "session_1", "/repo/fallback")).toEqual(["/repo/worktree", "/repo/fallback"]) - }) - - it("deduplicates the current directory when it is already active", () => { + it("deduplicates equivalent directory paths", () => { const active: ActiveSessionDirectories = new Map() update(active, "busy", "/repo/worktree") diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 45ef844f146..a909cbe407c 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, spyOn } from "bun:test" -import type { SessionStatus } from "@kilocode/sdk/v2/client" import type { PartUpdate } from "../../src/shared/stream-messages" // vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) @@ -45,8 +44,6 @@ function createClient(options?: { deleteDeferred?: Deferred sessionData?: unknown sessionGet?: (params: { sessionID: string; directory?: string }) => Promise<{ data: unknown }> - statusData?: Record - statusDeferred?: Deferred<{ data: Record }> abortFailures?: string[] }) { const calls: { before?: string; limit?: number }[] = [] @@ -62,7 +59,7 @@ function createClient(options?: { if (options?.sessionGet) return options.sessionGet(params) return { data: options?.sessionData ?? null } }, - status: async () => options?.statusDeferred?.promise ?? { data: options?.statusData ?? {} }, + status: async () => ({ data: {} }), abort: async (params: { sessionID: string; directory?: string }) => { aborted.push(params) if (params.directory && options?.abortFailures?.includes(params.directory)) throw new Error("abort failed") @@ -126,14 +123,11 @@ type ProviderInternals = { currentSession: { id: string; directory?: string } | null contextSessionID: string | undefined sessionDirectories: Map - activeSessionDirectories: Map> trackedSessionIds: Set streams: { push: (msg: PartUpdate) => void } stopCurrentSessionProcesses: (next?: string) => void handleEvent: (event: unknown, directory?: string) => void handleAbort: (sid?: string) => Promise - seedSessionStatusMap: (reconcile?: boolean) => Promise - refreshSessionDetails: (sid: string, dir: string, signal?: AbortSignal) => void handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise handleDeleteSession: (sid: string) => Promise } @@ -153,9 +147,30 @@ function makeProvider(client: ReturnType) { } describe("KiloProvider.handleAbort", () => { - it("keeps the active owner when a running session moves to a worktree", async () => { + it("aborts the original owner after a running session moves to a worktree", async () => { const client = createClient() const { provider, internal, sent } = makeProvider(client) + internal.handleEvent( + { + type: "session.status", + properties: { sessionID: "s1", status: { type: "busy" } }, + }, + "/repo", + ) + provider.setSessionDirectory("s1", "/repo/worktree") + + await internal.handleAbort("s1") + + expect(client.aborted).toEqual([ + { sessionID: "s1", directory: "/repo" }, + { sessionID: "s1", directory: "/repo/worktree" }, + ]) + expect(sent.at(-1)).toMatchObject({ type: "sessionStatus", sessionID: "s1", status: "idle" }) + }) + + it("preserves the original owner when the status event lacks a directory", async () => { + const client = createClient() + const { provider, internal } = makeProvider(client) internal.handleEvent({ type: "session.status", properties: { sessionID: "s1", status: { type: "busy" } }, @@ -168,30 +183,9 @@ describe("KiloProvider.handleAbort", () => { { sessionID: "s1", directory: "/repo" }, { sessionID: "s1", directory: "/repo/worktree" }, ]) - expect(sent.at(-1)).toMatchObject({ type: "sessionStatus", sessionID: "s1", status: "idle" }) }) - it("preserves a directory-qualified owner instead of inferring the current directory", async () => { - const client = createClient() - const { provider, internal } = makeProvider(client) - internal.handleEvent( - { - type: "session.status", - properties: { sessionID: "s1", status: { type: "busy" } }, - }, - "/repo/source", - ) - provider.setSessionDirectory("s1", "/repo/worktree") - - await internal.handleAbort("s1") - - expect(client.aborted).toEqual([ - { sessionID: "s1", directory: "/repo/source" }, - { sessionID: "s1", directory: "/repo/worktree" }, - ]) - }) - - it("attempts every owner when one abort request fails", async () => { + it("attempts every owner and stays busy when one abort fails", async () => { const error = spyOn(console, "error").mockImplementation(() => {}) const client = createClient({ abortFailures: ["/repo"] }) const { provider, internal, sent } = makeProvider(client) @@ -214,174 +208,6 @@ describe("KiloProvider.handleAbort", () => { expect(error).toHaveBeenCalledTimes(1) error.mockRestore() }) - - it("restores the remaining owner status when another owner reports idle", () => { - const client = createClient() - const { internal, sent } = makeProvider(client) - internal.handleEvent( - { - type: "session.status", - properties: { sessionID: "s1", status: { type: "offline", requestID: "request_1", message: "offline" } }, - }, - "/repo/source", - ) - internal.handleEvent( - { - type: "session.status", - properties: { sessionID: "s1", status: { type: "retry", attempt: 1, message: "retry", next: 1 } }, - }, - "/repo/worktree", - ) - internal.handleEvent( - { - type: "session.status", - properties: { sessionID: "s1", status: { type: "idle" } }, - }, - "/repo/worktree", - ) - - const statuses = sent.flatMap((msg) => - typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionStatus" - ? [(msg as { status: string }).status] - : [], - ) - expect(statuses).toEqual(["offline", "retry", "offline"]) - - internal.handleEvent( - { - type: "session.status", - properties: { sessionID: "s1", status: { type: "idle" } }, - }, - "/repo/source", - ) - expect( - sent.flatMap((msg) => - typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionStatus" - ? [(msg as { status: string }).status] - : [], - ), - ).toEqual(["offline", "retry", "offline", "idle"]) - }) - - it("restores the remaining owner when an active instance is disposed", () => { - const client = createClient() - const { internal, sent } = makeProvider(client) - internal.handleEvent( - { - type: "session.status", - properties: { sessionID: "s1", status: { type: "offline", requestID: "request_1", message: "offline" } }, - }, - "/repo/source", - ) - internal.handleEvent( - { - type: "session.status", - properties: { sessionID: "s1", status: { type: "retry", attempt: 1, message: "retry", next: 1 } }, - }, - "/repo/worktree", - ) - - internal.handleEvent({ - type: "server.instance.disposed", - properties: { directory: "/repo/worktree" }, - }) - - expect( - sent.flatMap((msg) => - typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionStatus" - ? [(msg as { status: string }).status] - : [], - ), - ).toEqual(["offline", "retry", "offline"]) - }) - - it("records an active owner discovered during status seeding", async () => { - const client = createClient({ statusData: { s1: { type: "busy" } } }) - const { provider, internal } = makeProvider(client) - provider.setSessionDirectory("s1", "/repo/worktree") - - await internal.seedSessionStatusMap() - await internal.handleAbort("s1") - - expect(client.aborted).toEqual([ - { sessionID: "s1", directory: "/repo" }, - { sessionID: "s1", directory: "/repo/worktree" }, - ]) - }) - - it("records a worktree owner discovered while refreshing session details", async () => { - const status = defer<{ data: Record }>() - const client = createClient({ statusDeferred: status }) - const { provider, internal } = makeProvider(client) - provider.setSessionDirectory("s1", "/repo/worktree") - internal.trackedSessionIds.add("s1") - - internal.refreshSessionDetails("s1", "/repo/worktree") - status.resolve({ data: { s1: { type: "busy" } } }) - await Bun.sleep(0) - provider.clearSessionDirectory("s1") - await internal.handleAbort("s1") - - expect(client.aborted).toEqual([ - { sessionID: "s1", directory: "/repo/worktree" }, - { sessionID: "s1", directory: "/repo" }, - ]) - }) - - it("does not overwrite a newer busy event with a stale status seed", async () => { - const status = defer<{ data: Record }>() - const client = createClient({ statusDeferred: status }) - const { provider, internal, sent } = makeProvider(client) - provider.setSessionDirectory("s1", "/repo/worktree") - - const seed = internal.seedSessionStatusMap() - internal.handleEvent( - { - type: "session.status", - properties: { sessionID: "s1", status: { type: "busy" } }, - }, - "/repo", - ) - status.resolve({ data: {} }) - await seed - await internal.handleAbort("s1") - - expect(client.aborted).toEqual([ - { sessionID: "s1", directory: "/repo" }, - { sessionID: "s1", directory: "/repo/worktree" }, - ]) - expect( - sent.flatMap((msg) => - typeof msg === "object" && msg && (msg as { type?: unknown }).type === "sessionStatus" - ? [(msg as { status: string }).status] - : [], - ), - ).toEqual(["busy", "idle"]) - }) - - it("keeps a seeded owner when another directory reports a newer status", async () => { - const status = defer<{ data: Record }>() - const client = createClient({ statusDeferred: status }) - const { provider, internal } = makeProvider(client) - provider.setSessionDirectory("s1", "/repo/worktree") - - const seed = internal.seedSessionStatusMap() - internal.handleEvent( - { - type: "session.status", - properties: { sessionID: "s1", status: { type: "busy" } }, - }, - "/repo/worktree", - ) - status.resolve({ data: { s1: { type: "busy" } } }) - await seed - await internal.handleAbort("s1") - - expect(client.aborted).toEqual([ - { sessionID: "s1", directory: "/repo/worktree" }, - { sessionID: "s1", directory: "/repo" }, - ]) - }) }) describe("KiloProvider.handleLoadMessages / focus mode freshness", () => { From 344a6a5f0f8377d8ab38792e6141d08947a7dc19 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 11:42:15 +0200 Subject: [PATCH 29/30] fix(cli): share listener session runtime --- .changeset/share-server-session-runtime.md | 6 ++ .../src/kilocode/server/httpapi/server.ts | 26 +++++++ .../opencode/src/kilocode/server/listener.ts | 8 +++ .../routes/instance/httpapi/handlers/v2.ts | 2 +- .../server/routes/instance/httpapi/server.ts | 15 ++++- packages/opencode/src/server/server.ts | 5 +- .../kilocode/server/listener-runtime.test.ts | 67 +++++++++++++++++++ script/check-opencode-promise-facades.ts | 1 + 8 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 .changeset/share-server-session-runtime.md create mode 100644 packages/opencode/src/kilocode/server/listener.ts create mode 100644 packages/opencode/test/kilocode/server/listener-runtime.test.ts diff --git a/.changeset/share-server-session-runtime.md b/.changeset/share-server-session-runtime.md new file mode 100644 index 00000000000..16bcb875580 --- /dev/null +++ b/.changeset/share-server-session-runtime.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Keep server controls and events connected to active sessions and subagents. diff --git a/packages/opencode/src/kilocode/server/httpapi/server.ts b/packages/opencode/src/kilocode/server/httpapi/server.ts index e8e1e97879c..3c6078e9d17 100644 --- a/packages/opencode/src/kilocode/server/httpapi/server.ts +++ b/packages/opencode/src/kilocode/server/httpapi/server.ts @@ -1,4 +1,10 @@ import { Layer } from "effect" +import { FetchHttpClient, HttpMiddleware, HttpRouter, HttpServer } from "effect/unstable/http" +import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" +import { compressionLayer } from "@/server/routes/instance/httpapi/middleware/compression" +import { corsVaryFix } from "@/server/routes/instance/httpapi/middleware/cors-vary" +import { errorLayer } from "@/server/routes/instance/httpapi/middleware/error" +import { fenceLayer } from "@/server/routes/instance/httpapi/middleware/fence" import { agentBuilderHandlers } from "./handlers/agent-builder" import { backgroundProcessHandlers } from "./handlers/background-process" @@ -29,3 +35,23 @@ export const provide = Layer.provide([ suggestionHandlers, telemetryHandlers, ]) + +export function provideListener(opts?: CorsOptions) { + const cors = HttpRouter.middleware( + HttpMiddleware.cors({ + allowedOrigins: (origin) => isAllowedCorsOrigin(origin, opts), + maxAge: 86_400, + }), + { global: true }, + ) + return Layer.provide([ + errorLayer, + compressionLayer, + corsVaryFix, + fenceLayer, + cors, + FetchHttpClient.layer, + HttpServer.layerServices, + Layer.succeed(CorsConfig)(opts), + ]) +} diff --git a/packages/opencode/src/kilocode/server/listener.ts b/packages/opencode/src/kilocode/server/listener.ts new file mode 100644 index 00000000000..3981a96b290 --- /dev/null +++ b/packages/opencode/src/kilocode/server/listener.ts @@ -0,0 +1,8 @@ +import { AppLayer } from "@/effect/app-runtime" +import { memoMap } from "@opencode-ai/core/effect/memo-map" +import { Layer, Scope } from "effect" + +export function build(layer: Layer.Layer, scope: Scope.Scope) { + // Keep listener transport state fresh while AppLayer reuses the process-wide services. + return Layer.buildWithMemoMap(Layer.fresh(layer).pipe(Layer.provide(AppLayer)), memoMap, scope) +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts index daa799b7a8b..c110d06b368 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/v2.ts @@ -8,5 +8,5 @@ import { sessionHandlers } from "./v2/session" export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers, providerHandlers).pipe( Layer.provide(v2LocationLayer), - Layer.provide(SessionV2.defaultLayer), + Layer.provide(SessionV2.layer), // kilocode_change - use the application EventV2Bridge ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index 5f426cf1381..d8d7dcd54f1 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -82,7 +82,12 @@ import { syncHandlers } from "./handlers/sync" import { tuiHandlers } from "./handlers/tui" import { v2Handlers } from "./handlers/v2" import { workspaceHandlers } from "./handlers/workspace" -import { provide as provideKiloHttpApiHandlers } from "@/kilocode/server/httpapi/server" // kilocode_change +// kilocode_change start +import { + provide as provideKiloHttpApiHandlers, + provideListener as provideKiloListenerRoutes, +} from "@/kilocode/server/httpapi/server" +// kilocode_change end import { instanceContextLayer, instanceRouterMiddleware } from "./middleware/instance-context" import { workspaceRouterMiddleware, workspaceRoutingLayer } from "./middleware/workspace-routing" import { disposeMiddleware } from "./lifecycle" @@ -248,6 +253,14 @@ export function createRoutes( ) } +// kilocode_change start - keep listener routes local while application services come from AppRuntime +export function createListenerRoutes(corsOptions?: CorsOptions) { + return Layer.mergeAll(rootApiRoutes, eventApiRoutes, instanceRoutes, docRoute, uiRoute).pipe( + provideKiloListenerRoutes(corsOptions), + ) +} +// kilocode_change end + export const routes = createRoutes() export const webHandler = lazy(() => diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 9a448b78d62..81f5e55c4e9 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -13,6 +13,7 @@ import { WebSocketTracker } from "./routes/instance/httpapi/websocket-tracker" import { PublicApi } from "./routes/instance/httpapi/public" import type { CorsOptions } from "./cors" import { lazy } from "@/util/lazy" +import * as KiloListener from "@/kilocode/server/listener" // kilocode_change // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false @@ -101,7 +102,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect Scope.close(scope, Exit.void).pipe(Effect.ignore)), Effect.map( diff --git a/packages/opencode/test/kilocode/server/listener-runtime.test.ts b/packages/opencode/test/kilocode/server/listener-runtime.test.ts new file mode 100644 index 00000000000..c3bd6c78bf3 --- /dev/null +++ b/packages/opencode/test/kilocode/server/listener-runtime.test.ts @@ -0,0 +1,67 @@ +import { afterEach, expect, test } from "bun:test" +import { Flag } from "@opencode-ai/core/flag/flag" +import * as Log from "@opencode-ai/core/util/log" +import { Effect } from "effect" +import { AppRuntime } from "../../../src/effect/app-runtime" +import { InstanceRef } from "../../../src/effect/instance-ref" +import { Server } from "../../../src/server/server" +import { SessionPaths } from "../../../src/server/routes/instance/httpapi/groups/session" +import { SessionRunState } from "../../../src/session/run-state" +import { SessionID } from "../../../src/session/schema" +import { withTimeout } from "../../../src/util/timeout" +import { resetDatabase } from "../../fixture/db" +import { disposeAllInstances, reloadTestInstance, tmpdir } from "../../fixture/fixture" + +void Log.init({ print: false }) + +const previous = { + flag: Flag.KILO_SERVER_PASSWORD, + env: process.env.KILO_SERVER_PASSWORD, +} + +afterEach(async () => { + Flag.KILO_SERVER_PASSWORD = previous.flag + if (previous.env === undefined) delete process.env.KILO_SERVER_PASSWORD + else process.env.KILO_SERVER_PASSWORD = previous.env + await disposeAllInstances() + await resetDatabase() +}) + +test("listener aborts shared session runners", async () => { + Flag.KILO_SERVER_PASSWORD = undefined + delete process.env.KILO_SERVER_PASSWORD + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const ctx = await reloadTestInstance({ directory: tmp.path }) + const sessionID = SessionID.descending() + const started = Promise.withResolvers() + const stopped = Promise.withResolvers() + const running = AppRuntime.runPromise( + SessionRunState.Service.use((state) => + state.ensureRunning( + sessionID, + Effect.interrupt, + Effect.sync(started.resolve).pipe(Effect.andThen(Effect.never), Effect.ensuring(Effect.sync(stopped.resolve))), + ), + ).pipe(Effect.provideService(InstanceRef, ctx)), + ).catch(() => undefined) + + try { + await withTimeout(started.promise, 5_000, "timed out waiting for shared session") + const listener = await Server.listen({ hostname: "127.0.0.1", port: 0 }) + try { + const response = await fetch(new URL(SessionPaths.abort.replace(":sessionID", sessionID), listener.url), { + method: "POST", + headers: { "x-kilo-directory": tmp.path }, + }) + expect(response.status).toBe(200) + await withTimeout(stopped.promise, 5_000, "listener did not interrupt the shared session") + } finally { + await withTimeout(listener.stop(true), 10_000, "timed out cleaning up shared-runtime listener") + } + } finally { + await AppRuntime.runPromise( + SessionRunState.Service.use((state) => state.cancel(sessionID)).pipe(Effect.provideService(InstanceRef, ctx)), + ).catch(() => undefined) + await running + } +}, 20_000) diff --git a/script/check-opencode-promise-facades.ts b/script/check-opencode-promise-facades.ts index ea654c5e51c..a231c97b73a 100644 --- a/script/check-opencode-promise-facades.ts +++ b/script/check-opencode-promise-facades.ts @@ -40,6 +40,7 @@ const testAllow: Record = { "provider/provider.test.ts": { count: 3, reason: "existing runtime integration test" }, "server/experimental-session-list.test.ts": { count: 2, reason: "Kilo session list integration test" }, "server/httpapi-event.test.ts": { count: 6, reason: "event stream integration test" }, + "kilocode/server/listener-runtime.test.ts": { count: 3, reason: "listener and AppRuntime integration test" }, "session/llm.test.ts": { count: 2, reason: "existing runtime integration test" }, "tool/recall.test.ts": { count: 11, reason: "existing runtime integration test" }, } From 09a7e89f5be6b6b543b903f189990a16cfb4c66a Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Thu, 18 Jun 2026 12:36:31 +0200 Subject: [PATCH 30/30] refactor(vscode): extract session abort tracking --- packages/kilo-vscode/src/KiloProvider.ts | 81 +++--------------- .../kilo-vscode/src/kilo-provider/abort.ts | 84 +++++++++++++------ packages/kilo-vscode/tests/unit/abort.test.ts | 62 ++++++++------ 3 files changed, 109 insertions(+), 118 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 06130e85e79..da0ea7d794b 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -72,12 +72,7 @@ import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-pa import { childID } from "./kilo-provider/task-session" import { VisibleTaskStreams } from "./kilo-provider/visible-task-streams" import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" -import { - abortSession, - resolveAbortDirectories, - updateActiveSessionDirectory, - type ActiveSessionDirectories, -} from "./kilo-provider/abort" +import { SessionAbort } from "./kilo-provider/abort" import { buildAutocompleteSettingsMessage, validAutocompleteSetting, @@ -315,7 +310,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private syncedChildSessions: Set = new Set() private sessionStatusMap = new Map() // Latest status used for destructive config warnings. private sessionDirectories = new Map() // Per-session directory overrides, such as Agent Manager worktrees. - private activeSessionDirectories: ActiveSessionDirectories = new Map() + private readonly aborts = new SessionAbort() private projectID: string | undefined // Current workspace project ID used to filter sessions. private loadMessagesAbort: AbortController | null = null // Current load request cancellation. private lastReconciledAt = new Map() // Per-session focus-mode reconcile timestamp. @@ -663,26 +658,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * When set, all operations for this session use this directory instead of the workspace root. */ public setSessionDirectory(sessionId: string, directory: string): void { - this.preserveDirectory(sessionId) + this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId)) this.sessionDirectories.set(sessionId, directory) } public clearSessionDirectory(sessionId: string): void { - this.preserveDirectory(sessionId) + this.aborts.preserve(sessionId, this.sessionStatusMap.get(sessionId), this.getWorkspaceDirectory(sessionId)) this.sessionDirectories.delete(sessionId) } - private preserveDirectory(sessionID: string): void { - const status = this.sessionStatusMap.get(sessionID) - if (!status || status === "idle" || this.activeSessionDirectories.has(sessionID)) return - updateActiveSessionDirectory({ - active: this.activeSessionDirectories, - sessionID, - status, - dir: this.getWorkspaceDirectory(sessionID), - }) - } - /** Exposes the session→directory map so callers outside the webview can resolve worktree paths. */ public getSessionDirectories(): ReadonlyMap { return this.sessionDirectories @@ -1774,7 +1758,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.visibleTaskStreams.delete(sessionID) this.syncedChildSessions.delete(sessionID) this.sessionDirectories.delete(sessionID) - this.activeSessionDirectories.delete(sessionID) + this.aborts.delete(sessionID) this.lastReconciledAt.delete(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { @@ -2774,35 +2758,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } private async handleAbort(sessionID?: string): Promise { - const client = this.client - if (!client) return - - const target = sessionID || this.currentSession?.id - if (!target) return - - const known = this.activeSessionDirectories.has(target) - const dirs = resolveAbortDirectories(this.activeSessionDirectories, target, this.getWorkspaceDirectory(target)) - const results = await Promise.allSettled( - dirs.map((dir) => - abortSession({ - client, - sessionID: target, - dir, - }), - ), - ) - const failures = results.flatMap((result, index) => - result.status === "rejected" ? [{ dir: dirs[index], error: result.reason }] : [], - ) - if (known && failures.length === 0) { - this.activeSessionDirectories.delete(target) - this.sessionStatusMap.set(target, "idle") - this.streams.flush(target) - this.postMessage({ type: "sessionStatus", sessionID: target, status: "idle" }) - } - if (failures.length > 0) { - console.error("[Kilo New] KiloProvider: Failed to abort session in one or more directories:", failures) - } + const sid = sessionID || this.currentSession?.id + if (!this.client || !sid || !(await this.aborts.stop(this.client, sid, this.getWorkspaceDirectory(sid)))) return + this.sessionStatusMap.set(sid, "idle") + this.streams.flush(sid) + this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) } private async handleRevertSession(sessionID: string, messageID: string, partID?: string): Promise { @@ -3187,12 +3147,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (event.type === "session.status") { const sid = event.properties.sessionID this.sessionStatusMap.set(sid, event.properties.status.type) - updateActiveSessionDirectory({ - active: this.activeSessionDirectories, - sessionID: sid, - status: event.properties.status.type, - dir: directory, - }) + this.aborts.observe(sid, event.properties.status.type, directory) const msg = mapSSEEventToWebviewMessage(event, sid) if (msg) { this.streams.flush(sid) @@ -3225,17 +3180,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (event.type === "server.instance.disposed") { const props = event.properties as Record | null const dir = typeof props?.directory === "string" ? props.directory : undefined - if (dir) { - for (const sessionID of [...this.activeSessionDirectories.keys()]) { - updateActiveSessionDirectory({ - active: this.activeSessionDirectories, - sessionID, - status: "idle", - dir, - }) - if (!this.activeSessionDirectories.has(sessionID)) this.sessionStatusMap.set(sessionID, "idle") - } - } + if (dir) for (const sid of this.aborts.dispose(dir)) this.sessionStatusMap.set(sid, "idle") if (dir && !sameDirectory(dir, this.getWorkspaceDirectory())) return void this.reloadAfterAuthChange() return @@ -3648,7 +3593,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.clear() this.syncedChildSessions.clear() this.sessionDirectories.clear() - this.activeSessionDirectories.clear() + this.aborts.clear() this.sessionStatusMap.clear() this.ignoreController?.dispose() this.chatAutocomplete?.dispose() diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index fa0eeb3edfe..7bb355b5b26 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -1,36 +1,68 @@ import type { KiloClient, SessionStatus } from "@kilocode/sdk/v2/client" import { sameDirectory } from "../kilo-provider-utils" -export type ActiveSessionDirectories = Map> +export class SessionAbort { + private active = new Map>() -export function updateActiveSessionDirectory(input: { - active: ActiveSessionDirectories - sessionID: string - status: SessionStatus["type"] - dir?: string -}) { - const target = input.dir - if (!target) return - const dirs = input.active.get(input.sessionID) - if (input.status === "idle") { - if (!dirs) return - for (const dir of dirs) { - if (sameDirectory(dir, target)) dirs.delete(dir) + observe(sessionID: string, status: SessionStatus["type"], dir?: string) { + if (!dir) return + const dirs = this.active.get(sessionID) + if (status === "idle") { + if (!dirs) return + for (const entry of dirs) { + if (sameDirectory(entry, dir)) dirs.delete(entry) + } + if (dirs.size === 0) this.active.delete(sessionID) + return } - if (dirs.size === 0) input.active.delete(input.sessionID) - return + if (!dirs) { + this.active.set(sessionID, new Set([dir])) + return + } + if (![...dirs].some((entry) => sameDirectory(entry, dir))) dirs.add(dir) } - if (!dirs) { - input.active.set(input.sessionID, new Set([target])) - return - } - if (![...dirs].some((dir) => sameDirectory(dir, target))) dirs.add(target) -} -export function resolveAbortDirectories(active: ActiveSessionDirectories, sessionID: string, fallback: string) { - const dirs = [...(active.get(sessionID) ?? [])] - if (!dirs.some((dir) => sameDirectory(dir, fallback))) dirs.push(fallback) - return dirs + preserve(sessionID: string, status: SessionStatus["type"] | undefined, dir: string) { + if (!status || status === "idle" || this.active.has(sessionID)) return + this.observe(sessionID, status, dir) + } + + async stop(client: KiloClient, sessionID: string, fallback: string) { + const known = this.active.has(sessionID) + const dirs = [...(this.active.get(sessionID) ?? [])] + if (!dirs.some((dir) => sameDirectory(dir, fallback))) dirs.push(fallback) + const results = await Promise.allSettled(dirs.map((dir) => abortSession({ client, sessionID, dir }))) + const failures = results.flatMap((result, index) => + result.status === "rejected" ? [{ dir: dirs[index], error: result.reason }] : [], + ) + if (failures.length > 0) { + console.error("[Kilo New] KiloProvider: Failed to abort session in one or more directories:", failures) + return false + } + if (known) this.active.delete(sessionID) + return known + } + + dispose(dir: string) { + const idle: string[] = [] + for (const [sessionID, dirs] of this.active) { + for (const entry of dirs) { + if (sameDirectory(entry, dir)) dirs.delete(entry) + } + if (dirs.size > 0) continue + this.active.delete(sessionID) + idle.push(sessionID) + } + return idle + } + + delete(sessionID: string) { + this.active.delete(sessionID) + } + + clear() { + this.active.clear() + } } export async function abortSession(input: { client: KiloClient; sessionID: string; dir: string }) { diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index 1c07d9c02b0..e971a1211b8 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "bun:test" import type { KiloClient } from "@kilocode/sdk/v2/client" -import { - abortSession, - resolveAbortDirectories, - updateActiveSessionDirectory, - type ActiveSessionDirectories, -} from "../../src/kilo-provider/abort" +import { abortSession, SessionAbort } from "../../src/kilo-provider/abort" function client(calls: unknown[], fail = false) { return { @@ -19,31 +14,50 @@ function client(calls: unknown[], fail = false) { } as unknown as KiloClient } -describe("active session directories", () => { - function update(active: ActiveSessionDirectories, status: "busy" | "idle", dir: string) { - updateActiveSessionDirectory({ active, sessionID: "session_1", status, dir }) - } +describe("SessionAbort", () => { + it("stops the active owner and current mapped directory", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo") - it("includes the active owner and current mapped directory", () => { - const active: ActiveSessionDirectories = new Map() - update(active, "busy", "/repo") - - expect(resolveAbortDirectories(active, "session_1", "/repo/worktree")).toEqual(["/repo", "/repo/worktree"]) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(true) + expect(calls).toEqual([ + { + type: "abort", + params: { sessionID: "session_1", directory: "/repo" }, + opts: { throwOnError: true }, + }, + { + type: "abort", + params: { sessionID: "session_1", directory: "/repo/worktree" }, + opts: { throwOnError: true }, + }, + ]) }) - it("removes an owner when its instance becomes idle", () => { - const active: ActiveSessionDirectories = new Map() - update(active, "busy", "/repo") - update(active, "idle", "/repo") + it("forgets an owner when its instance becomes idle", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo") + aborts.observe("session_1", "idle", "/repo") - expect(resolveAbortDirectories(active, "session_1", "/repo/worktree")).toEqual(["/repo/worktree"]) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(false) + expect(calls).toEqual([ + { + type: "abort", + params: { sessionID: "session_1", directory: "/repo/worktree" }, + opts: { throwOnError: true }, + }, + ]) }) - it("deduplicates equivalent directory paths", () => { - const active: ActiveSessionDirectories = new Map() - update(active, "busy", "/repo/worktree") + it("deduplicates equivalent directory paths", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo/worktree") - expect(resolveAbortDirectories(active, "session_1", "/repo/worktree/.")).toEqual(["/repo/worktree"]) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toBe(true) + expect(calls).toHaveLength(1) }) })