From 96ce0cb5f0bbe16d98ca98a9500ebfa4b2bdd271 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 16 Apr 2026 16:34:15 +0300 Subject: [PATCH 01/35] fix(cli,vscode): fix native memory leak in Agent Manager git polling Reduce git process spawn rate, cap stdout buffers, cache merge-base results, and dispose CLI instances when worktrees are deleted. --- .../src/agent-manager/AgentManagerProvider.ts | 18 +++ .../src/agent-manager/GitStatsPoller.ts | 2 +- .../agent-manager/worktree-diff-controller.ts | 2 +- .../unit/memory-instance-dispose.test.ts | 61 ++++++++ .../unit/memory-polling-intervals.test.ts | 30 ++++ .../src/kilocode/review/worktree-diff.ts | 130 ++++++++++++------ .../review/worktree-diff-buffer.test.ts | 76 ++++++++++ .../review/worktree-diff-cache.test.ts | 64 +++++++++ .../review/worktree-diff-memory.test.ts | 74 ++++++++++ 9 files changed, 415 insertions(+), 42 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/memory-instance-dispose.test.ts create mode 100644 packages/kilo-vscode/tests/unit/memory-polling-intervals.test.ts create mode 100644 packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts create mode 100644 packages/opencode/test/kilocode/review/worktree-diff-cache.test.ts create mode 100644 packages/opencode/test/kilocode/review/worktree-diff-memory.test.ts diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 2571e7fa2a0..7acb0a2a6f4 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -803,6 +803,15 @@ export class AgentManagerProvider implements Disposable { } for (const s of orphaned) this.panel?.sessions.clearSessionDirectory(s.id) this.pushState() + // Dispose the CLI Instance (file watchers, LSP, snapshot repo, PubSub) + // before removing the directory so the server can clean up properly. + try { + const client = this.connectionService.getClient() + await client.instance.dispose({ directory: worktree.path }) + this.log(`Disposed CLI instance for worktree ${worktreeId}`) + } catch (err) { + this.log(`instance.dispose() for worktree ${worktreeId} failed (non-fatal):`, err) + } // Disk removal after state is clean — pollers no longer reference this worktree. try { await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch) @@ -836,6 +845,15 @@ export class AgentManagerProvider implements Disposable { for (const session of orphaned) { this.panel?.sessions.clearSessionDirectory(session.id) } + // Dispose the CLI Instance even though the directory may be gone — the + // server cache entry still holds resources (PubSub queues, DB connections). + try { + const client = this.connectionService.getClient() + await client.instance.dispose({ directory: worktree.path }) + this.log(`Disposed CLI instance for stale worktree ${worktreeId}`) + } catch (err) { + this.log(`instance.dispose() for stale worktree ${worktreeId} failed (non-fatal):`, err) + } this.clearStaleTracking(worktreeId) this.pushState() this.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`) diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index b815658168d..8eacfe159d6 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -69,7 +69,7 @@ export class GitStatsPoller { private visible = true constructor(private readonly options: GitStatsPollerOptions) { - this.intervalMs = options.intervalMs ?? 5000 + this.intervalMs = options.intervalMs ?? 15_000 this.hiddenIntervalMs = options.hiddenIntervalMs ?? 60000 this.git = options.git } diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index a4ec1dabbd9..b40b824abb4 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -200,7 +200,7 @@ export class WorktreeDiffController { if (this.session !== sessionId) return this.interval = setInterval(() => { void this.poll(sessionId) - }, 2500) + }, 15_000) }) } diff --git a/packages/kilo-vscode/tests/unit/memory-instance-dispose.test.ts b/packages/kilo-vscode/tests/unit/memory-instance-dispose.test.ts new file mode 100644 index 00000000000..381aca23f29 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/memory-instance-dispose.test.ts @@ -0,0 +1,61 @@ +/** + * Guardrail tests: CLI instance disposal on worktree deletion. + * + * Deleted worktrees must have their CLI Instance disposed to release + * file watchers, LSP, snapshot repos, and PubSub queues. Without + * disposal, these resources accumulate permanently in the kilo serve + * process. + */ + +import { describe, it, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" +import { Project, SyntaxKind } from "ts-morph" + +const ROOT = path.resolve(import.meta.dir, "../..") +const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts") + +function body(name: string): string { + const project = new Project({ compilerOptions: { allowJs: true } }) + const source = project.addSourceFileAtPath(PROVIDER_FILE) + const cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration) + const method = cls?.getMethod(name) + expect(method, `method ${name} not found in AgentManagerProvider`).toBeTruthy() + return method!.getText() +} + +describe("Memory — CLI instance disposal", () => { + it("onDeleteWorktree calls instance.dispose() with the worktree directory", () => { + const text = body("onDeleteWorktree") + expect(text).toContain("instance.dispose(") + expect(text).toContain("worktree.path") + }) + + it("onRemoveStaleWorktree calls instance.dispose()", () => { + const text = body("onRemoveStaleWorktree") + expect(text).toContain("instance.dispose(") + expect(text).toContain("worktree.path") + }) + + it("instance.dispose() failure does not block worktree deletion", () => { + const text = body("onDeleteWorktree") + // The dispose call must be wrapped in try/catch so failures don't + // prevent disk removal or state cleanup. + const disposeIdx = text.indexOf("instance.dispose(") + const catchIdx = text.indexOf("catch", disposeIdx) + const removeIdx = text.indexOf("manager.removeWorktree", disposeIdx) + expect(disposeIdx, "dispose call must exist").toBeGreaterThan(-1) + expect(catchIdx, "catch must follow dispose").toBeGreaterThan(disposeIdx) + expect(removeIdx, "disk removal must follow dispose+catch").toBeGreaterThan(catchIdx) + }) + + it("instance.dispose() failure does not block stale worktree removal", () => { + const text = body("onRemoveStaleWorktree") + const disposeIdx = text.indexOf("instance.dispose(") + const catchIdx = text.indexOf("catch", disposeIdx) + const clearIdx = text.indexOf("clearStaleTracking", disposeIdx) + expect(disposeIdx, "dispose call must exist").toBeGreaterThan(-1) + expect(catchIdx, "catch must follow dispose").toBeGreaterThan(disposeIdx) + expect(clearIdx, "clearStaleTracking must follow dispose+catch").toBeGreaterThan(catchIdx) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/memory-polling-intervals.test.ts b/packages/kilo-vscode/tests/unit/memory-polling-intervals.test.ts new file mode 100644 index 00000000000..f5f3ba9c19f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/memory-polling-intervals.test.ts @@ -0,0 +1,30 @@ +/** + * Guardrail tests: polling interval minimums. + * + * Aggressive polling (< 10s) caused runaway native memory growth by spawning + * too many git processes per minute. These tests prevent accidental regression. + */ + +import { describe, it, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") + +describe("Memory — polling intervals", () => { + it("WorktreeDiffController polls at >= 10 000 ms", () => { + const src = fs.readFileSync(path.join(ROOT, "src/agent-manager/worktree-diff-controller.ts"), "utf-8") + const match = src.match(/setInterval\(\s*\(\)\s*=>\s*\{[^}]*\}\s*,\s*([\d_]+)\s*\)/) + expect(match, "setInterval call must exist in WorktreeDiffController").toBeTruthy() + const ms = Number(match![1]!.replace(/_/g, "")) + expect(ms).toBeGreaterThanOrEqual(10_000) + }) + + it("GitStatsPoller default interval is >= 10 000 ms", () => { + const src = fs.readFileSync(path.join(ROOT, "src/agent-manager/GitStatsPoller.ts"), "utf-8") + const match = src.match(/options\.intervalMs\s*\?\?\s*([\d_]+)/) + expect(match, "default intervalMs must exist in GitStatsPoller").toBeTruthy() + const ms = Number(match![1]!.replace(/_/g, "")) + expect(ms).toBeGreaterThanOrEqual(10_000) + }) +}) diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts index 3bccf81c79c..5756ed85fc2 100644 --- a/packages/opencode/src/kilocode/review/worktree-diff.ts +++ b/packages/opencode/src/kilocode/review/worktree-diff.ts @@ -1,5 +1,4 @@ // kilocode_change - new file -import { $ } from "bun" import { createTwoFilesPatch } from "diff" import fs from "node:fs/promises" import path from "node:path" @@ -8,6 +7,56 @@ import { FileIgnore } from "@/file/ignore" import { Snapshot } from "@/snapshot" import { Log } from "@/util/log" +// --------------------------------------------------------------------------- +// Git subprocess helper — caps stdout to prevent unbounded native memory growth +// --------------------------------------------------------------------------- + +const MAX_STDOUT = 10 * 1024 * 1024 // 10 MB general cap +const MAX_FILE_STDOUT = 1 * 1024 * 1024 // 1 MB per-file cap (readBefore) + +async function git( + args: string[], + cwd: string, + limit = MAX_STDOUT, +): Promise<{ ok: boolean; stdout: string; stderr: string }> { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + const chunks: Buffer[] = [] + let size = 0 + let truncated = false + const reader = proc.stdout.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + if (truncated) continue // drain pipe but don't store + size += value.length + if (size > limit) { + truncated = true + continue + } + chunks.push(Buffer.from(value)) + } + const code = await proc.exited + // Consume stderr to prevent blocking the child process pipe + const stderr = await new Response(proc.stderr).text() + return { + ok: code === 0, + stdout: Buffer.concat(chunks).toString(), + stderr, + } +} + +// --------------------------------------------------------------------------- +// Merge-base cache — avoids redundant git spawns across polling cycles +// --------------------------------------------------------------------------- + +const ancestors = new Map() +const ANCESTOR_TTL = 30_000 // 30 seconds + export namespace WorktreeDiff { export const Item = Snapshot.FileDiff.extend({ before: z.string(), @@ -37,29 +86,36 @@ export namespace WorktreeDiff { return FileIgnore.match(file) } + /** Clear the merge-base cache. Exported for testing. */ + export function clearCache() { + ancestors.clear() + } + async function ancestor(dir: string, base: string, log: Log.Logger) { - const result = await $`git merge-base HEAD ${base}`.cwd(dir).quiet().nothrow() - if (result.exitCode !== 0) { + const key = `${dir}\0${base}` + const cached = ancestors.get(key) + if (cached && Date.now() < cached.expires) return cached.hash + + const result = await git(["merge-base", "HEAD", base], dir) + if (!result.ok) { log.warn("git merge-base failed", { - exitCode: result.exitCode, - stderr: result.stderr.toString().trim(), + stderr: result.stderr.trim(), dir, base, }) return } - return result.stdout.toString().trim() + const hash = result.stdout.trim() + ancestors.set(key, { hash, expires: Date.now() + ANCESTOR_TTL }) + return hash } async function stats(dir: string, ancestor: string) { - const result = await $`git -c core.quotepath=false diff --numstat --no-renames ${ancestor}` - .cwd(dir) - .quiet() - .nothrow() + const result = await git(["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", ancestor], dir) const map = new Map() - if (result.exitCode !== 0) return map + if (!result.ok) return map - for (const line of result.stdout.toString().trim().split("\n")) { + for (const line of result.stdout.trim().split("\n")) { if (!line) continue const parts = line.split("\t") const add = parts[0] @@ -76,17 +132,14 @@ export namespace WorktreeDiff { } async function list(dir: string, ancestor: string, log: Log.Logger): Promise { - const nameStatus = await $`git -c core.quotepath=false diff --name-status --no-renames ${ancestor}` - .cwd(dir) - .quiet() - .nothrow() - if (nameStatus.exitCode !== 0) return [] + const nameStatus = await git(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", ancestor], dir) + if (!nameStatus.ok) return [] const result: Meta[] = [] const seen = new Set() const stat = await stats(dir, ancestor) - for (const line of nameStatus.stdout.toString().trim().split("\n")) { + for (const line of nameStatus.stdout.trim().split("\n")) { if (!line) continue const parts = line.split("\t") const code = parts[0] @@ -107,16 +160,13 @@ export namespace WorktreeDiff { }) } - const untracked = await $`git ls-files --others --exclude-standard`.cwd(dir).quiet().nothrow() - if (untracked.exitCode !== 0) { - log.warn("git ls-files failed", { - exitCode: untracked.exitCode, - stderr: untracked.stderr.toString().trim(), - }) + const untracked = await git(["ls-files", "--others", "--exclude-standard"], dir) + if (!untracked.ok) { + log.warn("git ls-files failed", { stderr: untracked.stderr.trim() }) return result } - const files = untracked.stdout.toString().trim() + const files = untracked.stdout.trim() if (files) { log.info("untracked files found", { count: files.split("\n").length }) } @@ -140,8 +190,8 @@ export namespace WorktreeDiff { } async function detailMeta(dir: string, ancestor: string, file: string): Promise { - const tracked = await $`git ls-files --error-unmatch -- ${file}`.cwd(dir).quiet().nothrow() - if (tracked.exitCode !== 0) { + const tracked = await git(["ls-files", "--error-unmatch", "--", file], dir) + if (!tracked.ok) { const after = Bun.file(path.join(dir, file)) if (!(await after.exists())) return undefined return { @@ -155,12 +205,12 @@ export namespace WorktreeDiff { } } - const nameStatus = await $`git -c core.quotepath=false diff --name-status --no-renames ${ancestor} -- ${file}` - .cwd(dir) - .quiet() - .nothrow() - if (nameStatus.exitCode !== 0) return undefined - const line = nameStatus.stdout.toString().trim().split("\n")[0] + const nameStatus = await git( + ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", ancestor, "--", file], + dir, + ) + if (!nameStatus.ok) return undefined + const line = nameStatus.stdout.trim().split("\n")[0] if (!line) return undefined const parts = line.split("\t") @@ -168,11 +218,11 @@ export namespace WorktreeDiff { const pathPart = parts.slice(1).join("\t") || file if (!code) return undefined - const numstat = await $`git -c core.quotepath=false diff --numstat --no-renames ${ancestor} -- ${file}` - .cwd(dir) - .quiet() - .nothrow() - const statLine = numstat.stdout.toString().trim().split("\n")[0] + const numstat = await git( + ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", ancestor, "--", file], + dir, + ) + const statLine = numstat.stdout.trim().split("\n")[0] const stat = statLine ? (() => { const values = statLine.split("\t") @@ -229,8 +279,8 @@ export namespace WorktreeDiff { async function readBefore(dir: string, ancestor: string, file: string, status: Status) { if (status === "added") return "" - const result = await $`git show ${ancestor}:${file}`.cwd(dir).quiet().nothrow() - return result.exitCode === 0 ? result.stdout.toString() : "" + const result = await git(["show", `${ancestor}:${file}`], dir, MAX_FILE_STDOUT) + return result.ok ? result.stdout : "" } async function readAfter(dir: string, file: string, status: Status) { diff --git a/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts new file mode 100644 index 00000000000..18c67798965 --- /dev/null +++ b/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts @@ -0,0 +1,76 @@ +/** + * Tests for the git() buffer-capped subprocess helper in worktree-diff.ts. + * + * Verifies that: + * - Output exceeding MAX_STDOUT is truncated (not accumulated unboundedly) + * - Truncated results don't crash downstream parsing + * - windowsHide is set on spawned processes + * - readBefore respects the per-file 1 MB limit + */ + +import { describe, test, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const SRC = path.resolve(import.meta.dir, "../../../src/kilocode/review/worktree-diff.ts") + +describe("worktree-diff buffer caps", () => { + const src = fs.readFileSync(SRC, "utf-8") + + test("MAX_STDOUT is defined and <= 10 MB", () => { + const match = src.match(/const MAX_STDOUT\s*=\s*(.+)/) + expect(match).toBeTruthy() + // Evaluate: 10 * 1024 * 1024 = 10485760 + expect(src).toContain("10 * 1024 * 1024") + }) + + test("MAX_FILE_STDOUT is defined and <= 1 MB", () => { + const match = src.match(/const MAX_FILE_STDOUT\s*=\s*(.+)/) + expect(match).toBeTruthy() + expect(src).toContain("1 * 1024 * 1024") + }) + + test("git() helper sets windowsHide: true", () => { + // Find the git() function and verify windowsHide + const fnStart = src.indexOf("async function git(") + expect(fnStart).toBeGreaterThan(-1) + const fnBody = src.slice(fnStart, fnStart + 600) + expect(fnBody).toContain("windowsHide: true") + }) + + test("git() helper uses Bun.spawn (not $ template)", () => { + // The file should not import $ from bun + expect(src).not.toContain('import { $ } from "bun"') + // Should use Bun.spawn + const fnStart = src.indexOf("async function git(") + const fnBody = src.slice(fnStart, fnStart + 600) + expect(fnBody).toContain("Bun.spawn") + }) + + test("git() helper drains pipe after truncation", () => { + const fnStart = src.indexOf("async function git(") + const fnBody = src.slice(fnStart, fnStart + 800) + // After setting truncated=true, the loop must continue reading (drain) + expect(fnBody).toContain("if (truncated) continue") + }) + + test("git() helper consumes stderr to prevent pipe blocking", () => { + const fnStart = src.indexOf("async function git(") + const fnBody = src.slice(fnStart, fnStart + 800) + expect(fnBody).toContain("proc.stderr") + }) + + test("readBefore uses MAX_FILE_STDOUT limit", () => { + const fnStart = src.indexOf("async function readBefore(") + expect(fnStart).toBeGreaterThan(-1) + const fnBody = src.slice(fnStart, fnStart + 300) + expect(fnBody).toContain("MAX_FILE_STDOUT") + }) + + test("no $ template git calls remain in the file", () => { + // All git commands should go through the git() helper now + // Match the Bun shell template pattern: $`git ...` + const templateCalls = src.match(/\$`git\s/g) + expect(templateCalls).toBeNull() + }) +}) diff --git a/packages/opencode/test/kilocode/review/worktree-diff-cache.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-cache.test.ts new file mode 100644 index 00000000000..d9bfac5aaf8 --- /dev/null +++ b/packages/opencode/test/kilocode/review/worktree-diff-cache.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for merge-base caching in worktree-diff.ts. + * + * Verifies that: + * - The cache exists and has a reasonable TTL + * - clearCache() is exported and functional + * - Different dir/base combinations use separate cache keys + */ + +import { describe, test, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const SRC = path.resolve(import.meta.dir, "../../../src/kilocode/review/worktree-diff.ts") + +describe("worktree-diff merge-base cache", () => { + const src = fs.readFileSync(SRC, "utf-8") + + test("ancestors cache is a Map with TTL tracking", () => { + expect(src).toContain("const ancestors = new Map") + expect(src).toContain("expires") + }) + + test("ANCESTOR_TTL is defined and >= 10 seconds", () => { + const match = src.match(/const ANCESTOR_TTL\s*=\s*([\d_]+)/) + expect(match).toBeTruthy() + const ttl = Number(match![1]!.replace(/_/g, "")) + expect(ttl).toBeGreaterThanOrEqual(10_000) + }) + + test("ancestor() checks cache before spawning git", () => { + const fnStart = src.indexOf("async function ancestor(") + expect(fnStart).toBeGreaterThan(-1) + const fnBody = src.slice(fnStart, fnStart + 600) + // Must check cache before calling git() + const cacheCheck = fnBody.indexOf("ancestors.get(") + const gitCall = fnBody.indexOf('git(["merge-base"') + expect(cacheCheck, "cache lookup must exist").toBeGreaterThan(-1) + expect(gitCall, "git call must exist").toBeGreaterThan(-1) + expect(cacheCheck, "cache lookup must come before git call").toBeLessThan(gitCall) + }) + + test("ancestor() stores result in cache after successful git call", () => { + const fnStart = src.indexOf("async function ancestor(") + const fnBody = src.slice(fnStart, fnStart + 600) + expect(fnBody).toContain("ancestors.set(") + expect(fnBody).toContain("ANCESTOR_TTL") + }) + + test("cache key uses dir and base to avoid collisions", () => { + const fnStart = src.indexOf("async function ancestor(") + const fnBody = src.slice(fnStart, fnStart + 600) + // Key should incorporate both dir and base + expect(fnBody).toMatch(/`\$\{dir\}.*\$\{base\}`/) + }) + + test("clearCache() is exported", () => { + expect(src).toContain("export function clearCache()") + // Must clear the ancestors map + const fnStart = src.indexOf("export function clearCache()") + const fnBody = src.slice(fnStart, fnStart + 100) + expect(fnBody).toContain("ancestors.clear()") + }) +}) diff --git a/packages/opencode/test/kilocode/review/worktree-diff-memory.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-memory.test.ts new file mode 100644 index 00000000000..21bbaffc96d --- /dev/null +++ b/packages/opencode/test/kilocode/review/worktree-diff-memory.test.ts @@ -0,0 +1,74 @@ +/** + * Memory regression test for WorktreeDiff.summary(). + * + * Runs summary() in a loop and asserts RSS doesn't grow beyond a threshold. + * This catches regressions where git output buffering changes could + * re-introduce unbounded native memory growth. + * + * Uses the kilocode repo itself as the test fixture — it always has diffs + * available against the default branch. + */ + +import { describe, test, expect, afterEach } from "bun:test" +import path from "node:path" +import { WorktreeDiff } from "@/kilocode/review/worktree-diff" + +const REPO = path.resolve(import.meta.dir, "../../../../..") +const ITERATIONS = 30 +// Generous margin: mimalloc retains 64 MB segments and this repo has a large +// diff surface. The test guards against catastrophic regressions (multi-GB +// leaks), not tight bounds. Pre-fix behavior was 6+ GB; post-fix should stay +// well under 1 GB even on large repos. +const MAX_GROWTH_MB = 512 + +describe("worktree-diff memory", () => { + afterEach(() => { + WorktreeDiff.clearCache() + }) + + test( + "summary() does not leak memory over repeated calls", + async () => { + // Resolve a base branch that exists in this repo + const base = await resolveBase() + if (!base) { + console.log("Skipping memory test: no suitable base branch found") + return + } + + // Force GC and take baseline + Bun.gc(true) + const baseline = process.memoryUsage().rss + + for (let i = 0; i < ITERATIONS; i++) { + await WorktreeDiff.summary({ dir: REPO, base }) + } + + // Force GC and measure + Bun.gc(true) + const after = process.memoryUsage().rss + const growth = (after - baseline) / 1024 / 1024 + + console.log( + `Memory: baseline=${(baseline / 1024 / 1024).toFixed(1)} MB, after=${(after / 1024 / 1024).toFixed(1)} MB, growth=${growth.toFixed(1)} MB`, + ) + + expect(growth).toBeLessThan(MAX_GROWTH_MB) + }, + { timeout: 120_000 }, + ) +}) + +/** Find a base branch that exists in the repo (main or master). */ +async function resolveBase(): Promise { + for (const branch of ["main", "master", "origin/main", "origin/master"]) { + const proc = Bun.spawnSync(["git", "rev-parse", "--verify", branch], { + cwd: REPO, + stdout: "pipe", + stderr: "pipe", + windowsHide: true, + }) + if (proc.exitCode === 0) return branch + } + return undefined +} From d404bb201f4134724a982746d41c037509bf057e Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Wed, 15 Apr 2026 22:33:12 -0400 Subject: [PATCH 02/35] fix: gate publish on smoke test --- .github/workflows/publish.yml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7d4046df932..977f9ba7820 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -254,16 +254,14 @@ jobs: # APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 # kilocode_change start - # Smoke test disabled as a release gate due to infrastructure issues. - # The job is skipped via `if: false` so it no longer blocks publishing. - # Re-enable by restoring the original `if:` condition and uncommenting - # `- smoke-test` in the publish job's `needs` list below. + # Run smoke tests against CLI assets uploaded to the draft GitHub release + # before publishing the release and package artifacts. smoke-test: - name: Smoke Test (pre-publish gate) [DISABLED] + name: Smoke Test (pre-publish gate) needs: - version - build-cli - if: false # was: github.repository == 'Kilo-Org/kilocode' + if: github.repository == 'Kilo-Org/kilocode' runs-on: ubuntu-24.04 steps: - name: Trigger kilo-bench smoke test @@ -276,7 +274,11 @@ jobs: gh api repos/Kilo-Org/kilo-bench/dispatches \ --method POST \ -f event_type=smoke-test \ + -f 'client_payload[cli_version]=${{ needs.version.outputs.version }}' \ -f 'client_payload[release_tag]=${{ needs.version.outputs.tag }}' \ + -f 'client_payload[release_id]=${{ needs.version.outputs.release }}' \ + -f 'client_payload[pre_release]=${{ inputs.pre_release }}' \ + -f 'client_payload[source_repo]=${{ github.repository }}' \ -f 'client_payload[source_run_id]=${{ github.run_id }}' # Poll for the run created after our dispatch timestamp. @@ -336,7 +338,7 @@ jobs: - version - build-cli - build-vscode - # - smoke-test # disabled: infrastructure issues (see smoke-test job comment) + - smoke-test # - build-tauri runs-on: ubuntu-24.04 steps: From 448cf2683f5d41c541c05f44c1acd92ffb21989c Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Thu, 16 Apr 2026 10:45:29 -0400 Subject: [PATCH 03/35] fix: use local smoke test in publish --- .github/workflows/publish.yml | 74 ++------------------------------ .github/workflows/smoke-test.yml | 8 +++- 2 files changed, 11 insertions(+), 71 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 977f9ba7820..1a419e32fb3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -262,76 +262,10 @@ jobs: - version - build-cli if: github.repository == 'Kilo-Org/kilocode' - runs-on: ubuntu-24.04 - steps: - - name: Trigger kilo-bench smoke test - id: trigger - env: - GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }} - run: | - BEFORE=$(date -u -d '60 seconds ago' +%Y-%m-%dT%H:%M:%SZ) - - gh api repos/Kilo-Org/kilo-bench/dispatches \ - --method POST \ - -f event_type=smoke-test \ - -f 'client_payload[cli_version]=${{ needs.version.outputs.version }}' \ - -f 'client_payload[release_tag]=${{ needs.version.outputs.tag }}' \ - -f 'client_payload[release_id]=${{ needs.version.outputs.release }}' \ - -f 'client_payload[pre_release]=${{ inputs.pre_release }}' \ - -f 'client_payload[source_repo]=${{ github.repository }}' \ - -f 'client_payload[source_run_id]=${{ github.run_id }}' - - # Poll for the run created after our dispatch timestamp. - # The dispatch API returns no run ID, so we query by created time - # and pick the oldest match to avoid grabbing an unrelated run. - echo "Waiting for smoke-test run to appear (dispatched after $BEFORE)..." - for attempt in $(seq 1 30); do - RUN_ID=$(gh api \ - "repos/Kilo-Org/kilo-bench/actions/workflows/smoke-test.yml/runs?event=repository_dispatch&created=>=$BEFORE" \ - --jq '.workflow_runs | sort_by(.created_at) | .[0].id // empty') - - if [[ -n "$RUN_ID" && "$RUN_ID" != "null" ]]; then - echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" - echo "::notice::Smoke test run: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID" - exit 0 - fi - - echo " attempt $attempt: run not yet registered, retrying in 10s..." - sleep 10 - done - - echo "::error::Smoke test run did not appear within 5 minutes after dispatch." - exit 1 - - - name: Wait for smoke test to complete - env: - GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }} - run: | - RUN_ID="${{ steps.trigger.outputs.run_id }}" - echo "Waiting for run $RUN_ID..." - - for i in $(seq 1 60); do - CONCLUSION=$(gh run view "$RUN_ID" \ - --repo Kilo-Org/kilo-bench \ - --json conclusion \ - --jq '.conclusion') - - echo " attempt $i: $CONCLUSION" - - if [[ "$CONCLUSION" == "success" ]]; then - echo "::notice::Smoke test passed." - exit 0 - elif [[ "$CONCLUSION" != "null" && "$CONCLUSION" != "" ]]; then - echo "::error::Smoke test failed with conclusion: $CONCLUSION" - echo "See: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID" - exit 1 - fi - - sleep 30 - done - - echo "::error::Smoke test did not complete within 30 minutes." - exit 1 + uses: ./.github/workflows/smoke-test.yml + with: + cli_version: ${{ needs.version.outputs.version }} + secrets: inherit # kilocode_change end publish: needs: diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index e115234ac3e..bd18344b3d2 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -7,7 +7,7 @@ # # Triggers: # - workflow_dispatch: manually from Actions tab (optionally pass a CLI version) -# - push to main: automatically after every merge +# - workflow_call: from publish.yml after draft release assets are uploaded # # Required secrets: # KILO_API_KEY — Kilo Gateway key @@ -23,6 +23,12 @@ on: description: "CLI version to test (e.g. 7.0.36). Leave blank for latest npm release." required: false type: string + workflow_call: + inputs: + cli_version: + description: "CLI version to test from draft release assets." + required: false + type: string concurrency: group: smoke-test From 959a8b498de6efd28756683162296dd40eb9b454 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 19:35:17 +0000 Subject: [PATCH 04/35] fix(cli): move queued user prompts to end of history When a user queued a prompt while the previous turn was still streaming, the message's time_created fell before later assistant steps of that turn. Ordering by time_created alone left the queued prompt in the middle of the prior turn's history, so the next request ended with an assistant message and tripped Anthropic's prefill rejection. Reorder inside KiloSessionPromptQueue.scope so the target user message and any of its own turn's assistants are always placed at the end. --- .changeset/fix-queued-prompt-reorder.md | 6 ++ .../src/kilocode/session/prompt-queue.ts | 20 +++++- .../kilocode/session-prompt-queue.test.ts | 67 +++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-queued-prompt-reorder.md diff --git a/.changeset/fix-queued-prompt-reorder.md b/.changeset/fix-queued-prompt-reorder.md new file mode 100644 index 00000000000..79a373300fe --- /dev/null +++ b/.changeset/fix-queued-prompt-reorder.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt. diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index 54adcb6c407..ab08c24346d 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -39,7 +39,25 @@ export namespace KiloSessionPromptQueue { if (item.info.role === "assistant") return !hidden.has(item.info.parentID) return true }) - return visible + + // When a user prompt is queued mid-turn, its time_created falls in the + // middle of the prior turn's messages (a later assistant step in that turn + // was written after the queue event). Ordering by time_created alone puts + // the queued prompt before the prior turn's final assistant reply, which + // makes the next request end with an assistant message and trips Anthropic's + // prefill rejection. Move the target user message and any of its own turn's + // assistant messages to the end so the request always ends with the queued + // user prompt (or with its own turn's latest assistant step). + const owns = (item: MessageV2.WithParts) => { + if (item.info.role === "user") return item.info.id === target + if (item.info.role === "assistant") return item.info.parentID === target + return false + } + const before: MessageV2.WithParts[] = [] + const after: MessageV2.WithParts[] = [] + for (const item of visible) (owns(item) ? after : before).push(item) + if (after.length === 0) return visible + return [...before, ...after] } export function enqueue( diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 39969b645dc..5d5e93ac1d0 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -121,6 +121,73 @@ describe("session prompt queue", () => { expect(ids).toEqual([one, ans, two]) }) + test("moves queued target to the end when prior-turn messages come after it", async () => { + // Regression: when a user queues a prompt while a turn is still running, + // the queued message's time_created falls before later assistant steps of + // that turn. Ordering by time_created alone would leave the queued prompt + // in the middle of the prior turn's messages, ending the next model request + // with an assistant message and tripping Anthropic's prefill rejection. + const sessionID = SessionID.make("session_queue_mid_turn") + const m1 = MessageID.make("message_10") + const a1 = MessageID.make("message_20") + const m2 = MessageID.make("message_30") + const a2step1 = MessageID.make("message_40") + const m3 = MessageID.make("message_50") // queued mid-turn + const a2step2 = MessageID.make("message_60") + const a2final = MessageID.make("message_70") + const messages = [ + user(sessionID, m1), + assistant(sessionID, a1, m1), + user(sessionID, m2), + assistant(sessionID, a2step1, m2), + user(sessionID, m3), + assistant(sessionID, a2step2, m2), + assistant(sessionID, a2final, m2), + ] + + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + m3, + Effect.sync(() => KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + + expect(ids).toEqual([m1, a1, m2, a2step1, a2step2, a2final, m3]) + expect(ids[ids.length - 1]).toBe(m3) + }) + + test("keeps the target turn's own assistant steps grouped at the end", async () => { + // After the first step of a queued turn has produced an assistant message, + // subsequent scope() calls should keep the target user together with its + // own turn's assistants (not interleaved with a prior turn's tail). + const sessionID = SessionID.make("session_queue_step_two") + const m1 = MessageID.make("message_01a") + const a1 = MessageID.make("message_02a") + const m2 = MessageID.make("message_03a") // queued mid-turn + const a1tail = MessageID.make("message_04a") + const a2step1 = MessageID.make("message_05a") + const messages = [ + user(sessionID, m1), + assistant(sessionID, a1, m1), + user(sessionID, m2), + assistant(sessionID, a1tail, m1), // prior turn's tail was written after m2 + assistant(sessionID, a2step1, m2), + ] + + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + m2, + Effect.sync(() => KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + + expect(ids).toEqual([m1, a1, a1tail, m2, a2step1]) + }) + test("continues a queued prompt after the active run finishes", async () => { const ready = Promise.withResolvers() const release = Promise.withResolvers() From 7682f1a2823f845715906047c3e3b401e4e8b01c Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 11:14:09 +0300 Subject: [PATCH 05/35] fix(review): surface truncated git output to callers --- .../src/kilocode/review/worktree-diff.ts | 27 ++++++++++++------- .../review/worktree-diff-buffer.test.ts | 2 +- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts index 5756ed85fc2..b0ac372b595 100644 --- a/packages/opencode/src/kilocode/review/worktree-diff.ts +++ b/packages/opencode/src/kilocode/review/worktree-diff.ts @@ -18,7 +18,7 @@ async function git( args: string[], cwd: string, limit = MAX_STDOUT, -): Promise<{ ok: boolean; stdout: string; stderr: string }> { +): Promise<{ ok: boolean; stdout: string; stderr: string; truncated: boolean }> { const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", @@ -44,9 +44,10 @@ async function git( // Consume stderr to prevent blocking the child process pipe const stderr = await new Response(proc.stderr).text() return { - ok: code === 0, + ok: code === 0 && !truncated, stdout: Buffer.concat(chunks).toString(), stderr, + truncated, } } @@ -110,9 +111,10 @@ export namespace WorktreeDiff { return hash } - async function stats(dir: string, ancestor: string) { + async function stats(dir: string, ancestor: string, log: Log.Logger) { const result = await git(["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", ancestor], dir) const map = new Map() + if (result.truncated) log.warn("git diff --numstat output truncated, counts unavailable", { dir }) if (!result.ok) return map for (const line of result.stdout.trim().split("\n")) { @@ -133,11 +135,14 @@ export namespace WorktreeDiff { async function list(dir: string, ancestor: string, log: Log.Logger): Promise { const nameStatus = await git(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", ancestor], dir) + if (nameStatus.truncated) { + log.warn("git diff --name-status output truncated, diff omitted", { dir }) + } if (!nameStatus.ok) return [] const result: Meta[] = [] const seen = new Set() - const stat = await stats(dir, ancestor) + const stat = await stats(dir, ancestor, log) for (const line of nameStatus.stdout.trim().split("\n")) { if (!line) continue @@ -161,6 +166,9 @@ export namespace WorktreeDiff { } const untracked = await git(["ls-files", "--others", "--exclude-standard"], dir) + if (untracked.truncated) { + log.warn("git ls-files output truncated, untracked list incomplete", { dir }) + } if (!untracked.ok) { log.warn("git ls-files failed", { stderr: untracked.stderr.trim() }) return result @@ -277,9 +285,10 @@ export namespace WorktreeDiff { return `${stat.size}:${stat.mtimeMs}` } - async function readBefore(dir: string, ancestor: string, file: string, status: Status) { + async function readBefore(dir: string, ancestor: string, file: string, status: Status, log: Log.Logger) { if (status === "added") return "" const result = await git(["show", `${ancestor}:${file}`], dir, MAX_FILE_STDOUT) + if (result.truncated) log.warn("git show output truncated, before content omitted", { file }) return result.ok ? result.stdout : "" } @@ -289,8 +298,8 @@ export namespace WorktreeDiff { return (await result.exists()) ? await result.text() : "" } - async function load(dir: string, ancestor: string, meta: Meta): Promise { - const before = await readBefore(dir, ancestor, meta.file, meta.status) + async function load(dir: string, ancestor: string, meta: Meta, log: Log.Logger): Promise { + const before = await readBefore(dir, ancestor, meta.file, meta.status, log) const after = await readAfter(dir, meta.file, meta.status) const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? lines(after) : meta.additions return { @@ -341,7 +350,7 @@ export namespace WorktreeDiff { if (!ancestorHash) return undefined const item = await detailMeta(input.dir, ancestorHash, input.file) if (!item) return undefined - return await load(input.dir, ancestorHash, item) + return await load(input.dir, ancestorHash, item, log) } export async function full(input: { dir: string; base: string; log?: Log.Logger }) { @@ -351,7 +360,7 @@ export namespace WorktreeDiff { if (!ancestorHash) return [] log.info("merge-base resolved", { ancestor: ancestorHash.slice(0, 12) }) const items = await list(input.dir, ancestorHash, log) - const result = await Promise.all(items.map((item) => load(input.dir, ancestorHash, item))) + const result = await Promise.all(items.map((item) => load(input.dir, ancestorHash, item, log))) log.info("diff complete", { totalFiles: result.length }) return result } diff --git a/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts index 18c67798965..f3adbcedef4 100644 --- a/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts +++ b/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts @@ -56,7 +56,7 @@ describe("worktree-diff buffer caps", () => { test("git() helper consumes stderr to prevent pipe blocking", () => { const fnStart = src.indexOf("async function git(") - const fnBody = src.slice(fnStart, fnStart + 800) + const fnBody = src.slice(fnStart, fnStart + 1000) expect(fnBody).toContain("proc.stderr") }) From 18d6868775e61e1ca234d33b3a5e0fa7d730f20a Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 11:14:33 +0300 Subject: [PATCH 06/35] fix(review): drain stderr concurrently to avoid hang --- packages/opencode/src/kilocode/review/worktree-diff.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts index b0ac372b595..ac9934aa09f 100644 --- a/packages/opencode/src/kilocode/review/worktree-diff.ts +++ b/packages/opencode/src/kilocode/review/worktree-diff.ts @@ -25,6 +25,9 @@ async function git( stderr: "pipe", windowsHide: true, }) + // Kick off stderr drain immediately so a full stderr pipe can't block the child. + // Both stdout and stderr must be drained concurrently to avoid deadlock. + const stderrPromise = new Response(proc.stderr).text() const chunks: Buffer[] = [] let size = 0 let truncated = false @@ -40,9 +43,8 @@ async function git( } chunks.push(Buffer.from(value)) } + const stderr = await stderrPromise const code = await proc.exited - // Consume stderr to prevent blocking the child process pipe - const stderr = await new Response(proc.stderr).text() return { ok: code === 0 && !truncated, stdout: Buffer.concat(chunks).toString(), From 0965571ab8e79b59d2697a166cf419694a8c3c09 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 12:15:07 +0300 Subject: [PATCH 07/35] fix(cli,vscode): evict idle worktree instances and stream git output Idle Agent Manager worktrees pinned LSP, file watchers, snapshot handles, and PubSub queues for the session lifetime, and every git subprocess copied stdout through an intermediate Buffer array and final Buffer.concat. The first pins kilo serve state on the cached instance side; the second inflates native allocator high-water so freed memory is never returned. Dispose idle instances after 10 min of no requests (the sweeper skips any instance with in-flight work), and collect git stdout chunks by reference with a single-allocation decode at the end. --- .../src/kilocode/review/worktree-diff.ts | 37 ++++++- .../opencode/src/kilocode/server/server.ts | 37 +++++++ packages/opencode/src/project/instance.ts | 66 +++++++++++- packages/opencode/src/server/server.ts | 2 + .../kilocode/project/instance-evict.test.ts | 101 ++++++++++++++++++ .../review/worktree-diff-buffer.test.ts | 4 +- .../review/worktree-diff-stream.test.ts | 57 ++++++++++ 7 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 packages/opencode/test/kilocode/project/instance-evict.test.ts create mode 100644 packages/opencode/test/kilocode/review/worktree-diff-stream.test.ts diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts index ac9934aa09f..47037b59598 100644 --- a/packages/opencode/src/kilocode/review/worktree-diff.ts +++ b/packages/opencode/src/kilocode/review/worktree-diff.ts @@ -14,6 +14,10 @@ import { Log } from "@/util/log" const MAX_STDOUT = 10 * 1024 * 1024 // 10 MB general cap const MAX_FILE_STDOUT = 1 * 1024 * 1024 // 1 MB per-file cap (readBefore) +// Shared decoder — one instance per module avoids re-allocating the ICU state +// on every git call and keeps the native allocator footprint small. +const decoder = new TextDecoder() + async function git( args: string[], cwd: string, @@ -28,7 +32,12 @@ async function git( // Kick off stderr drain immediately so a full stderr pipe can't block the child. // Both stdout and stderr must be drained concurrently to avoid deadlock. const stderrPromise = new Response(proc.stderr).text() - const chunks: Buffer[] = [] + + // Collect chunks by reference (the stream hands us freshly-allocated + // Uint8Arrays — no Buffer.from copy needed). A single join at the end + // keeps the allocator high-water to exactly one final buffer per call, + // which is what mimalloc actually retains in its arenas. + const chunks: Uint8Array[] = [] let size = 0 let truncated = false const reader = proc.stdout.getReader() @@ -36,23 +45,41 @@ async function git( const { done, value } = await reader.read() if (done) break if (truncated) continue // drain pipe but don't store - size += value.length - if (size > limit) { + const space = limit - size + if (value.length >= space) { + if (space > 0) chunks.push(value.subarray(0, space)) + size = limit truncated = true continue } - chunks.push(Buffer.from(value)) + chunks.push(value) + size += value.length } const stderr = await stderrPromise const code = await proc.exited return { ok: code === 0 && !truncated, - stdout: Buffer.concat(chunks).toString(), + stdout: join(chunks, size), stderr, truncated, } } +// Single-allocation decode: fast path for the common case of one chunk, +// otherwise one Uint8Array the exact size of the final output plus the +// decoded string. Avoids the extra copy of chunk-array concatenation. +function join(chunks: Uint8Array[], size: number): string { + if (size === 0) return "" + if (chunks.length === 1) return decoder.decode(chunks[0]) + const buf = new Uint8Array(size) + let pos = 0 + for (const c of chunks) { + buf.set(c, pos) + pos += c.length + } + return decoder.decode(buf) +} + // --------------------------------------------------------------------------- // Merge-base cache — avoids redundant git spawns across polling cycles // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/kilocode/server/server.ts b/packages/opencode/src/kilocode/server/server.ts index 487892585e0..acc19ca827e 100644 --- a/packages/opencode/src/kilocode/server/server.ts +++ b/packages/opencode/src/kilocode/server/server.ts @@ -4,6 +4,7 @@ import { ModelCache } from "../../provider/model-cache" import { Instance } from "../../project/instance" +import { Log } from "../../util/log" /** Extra paths to skip request logging for */ export function skipLogging(path: string): boolean { @@ -26,3 +27,39 @@ export async function authChanged(providerID: string) { export const DOC_TITLE = "kilo" export const DOC_DESCRIPTION = "kilo api" + +// --------------------------------------------------------------------------- +// Idle instance eviction +// --------------------------------------------------------------------------- +// VS Code Agent Manager leaves one Instance alive per worktree for the whole +// session. Each Instance holds file watchers, LSP state, snapshot gitdir +// handles, DB connections, and PubSub queues. Without eviction these +// accumulate until the user closes VS Code — which is the main source of +// the multi-GB "kilo serve" RSS growth observed on Windows. +// +// The sweeper disposes any instance that hasn't served a request for +// IDLE_MS and has no in-flight work. The next request for that directory +// re-bootstraps from fresh state. + +const log = Log.create({ service: "instance-evictor" }) +const IDLE_MS = 10 * 60 * 1000 // 10 minutes +const SWEEP_MS = 60 * 1000 // check every minute + +const evictor = { timer: undefined as ReturnType | undefined } + +export function startIdleEviction() { + if (evictor.timer) return + evictor.timer = setInterval(() => { + Instance.evictIdle(IDLE_MS).catch((err) => { + log.error("evictIdle failed", { error: err instanceof Error ? err.message : String(err) }) + }) + }, SWEEP_MS) + evictor.timer.unref?.() + log.info("idle eviction started", { idleMs: IDLE_MS, sweepMs: SWEEP_MS }) +} + +export function stopIdleEviction() { + if (!evictor.timer) return + clearInterval(evictor.timer) + evictor.timer = undefined +} diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index a0d6f2414a8..f86c5edcd05 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -16,6 +16,15 @@ export interface InstanceContext { const context = Context.create("instance") const cache = new Map>() +// kilocode_change start - idle eviction tracking +// Tracks last-use time and active request count per cached instance so +// the idle eviction sweeper can dispose instances that have been quiet +// without killing one that still has an in-flight request (e.g. a +// running session). Both maps are keyed by the resolved directory. +const lastUsed = new Map() +const inflight = new Map() +// kilocode_change end + const disposal = { all: undefined as Promise | undefined, } @@ -76,10 +85,20 @@ export const Instance = { }), ) } - const ctx = await existing - return context.provide(ctx, async () => { - return input.fn() - }) + // kilocode_change start - track in-flight requests and last-use time + // so evictIdle() can dispose idle instances without racing live work. + inflight.set(directory, (inflight.get(directory) ?? 0) + 1) + lastUsed.set(directory, Date.now()) + try { + const ctx = await existing + return await context.provide(ctx, async () => { + return input.fn() + }) + } finally { + inflight.set(directory, Math.max(0, (inflight.get(directory) ?? 1) - 1)) + lastUsed.set(directory, Date.now()) + } + // kilocode_change end }, get current() { return context.use() @@ -130,6 +149,8 @@ export const Instance = { Log.Default.info("reloading instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) + lastUsed.delete(directory) // kilocode_change + inflight.delete(directory) // kilocode_change const next = track(directory, boot({ ...input, directory })) emit(directory) return await next @@ -139,6 +160,8 @@ export const Instance = { Log.Default.info("disposing instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) + lastUsed.delete(directory) // kilocode_change + inflight.delete(directory) // kilocode_change emit(directory) }, async disposeAll() { @@ -172,4 +195,39 @@ export const Instance = { return disposal.all }, + // kilocode_change start - idle eviction + /** + * Dispose instances that haven't been used for `idleMs` and have no + * in-flight requests. Releases file watchers, LSP, snapshot state, + * DB handles, and PubSub queues so the native allocator can actually + * return pages to the OS. The next request for that directory will + * re-bootstrap from scratch. + */ + async evictIdle(idleMs: number) { + const cutoff = Date.now() - idleMs + const stale: Array<[string, Promise]> = [] + for (const [dir, used] of lastUsed) { + if (used >= cutoff) continue + if ((inflight.get(dir) ?? 0) > 0) continue + const entry = cache.get(dir) + if (entry) stale.push([dir, entry]) + } + for (const [dir, entry] of stale) { + if (cache.get(dir) !== entry) continue + if ((inflight.get(dir) ?? 0) > 0) continue + const ctx = await entry.catch(() => undefined) + if (!ctx) { + if (cache.get(dir) === entry) cache.delete(dir) + lastUsed.delete(dir) + inflight.delete(dir) + continue + } + Log.Default.info("evicting idle instance", { directory: dir, idleMs }) + await context.provide(ctx, async () => { + await Instance.dispose() + }) + } + return stale.length + }, + // kilocode_change end } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 383294419de..746ad7dfe5a 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -312,6 +312,7 @@ export namespace Server { }) const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) + KiloServer.startIdleEviction() // kilocode_change - release idle worktree instances const addr = server.address() if (!addr || typeof addr === "string") { throw new Error(`Failed to resolve server address for port ${opts.port}`) @@ -341,6 +342,7 @@ export namespace Server { url: next, stop(close?: boolean) { closing ??= new Promise((resolve, reject) => { + KiloServer.stopIdleEviction() // kilocode_change if (mdns) MDNS.unpublish() server.close((err) => { if (err) { diff --git a/packages/opencode/test/kilocode/project/instance-evict.test.ts b/packages/opencode/test/kilocode/project/instance-evict.test.ts new file mode 100644 index 00000000000..db47ff527d3 --- /dev/null +++ b/packages/opencode/test/kilocode/project/instance-evict.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for Instance.evictIdle() — the idle eviction sweeper. + * + * PR #9046 disposed Instance contexts only when a worktree was deleted, + * but long-lived Agent Manager sessions don't delete their worktrees. + * Every active worktree's Instance held file watchers, LSP state, + * snapshot handles, and PubSub queues forever — the main source of + * native RSS growth observed in the kilo.DMP memory report. + * + * This test exercises the eviction path with short idle thresholds so + * the cache can be verified to release entries and re-bootstrap on the + * next request. + */ + +import { describe, test, expect } from "bun:test" +import { Instance } from "../../../src/project/instance" +import { tmpdir } from "../../fixture/fixture" + +describe("Instance.evictIdle", () => { + test("disposes instances older than the cutoff and preserves fresh ones", async () => { + await using a = await tmpdir({ git: true }) + await using b = await tmpdir({ git: true }) + + const inits: string[] = [] + const bootA = () => { + inits.push("a") + return Promise.resolve() + } + const bootB = () => { + inits.push("b") + return Promise.resolve() + } + + await Instance.provide({ directory: a.path, init: bootA, fn: async () => undefined }) + await Instance.provide({ directory: b.path, init: bootB, fn: async () => undefined }) + expect(inits).toEqual(["a", "b"]) + + // Let a become idle while b is kept warm with a second touch. + await new Promise((r) => setTimeout(r, 50)) + await Instance.provide({ directory: b.path, init: bootB, fn: async () => undefined }) + + // Threshold sits between the two lastUsed timestamps so only a is evicted. + const evicted = await Instance.evictIdle(30) + expect(evicted).toBeGreaterThanOrEqual(1) + + // a is gone — next provide re-runs init. b is cached — init does not run. + await Instance.provide({ directory: a.path, init: bootA, fn: async () => undefined }) + await Instance.provide({ directory: b.path, init: bootB, fn: async () => undefined }) + expect(inits.filter((x) => x === "a")).toHaveLength(2) + expect(inits.filter((x) => x === "b")).toHaveLength(1) + + await Instance.disposeAll() + }, 30_000) + + test("skips instances with in-flight requests regardless of age", async () => { + await using t = await tmpdir({ git: true }) + + // Hold provide() open so the in-flight counter stays > 0. + const release = Promise.withResolvers() + const running = Instance.provide({ + directory: t.path, + fn: async () => { + await release.promise + }, + }) + + // Give the request a moment to register then evict with a zero cutoff + // which would evict every cache entry if in-flight were ignored. + await new Promise((r) => setTimeout(r, 50)) + await Instance.evictIdle(0) + + release.resolve() + await running + + // The cache entry survived — provide() with no init callback returns + // without re-bootstrapping. We verify by measuring that a brand new + // init callback is NOT invoked. + const inits: string[] = [] + await Instance.provide({ + directory: t.path, + init: () => { + inits.push("x") + return Promise.resolve() + }, + fn: async () => undefined, + }) + expect(inits).toHaveLength(0) + + await Instance.disposeAll() + }, 30_000) + + test("returns 0 when nothing is idle", async () => { + await using t = await tmpdir({ git: true }) + await Instance.provide({ directory: t.path, fn: async () => undefined }) + + const evicted = await Instance.evictIdle(60_000) + expect(evicted).toBe(0) + + await Instance.disposeAll() + }, 30_000) +}) diff --git a/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts index f3adbcedef4..5badec5340f 100644 --- a/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts +++ b/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts @@ -49,14 +49,14 @@ describe("worktree-diff buffer caps", () => { test("git() helper drains pipe after truncation", () => { const fnStart = src.indexOf("async function git(") - const fnBody = src.slice(fnStart, fnStart + 800) + const fnBody = src.slice(fnStart, fnStart + 1600) // After setting truncated=true, the loop must continue reading (drain) expect(fnBody).toContain("if (truncated) continue") }) test("git() helper consumes stderr to prevent pipe blocking", () => { const fnStart = src.indexOf("async function git(") - const fnBody = src.slice(fnStart, fnStart + 1000) + const fnBody = src.slice(fnStart, fnStart + 1600) expect(fnBody).toContain("proc.stderr") }) diff --git a/packages/opencode/test/kilocode/review/worktree-diff-stream.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-stream.test.ts new file mode 100644 index 00000000000..c103645fdf1 --- /dev/null +++ b/packages/opencode/test/kilocode/review/worktree-diff-stream.test.ts @@ -0,0 +1,57 @@ +/** + * Tests for the single-allocation git() output helper. + * + * Where the old helper copied every pipe chunk into a Buffer and then + * concat+toString'd them, the new helper references the chunks directly + * and collapses to one Uint8Array + one decode at the end. This catches + * regressions that would re-introduce the per-chunk Buffer.from/concat + * pattern (the actual source of mimalloc arena retention in the PR #9046 + * memory report). + */ + +import { describe, test, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const SRC = path.resolve(import.meta.dir, "../../../src/kilocode/review/worktree-diff.ts") + +describe("worktree-diff stream helper", () => { + const src = fs.readFileSync(SRC, "utf-8") + + test("no per-chunk Buffer.from copy", () => { + // The old helper did `chunks.push(Buffer.from(value))` which allocates + // once per chunk. Each small allocation is what mimalloc retains + // forever in its arenas. + expect(src).not.toContain("Buffer.from(value)") + }) + + test("no Buffer.concat in git helper", () => { + const start = src.indexOf("async function git(") + expect(start).toBeGreaterThan(-1) + const end = src.indexOf("\nfunction ", start) + const body = src.slice(start, end > 0 ? end : start + 2000) + expect(body).not.toContain("Buffer.concat") + }) + + test("chunks are Uint8Array, not Buffer", () => { + const start = src.indexOf("async function git(") + const body = src.slice(start, start + 1200) + expect(body).toContain("Uint8Array[]") + expect(body).not.toContain("chunks: Buffer[]") + }) + + test("decoder is reused across calls", () => { + expect(src).toContain("const decoder = new TextDecoder()") + // The only decode() call should go through the shared instance. + const inlineDecoder = src.match(/new TextDecoder\(\)\.decode/g) + expect(inlineDecoder ?? []).toHaveLength(0) + }) + + test("bounded fast path when output fits in one chunk", () => { + const joinStart = src.indexOf("function join(") + expect(joinStart).toBeGreaterThan(-1) + const body = src.slice(joinStart, joinStart + 400) + // Single-chunk fast path avoids even the intermediate buffer. + expect(body).toContain("chunks.length === 1") + }) +}) From 343455b87895a0551760b5710b1ffe58fae21efd Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 12:47:35 +0300 Subject: [PATCH 08/35] fix(cli): preserve per-agent model overrides (#9050) Gate the agent-change auto-apply effect on model.json load and skip it when a per-agent pick already exists, so user-selected models for agents with a configured default no longer revert on agent switches or restarts. --- .changeset/fix-per-agent-model-override.md | 6 + .../src/cli/cmd/tui/context/local.tsx | 36 ++++-- .../test/kilocode/local-model.test.ts | 120 ++++++++++++++++++ 3 files changed, 149 insertions(+), 13 deletions(-) create mode 100644 .changeset/fix-per-agent-model-override.md diff --git a/.changeset/fix-per-agent-model-override.md b/.changeset/fix-per-agent-model-override.md new file mode 100644 index 00000000000..a58ec1106fd --- /dev/null +++ b/.changeset/fix-per-agent-model-override.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`). diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 895aad8d28e..c24f167c057 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -216,6 +216,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ get ready() { return modelStore.ready }, + // kilocode_change start - expose saved per-agent pick for auto-apply guard + saved(name: string) { + return modelStore.model[name] + }, + // kilocode_change end recent() { return modelStore.recent }, @@ -409,21 +414,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Automatically update model when agent changes createEffect(() => { + // kilocode_change start - wait for persistence load; don't overwrite saved per-agent picks (#9050) + if (!model.ready) return + // kilocode_change end const value = agent.current() if (!value) return // kilocode_change - guard against empty agent list during org switch - if (value.model) { - if (isModelValid(value.model)) - model.set({ - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - else - toast.show({ - variant: "warning", - message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, - duration: 3000, - }) - } + // kilocode_change start - skip when the user (or a previous session) already picked a model + if (!value.model) return + if (model.saved(value.name)) return + // kilocode_change end + if (isModelValid(value.model)) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + else + toast.show({ + variant: "warning", + message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, + duration: 3000, + }) }) const result = { diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts index 1920f1b7070..43f61938606 100644 --- a/packages/opencode/test/kilocode/local-model.test.ts +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -473,3 +473,123 @@ describe("edge cases and error handling", () => { } }) }) + +// ── Regression tests for #9050 ────────────────────────────────────────────── +// The auto-apply createEffect in local.tsx previously clobbered user-selected +// per-agent models whenever it re-fired. The fix gates it on (a) modelStore.ready +// and (b) the absence of an existing saved entry for that agent. + +describe("#9050: auto-apply effect respects saved per-agent selection", () => { + test("13: fresh start — config model for active agent is applied after ready", async () => { + // plan is second; code (first) has no config model. Switch to plan post-init. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal() + try { + // Effect should not touch the code agent (no config model). + expect(local.model.saved("code")).toBeUndefined() + + local.agent.set("plan") + // Give the effect time to re-run now that agent.current() changed. + await Bun.sleep(50) + + // First-time application: no saved entry → config model applied and persisted. + expect(local.model.saved("plan")).toEqual(OPUS) + const data = await readModelJson() + expect(data.model.plan).toEqual(OPUS) + } finally { + dispose() + } + }) + + test("14: saved entry from model.json is preserved over a differing config model", async () => { + // Config says plan → OPUS; saved file says plan → SONNET. Saved must win. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal({ + prewrite: { + recent: [SONNET], + model: { plan: SONNET }, + favorite: [], + variant: {}, + }, + }) + try { + local.agent.set("plan") + await Bun.sleep(50) + + // The fix: effect sees an existing saved entry and leaves it alone. + expect(local.model.saved("plan")).toEqual(SONNET) + const data = await readModelJson() + expect(data.model.plan).toEqual(SONNET) + } finally { + dispose() + } + }) + + test("15: user override of a config-model agent sticks across agent switches", async () => { + // plan has config model OPUS; user picks SONNET for plan; switching away + // and back must not revert to OPUS. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal() + try { + local.agent.set("plan") + await Bun.sleep(50) + // Effect applied config default (no saved entry yet). + expect(local.model.saved("plan")).toEqual(OPUS) + + // User picks a different model. + local.model.set(SONNET, { recent: true }) + await Bun.sleep(50) + expect(local.model.saved("plan")).toEqual(SONNET) + + // Bounce agents. + local.agent.set("code") + await Bun.sleep(50) + local.agent.set("plan") + await Bun.sleep(50) + + // Saved pick survives. + expect(local.model.saved("plan")).toEqual(SONNET) + const data = await readModelJson() + expect(data.model.plan).toEqual(SONNET) + } finally { + dispose() + } + }) + + test("16: invalid config model still emits a warning toast", async () => { + // Ensure the fix didn't silence the existing invalid-model warning path. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { + name: "plan", + mode: "primary", + hidden: false, + model: { providerID: "nonexistent", modelID: "fake-model" }, + color: undefined, + permission: {}, + }, + ] + const { local, dispose } = await initLocal() + try { + toastMessages = [] + local.agent.set("plan") + await Bun.sleep(50) + + const warnings = toastMessages.filter((t) => t.variant === "warning" && t.message.includes("not valid")) + expect(warnings.length).toBeGreaterThan(0) + // And no bogus value was written. + expect(local.model.saved("plan")).toBeUndefined() + } finally { + dispose() + } + }) +}) From 4ef6bbff5093dc68a607f1f268e6ab662781922e Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 12:21:47 +0200 Subject: [PATCH 09/35] feat(agent-manager): enable /sessions command to browse and resume session history (#8976) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(agent-manager): enable /sessions command to browse and resume session history * fix(agent-manager): prevent duplicate tabs when selecting already-open session from history * fix(agent-manager): preserve tab memory when reopening local history sessions * docs(vscode): add agent manager sessions changeset * fix(cli): eliminate mock.module race in commit-message tests git-context.test.ts and generate.test.ts both called mock.module on the same path. Bun mock.module is process-wide and permanent, so whichever file loaded second saw the other's mock. Replace with injectable test seams (setGitRunnerForTest, setGitContextForTest) that are cleaned up in afterEach. * test(cli): retry model json reads during async persistence * fix(agent-manager): hide review tab when picking session from history Also revert the commit-message test seam refactor — upstream rewrote git-context.test.ts to use real git repos and the test runner isolates each file in its own process, so the mock.module race no longer exists. * docs(vscode): bump agent manager sessions changeset to minor --- .changeset/agent-manager-sessions-history.md | 5 ++ .../agent-manager/AgentManagerApp.tsx | 90 +++++++++---------- .../src/components/chat/PromptInput.tsx | 3 +- .../test/kilocode/local-model.test.ts | 12 ++- 4 files changed, 61 insertions(+), 49 deletions(-) create mode 100644 .changeset/agent-manager-sessions-history.md diff --git a/.changeset/agent-manager-sessions-history.md b/.changeset/agent-manager-sessions-history.md new file mode 100644 index 00000000000..2df85d93802 --- /dev/null +++ b/.changeset/agent-manager-sessions-history.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support browsing and resuming sessions from Agent Manager with `/sessions`. diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index da51d775c96..53f8d494d83 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -78,6 +78,7 @@ import { NotificationsProvider } from "../src/context/notifications" import { SessionProvider, useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" import { ChatView } from "../src/components/chat" +import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" import { LanguageBridge, DataBridge } from "../src/App" import { useLanguage } from "../src/context/language" @@ -348,6 +349,7 @@ const AgentManagerContent: Component = () => { let diffRaf: number | undefined let pendingDiffWidth: number | undefined + const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === "diff" const [diffDatas, setDiffDatas] = createSignal>({}) @@ -697,28 +699,23 @@ const AgentManagerContent: Component = () => { const valid = prev.filter((lid) => isPending(lid) || validateLocalSession(lid, ids)) if (valid.length !== prev.length) { const removed = prev.filter((lid) => !isPending(lid) && !valid.includes(lid)) - for (const id of removed) { - vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) - } + for (const id of removed) vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) setLocalSessionIDs(valid) } }) // Drop in-memory review state for worktrees that no longer exist. createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) - setReviewOpenByContext((prev) => { const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id))) if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) - setReviewCommentsByContext((prev) => { const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id))) if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) - setApplyStates((prev) => { const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))) if (Object.keys(next).length === Object.keys(prev).length) return prev @@ -1000,12 +997,8 @@ const AgentManagerContent: Component = () => { if (fallback && !isPending(fallback.id)) { setActivePendingId(undefined) session.selectSession(fallback.id) - } else if (fallback && isPending(fallback.id)) { - setActivePendingId(fallback.id) - session.clearCurrentSession() - vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" }) } else { - setActivePendingId(undefined) + setActivePendingId(fallback && isPending(fallback.id) ? fallback.id : undefined) session.clearCurrentSession() vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" }) } @@ -1021,11 +1014,8 @@ const AgentManagerContent: Component = () => { const remembered = tabMemory()[worktreeId] const target = remembered ? sessions.find((s) => s.id === remembered) : undefined const fallback = target ?? sessions[0] - if (fallback) { - session.selectSession(fallback.id) - } else { - session.setCurrentSessionID(undefined) - } + if (fallback) session.selectSession(fallback.id) + else session.setCurrentSessionID(undefined) setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true) } @@ -1048,7 +1038,8 @@ const AgentManagerContent: Component = () => { onMount(() => { const handler = (event: MessageEvent) => { - const msg = event.data as ExtensionMessage + const msg = event.data + if (msg?.type === "navigate" && msg.view === "history") return setHistory(true) if (msg?.type !== "action") return if (msg.action === "sessionPrevious") navigate("up") else if (msg.action === "sessionNext") navigate("down") @@ -1062,9 +1053,7 @@ const AgentManagerContent: Component = () => { if (reviewActive()) { closeReviewTab() setSidePanel("diff") - } else { - setSidePanel((prev) => (prev === "diff" ? null : "diff")) - } + } else setSidePanel((prev) => (prev === "diff" ? null : "diff")) } else if (msg.action === "newTab") handleNewTabForCurrentSelection() else if (msg.action === "closeTab") closeActiveTab() else if (msg.action === "newWorktree") handleNewWorktreeOrPromote() @@ -1886,9 +1875,7 @@ const AgentManagerContent: Component = () => { if (pending) { setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? sid : id))) setActivePendingId(undefined) - } else { - setLocalSessionIDs((prev) => [...prev, sid]) - } + } else setLocalSessionIDs((prev) => [...prev, sid]) setSelection(LOCAL) setReviewActive(false) session.selectSession(sid) @@ -1897,20 +1884,14 @@ const AgentManagerContent: Component = () => { const handleAddSession = () => { const sel = selection() - if (sel === LOCAL) { - addPendingTab() - } else if (sel) { - vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel }) - } + if (sel === LOCAL) addPendingTab() + else if (sel) vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel }) } const handleForkSession = (sessionId: string) => { const sel = selection() - if (sel === LOCAL) { - vscode.postMessage({ type: "agentManager.forkSession", sessionId }) - } else if (sel) { - vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel }) - } + if (sel === LOCAL) vscode.postMessage({ type: "agentManager.forkSession", sessionId }) + else if (sel) vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel }) } const handleCloseTab = (sessionId: string) => { @@ -1920,14 +1901,12 @@ const AgentManagerContent: Component = () => { const tabs = activeTabs() const idx = tabs.findIndex((s) => s.id === sessionId) const next = tabs[idx + 1] ?? tabs[idx - 1] - if (next) { - if (isPending(next.id)) { - setActivePendingId(next.id) - session.clearCurrentSession() - } else { - setActivePendingId(undefined) - session.selectSession(next.id) - } + if (next && isPending(next.id)) { + setActivePendingId(next.id) + session.clearCurrentSession() + } else if (next) { + setActivePendingId(undefined) + session.selectSession(next.id) } else { setActivePendingId(undefined) session.clearCurrentSession() @@ -1935,9 +1914,7 @@ const AgentManagerContent: Component = () => { } if (pending || localSet().has(sessionId)) { setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId)) - if (!pending) { - vscode.postMessage({ type: "agentManager.forgetSession", sessionId }) - } + if (!pending) vscode.postMessage({ type: "agentManager.forgetSession", sessionId }) } else { vscode.postMessage({ type: "agentManager.closeSession", sessionId }) } @@ -2921,7 +2898,29 @@ const AgentManagerContent: Component = () => { ) })()} - + + { + setHistory(false) + if (localSessionIDs().includes(id)) { + saveTabMemory() + session.selectSession(id) + setSelection(LOCAL) + return + } + const ms = worktreeSessionIds().has(id) ? managedSessions().find((s) => s.id === id) : undefined + if (ms?.worktreeId) { + selectWorktree(ms.worktreeId) + session.selectSession(id) + setReviewActive(false) + return + } + openLocally(id) + }} + onBack={() => setHistory(false)} + /> + + {/* Chat + side diff panel (hidden when review tab is active) */}
{ } openLocally(id) }} + onShowHistory={() => setHistory(true)} readonly={readOnly()} continueInWorktree={selection() === LOCAL} promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 98937c95713..c9f6c2bec0a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -70,8 +70,7 @@ export const PromptInput: Component = (props) => { () => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID(), ) const terminal = useTerminalContext(vscode) - const excluded = worktree ? new Set(["sessions"]) : undefined - const slash = useSlashCommand(vscode, excluded) + const slash = useSlashCommand(vscode) const imageAttach = useImageAttachments() imageAttach.setFilePathDropHandler((paths) => { const cwd = server.workspaceDirectory() diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts index 1920f1b7070..106651478c4 100644 --- a/packages/opencode/test/kilocode/local-model.test.ts +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -189,8 +189,16 @@ async function initLocal(options?: { prewrite?: Record }): Promise< } async function readModelJson(): Promise { - const text = await fs.readFile(modelJsonPath, "utf-8") - return JSON.parse(text) + const until = Date.now() + 2000 + while (true) { + try { + const text = await fs.readFile(modelJsonPath, "utf-8") + return JSON.parse(text) + } catch (err) { + if (Date.now() >= until) throw err + await Bun.sleep(10) + } + } } async function removeModelJson() { From 31808ccc64f057e885c95e8cc172b4ce46a7a53d Mon Sep 17 00:00:00 2001 From: Josh Holmer Date: Fri, 17 Apr 2026 06:36:21 -0400 Subject: [PATCH 10/35] fix: improve GPT and Codex subagent usage (#9076) --- packages/opencode/src/session/prompt/codex.txt | 1 + packages/opencode/src/session/prompt/gpt.txt | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/opencode/src/session/prompt/codex.txt b/packages/opencode/src/session/prompt/codex.txt index 8524019eb56..2f31b1d9417 100644 --- a/packages/opencode/src/session/prompt/codex.txt +++ b/packages/opencode/src/session/prompt/codex.txt @@ -8,6 +8,7 @@ You are an interactive CLI tool that helps users with software engineering tasks - Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). ## Tool usage +- If the Task tool is available, use it proactively to delegate focused subtasks to a subagent instance. You can spawn multiple subagents in parallel. - Prefer specialized tools over shell for file operations: - Use Read to view files, Edit to modify files, and Write only when needed. - Use Glob to find files by name and Grep to search file contents. diff --git a/packages/opencode/src/session/prompt/gpt.txt b/packages/opencode/src/session/prompt/gpt.txt index 76dc41063af..da9f94e6044 100644 --- a/packages/opencode/src/session/prompt/gpt.txt +++ b/packages/opencode/src/session/prompt/gpt.txt @@ -2,6 +2,7 @@ You are Kilo Code, You and the user share the same workspace and collaborate to You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer. +- If the Task tool is available, use it proactively to delegate focused subtasks to a subagent instance. You can spawn multiple subagents in parallel. - When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`) - Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly. From a98b10ebc98407d323df51a93a87f554bef20f2c Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 17 Apr 2026 12:48:40 +0200 Subject: [PATCH 11/35] docs: document changeset workflow in contributing guide --- .../kilo-docs/pages/contributing/index.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/kilo-docs/pages/contributing/index.md b/packages/kilo-docs/pages/contributing/index.md index 1b8fa9e8604..a3f17423b64 100644 --- a/packages/kilo-docs/pages/contributing/index.md +++ b/packages/kilo-docs/pages/contributing/index.md @@ -58,6 +58,33 @@ git checkout -b docs/your-change-description - Reference issue numbers when applicable - Keep commits focused on a single change +### Changesets + +User-facing changes (features, fixes, breaking changes) require a changeset file so the update shows up in the next release notes. Run the interactive tool, or create the file by hand: + +```bash +bunx changeset add +``` + +Or create `.changeset/.md` manually: + +```md +--- +"kilo-code": minor +--- + +Short description of the change for the changelog. +``` + +Guidelines: + +- Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. +- Descriptions are read by end users in release notes — keep them concise and feature-oriented. Describe **what changed from the user's perspective**, not implementation details. +- Write in imperative mood (e.g. "Support exporting conversations as markdown" rather than "Add a new export handler that serializes session messages to .md files"). +- Changesets are consumed at release time by the `publish.yml` workflow, which generates changelog entries for the GitHub release notes. + +Skip the changeset only for internal refactors, CI tweaks, test-only changes, or docs that do not affect users. + ### Testing Your Changes - Run the test suite: From 07df796591fce924de1d45af08acbf119572213b Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 14:12:48 +0300 Subject: [PATCH 12/35] fix(cli): wrap auto-apply effect in change markers --- packages/opencode/src/cli/cmd/tui/context/local.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index c24f167c057..879b0eb4c72 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -414,15 +414,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Automatically update model when agent changes createEffect(() => { - // kilocode_change start - wait for persistence load; don't overwrite saved per-agent picks (#9050) + // kilocode_change start - wait for persistence load and skip when a per-agent pick already exists (#9050) if (!model.ready) return - // kilocode_change end const value = agent.current() - if (!value) return // kilocode_change - guard against empty agent list during org switch - // kilocode_change start - skip when the user (or a previous session) already picked a model + if (!value) return // guard against empty agent list during org switch if (!value.model) return if (model.saved(value.name)) return - // kilocode_change end if (isModelValid(value.model)) model.set({ providerID: value.model.providerID, @@ -434,6 +431,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, duration: 3000, }) + // kilocode_change end }) const result = { From 03276137581e5139bc1e4a9545203b66489e65bb Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:13:28 +0000 Subject: [PATCH 13/35] docs(kilo-docs): update kilo-auto model mappings Sync frontier/balanced/free/small tier mappings with the current kilo-auto resolution logic in Kilo-Org/cloud. --- .../architecture/auto-model-tiers.md | 8 ++-- .../pages/gateway/models-and-providers.md | 39 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index e2e13938ce2..9b82541e6d1 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -52,7 +52,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost. -**What it does**: Follows the same mode-based routing structure as Frontier but uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — across all modes. +**What it does**: Uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — for every mode. Unlike Frontier, Balanced does not vary its underlying model by mode. The legacy `kilo/auto` model ID also resolves to Balanced. **Pricing**: Paid, but significantly cheaper than Frontier. @@ -62,17 +62,17 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Users who want to try Kilo without a credit card, students, hobbyists, and anyone exploring AI-assisted coding. -**What it does**: Automatically maps to the best available free model(s) for each mode. As free model availability changes due to promotional periods, the mapping updates transparently. Users always get the best free option without having to track which models are currently available. +**What it does**: Splits requests across the best available free models, weighted by a deterministic per-session hash so a given session sticks with one model. As free model availability changes due to promotional periods, the split and the underlying models are updated transparently server-side. Users always get the best free option without having to track which models are currently available. **Pricing**: Free. No credits required. -**Constraints**: Free models may not provide sufficient breadth to justify different models per mode. In that case, a single model may be used for all modes. Quality will be lower than Frontier or Balanced tiers — this is a tradeoff users accept by choosing free. +**Constraints**: Free models do not vary by mode — the same model is used for every mode within a session. Quality will be lower than Frontier or Balanced tiers — this is a tradeoff users accept by choosing free. ### Auto: Small (internal) **Who it's for**: Not user-facing. Used internally by Kilo for lightweight background tasks (session titles, commit messages, conversation summaries). -**What it does**: Automatically selects the right small model for lightweight tasks. When credits are available, it uses a fast paid small model. +**What it does**: Automatically selects the right small model for lightweight tasks. When the account has a positive balance, it uses a fast paid small model; otherwise it falls back to a free small model. **Why it matters**: Users never think about background tasks, and they shouldn't have to. Auto: Small ensures these tasks always work, always feel fast, and never waste credits on an expensive model when a cheap one will do. diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 455eae7831a..ccf7ade164c 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -78,40 +78,39 @@ Kilo Auto virtual models automatically select the best underlying model based on ### `kilo-auto/frontier` -Highest performance and capability for any task. +Highest performance and capability for any task. Frontier requests are sent with medium reasoning effort and medium verbosity. | Mode | Resolved Model | | -------------------------------------------------------------- | ----------------------------- | -| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.6` | +| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.7` | | `build`, `explore`, `code` | `anthropic/claude-sonnet-4.6` | -| Default (no mode specified) | `anthropic/claude-sonnet-4.6` | +| Default (no / unknown mode) | `anthropic/claude-sonnet-4.6` | ### `kilo-auto/balanced` -Great balance of price and capability. - -| Mode | Resolved Model | -| -------------------------------------------------------------- | ---------------------- | -| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `openai/gpt-5.3-codex` | -| `build`, `explore`, `code` | `openai/gpt-5.3-codex` | -| Default (no mode specified) | `openai/gpt-5.3-codex` | - -### `kilo-auto/free` - -Free with limited capability. No credits required. +Great balance of price and capability. Balanced routes to the same model regardless of mode, with low reasoning effort. The legacy `kilo/auto` alias resolves to the same behavior. | Mode | Resolved Model | | --------- | ---------------------- | -| All modes | `minimax/minimax-m2.5` | +| All modes | `openai/gpt-5.3-codex` | + +### `kilo-auto/free` + +Free with limited capability. No credits required. Requests are split across the available free models; the mapping updates server-side as free model availability shifts. + +| Routing | Resolved Model | +| ------- | ----------------------------- | +| 80% | `minimax/minimax-m2.5:free` | +| 20% | `stepfun/step-3.5-flash:free` | ### `kilo-auto/small` -Automatically routes to a small, fast model. +Automatically routes to a small, fast model for lightweight background tasks (session titles, commit messages, summaries). -| Mode | Resolved Model | -| ------------- | -------------------- | -| Default | `openai/gpt-5-nano` | -| Free fallback | `openai/gpt-oss-20b` | +| Condition | Resolved Model | +| ------------------------- | -------------------------------- | +| Account has paid balance | `google/gemma-4-31b-it` | +| No balance / free account | `google/gemma-4-26b-a4b-it:free` | ### Example usage From 6231a54a5728109458eaf264ef361f09b1927722 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:18:43 +0000 Subject: [PATCH 14/35] docs(kilo-docs): clarify balanced and free routing in auto-model guide --- packages/kilo-docs/pages/code-with-ai/agents/auto-model.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index c96989df403..ac7bc6854c8 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -26,8 +26,8 @@ You can see which underlying models are used, as well as the cost, in the expand ## Tiers - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. -- **Balanced** — Follows the same mode-based routing structure as Frontier but uses a more cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices. -- **Free** — Routes to the best available free model on OpenRouter. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the model may change over time. +- **Balanced** — Uses a single cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices. +- **Free** — Routes to the best available free models on OpenRouter, splitting traffic across them. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the models may change over time. ## Benefits From 9749cc178d999f96669cc815709a7cdf3129aefd Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 13:24:35 +0200 Subject: [PATCH 15/35] feat(kilo-ui): enhance MCP tool display with input/output sections and improved styling (#9123) * feat(kilo-ui): enhance MCP tool display with input/output sections and improved styling - Add subtitle and args display to MCP tool triggers for better context - Separate input and output sections with labels and dividers - Format input and output as JSON with proper syntax highlighting - Reposition copy button tooltip to prevent clipping in tool output - Add styling for MCP section labels and tool dividers * feat(kilo-ui): i18n for MCP input/output labels * chore: add changeset --------- Co-authored-by: Sylwester Liljegren --- .changeset/mcp-input-output-i18n.md | 5 ++ .../kilo-ui/src/components/basic-tool.css | 18 +++++ .../kilo-ui/src/components/message-part.tsx | 76 +++++++++++++++++-- ...-to-queued-user-spacing-chromium-linux.png | 4 +- .../shell-execution-chromium-linux.png | 4 +- .../mcp-tool-cards-chromium-linux.png | 4 +- .../mcp-tool-expanded-chromium-linux.png | 4 +- .../multiple-tool-calls-chromium-linux.png | 4 +- .../question-dismissed-chromium-linux.png | 4 +- .../tool-cards-chromium-linux.png | 4 +- packages/ui/src/i18n/ar.ts | 2 + packages/ui/src/i18n/br.ts | 2 + packages/ui/src/i18n/bs.ts | 2 + packages/ui/src/i18n/da.ts | 2 + packages/ui/src/i18n/de.ts | 2 + packages/ui/src/i18n/en.ts | 2 + packages/ui/src/i18n/es.ts | 2 + packages/ui/src/i18n/fr.ts | 2 + packages/ui/src/i18n/ja.ts | 2 + packages/ui/src/i18n/ko.ts | 2 + packages/ui/src/i18n/nl.ts | 2 + packages/ui/src/i18n/no.ts | 2 + packages/ui/src/i18n/pl.ts | 2 + packages/ui/src/i18n/ru.ts | 2 + packages/ui/src/i18n/th.ts | 2 + packages/ui/src/i18n/tr.ts | 2 + packages/ui/src/i18n/uk.ts | 2 + packages/ui/src/i18n/zh.ts | 2 + packages/ui/src/i18n/zht.ts | 2 + 29 files changed, 143 insertions(+), 22 deletions(-) create mode 100644 .changeset/mcp-input-output-i18n.md diff --git a/.changeset/mcp-input-output-i18n.md b/.changeset/mcp-input-output-i18n.md new file mode 100644 index 00000000000..607911264c1 --- /dev/null +++ b/.changeset/mcp-input-output-i18n.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show MCP tool call inputs alongside outputs in chat, with JSON syntax highlighting for both. diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index d8a66e011cb..17cb5d9d07c 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -156,6 +156,24 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty } } + /* Reposition copy button tooltip to appear below (not above) to avoid clipping */ + [data-component="tool-output"] [data-slot="markdown-copy-button"]::after { + bottom: auto; + top: calc(100% + 4px); + } + + [data-slot="mcp-section-label"] { + padding: 6px 12px 0; + font-size: 11px; + color: var(--text-weak, var(--vscode-descriptionForeground)); + } + + [data-slot="mcp-tool-divider"] { + height: 1px; + background: var(--border-weak-base, var(--vscode-panel-border)); + margin-top: 4px; + } + /* Expandable tool output content */ [data-component="tool-output"] { padding: 8px 12px; diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index aa30400b5d2..1bbbdb1eb7a 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1026,24 +1026,84 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre // GenericTool (upstream) does not render output; this override does. // When hideDetails is true, render as a row (no content), otherwise as a panel with markdown output. function McpTool(props: ToolProps) { + const i18n = useI18n() + const labelKeys = ["description", "query", "url", "filePath", "path", "pattern", "name"] + const skipKeys = new Set(labelKeys) + + const subtitle = () => + labelKeys + .map((key) => props.input?.[key]) + .find((value): value is string => typeof value === "string" && value.length > 0) + + const inputArgs = () => { + if (!props.input) return [] + return Object.entries(props.input) + .filter(([key]) => !skipKeys.has(key)) + .flatMap(([key, value]) => { + if (typeof value === "string") return [`${key}=${value}`] + if (typeof value === "number") return [`${key}=${value}`] + if (typeof value === "boolean") return [`${key}=${value}`] + return [] + }) + .slice(0, 3) + } + + const formatted = createMemo(() => { + if (!props.input || Object.keys(props.input).length === 0) return "" + return "```json\n" + JSON.stringify(props.input, null, 2) + "\n```" + }) + + const formattedOutput = createMemo(() => { + if (!props.output) return undefined + try { + const parsed = JSON.parse(props.output) + return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```" + } catch { + return props.output + } + }) + return ( } + fallback={ + + } > - - {(output) => ( -
- -
+ + {(text) => ( + <> +
{i18n.t("ui.messagePart.mcp.input")}
+
+ +
+ + )} +
+ + {(text) => ( + <> + +
+ +
{i18n.t("ui.messagePart.mcp.output")}
+
+ +
+ )} @@ -1068,7 +1128,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { return ( -
+
{(error) => { diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png index fa65a4f8a84..92378b5e3c5 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:123f32340a93af21146d08bb40738710bdd84099a27d0310316bb937360ca9f9 -size 14605 +oid sha256:07b3bf094c0a82e64fc7cb4e22aa37ada3dd5904a19a313879c6016c2ab55a00 +size 14586 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png index e09f627f07c..4d612a7ad03 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9dbdd4b71bea71529e8835def452bbb627837d5bb6dbe460d882b20c4add6e98 -size 17650 +oid sha256:74a42ec77a8ac8d5a1c555b46e3d550c9acf3198f45cc87da6582c0ff924d5f9 +size 17677 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png index 887e38a6c50..4ec7183fdad 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f50e8ca1c012b64797cf57d5293ac59afefa79c8f1d703b66ac796e5b85fbaf -size 6402 +oid sha256:c583144b11ac9608ec755e9a683dced8ae6ffae9a6f3833e1c0eec6b664f358e +size 7720 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png index cb685b058e1..238361cf9e6 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3fca4a95e3754b2d1316635fd756fa3fe8895213de7332d130a2f44075f06485 -size 19072 +oid sha256:8348db9191e1e616c93bf54ae33702a57e25c092a8f80a24d1da04811e523500 +size 26519 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png index 4a06e7da60d..fa1a4b2466a 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f539f72e2c53c6ce84d26397715bb9c4e80c2c107e4584023f2bdbeba0086e62 -size 6958 +oid sha256:15116299b4700cceb501114e8fdca8b3ddfa5fb9d793f7cbab44b2647bbe64a4 +size 8070 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png index 5a6833dfadf..85da22e8f7e 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4ea685888b585c50e8902a3df4a0fc85104a217b41bf6763d8e7e2f113c32ab -size 4356 +oid sha256:ed66186a7aacdd4d31ff250a9c31fb1b244834cbb23e36abc51af706545f4518 +size 5059 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png index 7f614a23d6f..7959059a155 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e0fecc40d49bdd601a7d1ed6c8d4bb6c3854843c3e67eca408d3cd0a42b9f4f -size 8193 +oid sha256:1d21dc25072b1c52cacaf0a91369f45e89f43ccc181d643e6b65030b829e5b1e +size 8591 diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 72e834e5e0c..f31f108cb13 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} قائمة", "ui.messagePart.context.list.other": "{{count}} قوائم", "ui.messagePart.diagnostic.error": "خطأ", + "ui.messagePart.mcp.input": "الإدخال", + "ui.messagePart.mcp.output": "الإخراج", "ui.messagePart.title.edit": "تحرير", "ui.messagePart.title.write": "كتابة", "ui.messagePart.option.typeOwnAnswer": "اكتب إجابتك الخاصة", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index e14a3fed47c..6d4a826bcde 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} lista", "ui.messagePart.context.list.other": "{{count}} listas", "ui.messagePart.diagnostic.error": "Erro", + "ui.messagePart.mcp.input": "Entrada", + "ui.messagePart.mcp.output": "Saída", "ui.messagePart.title.edit": "Editar", "ui.messagePart.title.write": "Escrever", "ui.messagePart.option.typeOwnAnswer": "Digite sua própria resposta", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index ccea10c1e6e..62970c7279f 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -80,6 +80,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} lista", "ui.messagePart.context.list.other": "{{count}} liste", "ui.messagePart.diagnostic.error": "Greška", + "ui.messagePart.mcp.input": "Ulaz", + "ui.messagePart.mcp.output": "Izlaz", "ui.messagePart.title.edit": "Uredi", "ui.messagePart.title.write": "Napiši", "ui.messagePart.option.typeOwnAnswer": "Unesi svoj odgovor", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 71bb5236667..20303221038 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -75,6 +75,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} liste", "ui.messagePart.context.list.other": "{{count}} lister", "ui.messagePart.diagnostic.error": "Fejl", + "ui.messagePart.mcp.input": "Input", + "ui.messagePart.mcp.output": "Output", "ui.messagePart.title.edit": "Rediger", "ui.messagePart.title.write": "Skriv", "ui.messagePart.option.typeOwnAnswer": "Skriv dit eget svar", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 9a0cb10cb93..bc0358ea056 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -81,6 +81,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} Liste", "ui.messagePart.context.list.other": "{{count}} Listen", "ui.messagePart.diagnostic.error": "Fehler", + "ui.messagePart.mcp.input": "Eingabe", + "ui.messagePart.mcp.output": "Ausgabe", "ui.messagePart.title.edit": "Bearbeiten", "ui.messagePart.title.write": "Schreiben", "ui.messagePart.option.typeOwnAnswer": "Eigene Antwort eingeben", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 0450e757afb..4ed9e9c01b6 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -69,6 +69,8 @@ export const dict: Record = { "ui.sessionTurn.status.consideringNextSteps": "Considering next steps", "ui.messagePart.diagnostic.error": "Error", + "ui.messagePart.mcp.input": "Input", + "ui.messagePart.mcp.output": "Output", "ui.messagePart.title.edit": "Edit", "ui.messagePart.title.write": "Write", "ui.messagePart.option.typeOwnAnswer": "Type your own answer", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index e952c098be1..90899358219 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} lista", "ui.messagePart.context.list.other": "{{count}} listas", "ui.messagePart.diagnostic.error": "Error", + "ui.messagePart.mcp.input": "Entrada", + "ui.messagePart.mcp.output": "Salida", "ui.messagePart.title.edit": "Editar", "ui.messagePart.title.write": "Escribir", "ui.messagePart.option.typeOwnAnswer": "Escribe tu propia respuesta", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 9d48158a73c..35f6702c5d4 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} liste", "ui.messagePart.context.list.other": "{{count}} listes", "ui.messagePart.diagnostic.error": "Erreur", + "ui.messagePart.mcp.input": "Entrée", + "ui.messagePart.mcp.output": "Sortie", "ui.messagePart.title.edit": "Modifier", "ui.messagePart.title.write": "Écrire", "ui.messagePart.option.typeOwnAnswer": "Tapez votre propre réponse", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 71e78ffa85c..2daf8cf2443 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -75,6 +75,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} 件のリスト", "ui.messagePart.context.list.other": "{{count}} 件のリスト", "ui.messagePart.diagnostic.error": "エラー", + "ui.messagePart.mcp.input": "入力", + "ui.messagePart.mcp.output": "出力", "ui.messagePart.title.edit": "編集", "ui.messagePart.title.write": "作成", "ui.messagePart.option.typeOwnAnswer": "自分の回答を入力", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index cf56b257e37..ee6e1f83096 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -76,6 +76,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}}개 목록", "ui.messagePart.context.list.other": "{{count}}개 목록", "ui.messagePart.diagnostic.error": "오류", + "ui.messagePart.mcp.input": "입력", + "ui.messagePart.mcp.output": "출력", "ui.messagePart.title.edit": "편집", "ui.messagePart.title.write": "작성", "ui.messagePart.option.typeOwnAnswer": "직접 답변 입력", diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts index b86fa54ea11..c3cab8540d2 100644 --- a/packages/ui/src/i18n/nl.ts +++ b/packages/ui/src/i18n/nl.ts @@ -69,6 +69,8 @@ export const dict: Record = { "ui.sessionTurn.status.consideringNextSteps": "Volgende stappen overwegen", "ui.messagePart.diagnostic.error": "Fout", + "ui.messagePart.mcp.input": "Invoer", + "ui.messagePart.mcp.output": "Uitvoer", "ui.messagePart.title.edit": "Bewerken", "ui.messagePart.title.write": "Schrijven", "ui.messagePart.option.typeOwnAnswer": "Typ je eigen antwoord", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index e2ac20fb836..377fa7a95a8 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -79,6 +79,8 @@ export const dict: Record = { "ui.messagePart.context.list.one": "{{count}} liste", "ui.messagePart.context.list.other": "{{count}} lister", "ui.messagePart.diagnostic.error": "Feil", + "ui.messagePart.mcp.input": "Inndata", + "ui.messagePart.mcp.output": "Utdata", "ui.messagePart.title.edit": "Rediger", "ui.messagePart.title.write": "Skriv", "ui.messagePart.option.typeOwnAnswer": "Skriv ditt eget svar", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index fa21eea0d4a..05a1a2e1662 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -75,6 +75,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} lista", "ui.messagePart.context.list.other": "{{count}} listy", "ui.messagePart.diagnostic.error": "Błąd", + "ui.messagePart.mcp.input": "Wejście", + "ui.messagePart.mcp.output": "Wyjście", "ui.messagePart.title.edit": "Edycja", "ui.messagePart.title.write": "Pisanie", "ui.messagePart.option.typeOwnAnswer": "Wpisz własną odpowiedź", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 5c01b07d09d..067cbd0e67b 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -75,6 +75,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} список", "ui.messagePart.context.list.other": "{{count}} списков", "ui.messagePart.diagnostic.error": "Ошибка", + "ui.messagePart.mcp.input": "Ввод", + "ui.messagePart.mcp.output": "Вывод", "ui.messagePart.title.edit": "Редактировать", "ui.messagePart.title.write": "Написать", "ui.messagePart.option.typeOwnAnswer": "Введите свой ответ", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 15d94f0fb11..92299360185 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -77,6 +77,8 @@ export const dict = { "ui.messagePart.context.list.one": "รายการ {{count}} รายการ", "ui.messagePart.context.list.other": "รายการ {{count}} รายการ", "ui.messagePart.diagnostic.error": "ข้อผิดพลาด", + "ui.messagePart.mcp.input": "อินพุต", + "ui.messagePart.mcp.output": "เอาต์พุต", "ui.messagePart.title.edit": "แก้ไข", "ui.messagePart.title.write": "เขียน", "ui.messagePart.option.typeOwnAnswer": "พิมพ์คำตอบของคุณเอง", diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts index f3f9886ca6c..f7b55f6ad8e 100644 --- a/packages/ui/src/i18n/tr.ts +++ b/packages/ui/src/i18n/tr.ts @@ -82,6 +82,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} liste", "ui.messagePart.context.list.other": "{{count}} liste", "ui.messagePart.diagnostic.error": "Hata", + "ui.messagePart.mcp.input": "Giriş", + "ui.messagePart.mcp.output": "Çıkış", "ui.messagePart.title.edit": "Düzenle", "ui.messagePart.title.write": "Yaz", "ui.messagePart.option.typeOwnAnswer": "Kendi cevabınızı yazın", diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts index 090a0481874..3ebf9ce3396 100644 --- a/packages/ui/src/i18n/uk.ts +++ b/packages/ui/src/i18n/uk.ts @@ -82,6 +82,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} список", "ui.messagePart.context.list.other": "{{count}} списків", "ui.messagePart.diagnostic.error": "Помилка", + "ui.messagePart.mcp.input": "Вхід", + "ui.messagePart.mcp.output": "Вихід", "ui.messagePart.title.edit": "Редагувати", "ui.messagePart.title.write": "Записати", "ui.messagePart.option.typeOwnAnswer": "Введіть власну відповідь", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index c9b235a2a76..48423ab232c 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -80,6 +80,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} 个列表", "ui.messagePart.context.list.other": "{{count}} 个列表", "ui.messagePart.diagnostic.error": "错误", + "ui.messagePart.mcp.input": "输入", + "ui.messagePart.mcp.output": "输出", "ui.messagePart.title.edit": "编辑", "ui.messagePart.title.write": "写入", "ui.messagePart.option.typeOwnAnswer": "输入自己的答案", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 64728cb4565..276db511540 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -80,6 +80,8 @@ export const dict = { "ui.messagePart.context.list.one": "{{count}} 個清單", "ui.messagePart.context.list.other": "{{count}} 個清單", "ui.messagePart.diagnostic.error": "錯誤", + "ui.messagePart.mcp.input": "輸入", + "ui.messagePart.mcp.output": "輸出", "ui.messagePart.title.edit": "編輯", "ui.messagePart.title.write": "寫入", "ui.messagePart.option.typeOwnAnswer": "輸入自己的答案", From b5bc3fe025a9104511a51bfb042ff897cfbb0ff6 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:26:34 +0000 Subject: [PATCH 16/35] docs(kilo-docs): remove legacy kilo/auto alias mentions --- .../pages/contributing/architecture/auto-model-tiers.md | 2 +- packages/kilo-docs/pages/gateway/models-and-providers.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index 9b82541e6d1..9f47cd20bab 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -52,7 +52,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost. -**What it does**: Uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — for every mode. Unlike Frontier, Balanced does not vary its underlying model by mode. The legacy `kilo/auto` model ID also resolves to Balanced. +**What it does**: Uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — for every mode. Unlike Frontier, Balanced does not vary its underlying model by mode. **Pricing**: Paid, but significantly cheaper than Frontier. diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index ccf7ade164c..73d528c58d8 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -88,7 +88,7 @@ Highest performance and capability for any task. Frontier requests are sent with ### `kilo-auto/balanced` -Great balance of price and capability. Balanced routes to the same model regardless of mode, with low reasoning effort. The legacy `kilo/auto` alias resolves to the same behavior. +Great balance of price and capability. Balanced routes to the same model regardless of mode, with low reasoning effort. | Mode | Resolved Model | | --------- | ---------------------- | From 20331784f5c2b3c59bf489bb4b8d176ebd066639 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:30:02 +0000 Subject: [PATCH 17/35] docs(kilo-docs): bump stale claude model IDs to current versions --- .../pages/contributing/architecture/auto-model-tiers.md | 4 ++-- packages/kilo-docs/pages/gateway/models-and-providers.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index 9f47cd20bab..0821d610f95 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -115,8 +115,8 @@ The Kilo API at `api.kilo.ai` defines which underlying models each `kilo-auto/*` { "opencode": { "variants": { - "architect": { "model": "anthropic/claude-opus-4-6", ... }, - "code": { "model": "anthropic/claude-sonnet-4-6", ... } + "architect": { "model": "anthropic/claude-opus-4.7", ... }, + "code": { "model": "anthropic/claude-sonnet-4.6", ... } } } } diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 73d528c58d8..63cff2caef1 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -41,7 +41,7 @@ This returns model information including pricing, context window, and supported | Model ID | Provider | Description | | ------------------------------- | --------- | ----------------------------------------------- | -| `anthropic/claude-opus-4.6` | Anthropic | Most capable Claude model for complex reasoning | +| `anthropic/claude-opus-4.7` | Anthropic | Most capable Claude model for complex reasoning | | `anthropic/claude-sonnet-4.6` | Anthropic | Balanced performance and cost | | `anthropic/claude-haiku-4.5` | Anthropic | Fast and cost-effective | | `openai/gpt-5.4` | OpenAI | Latest GPT model | From 7267b7dc9b94f84a062d48f431188e010514a2b8 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 15:04:29 +0300 Subject: [PATCH 18/35] revert: undo all Agent Manager leak-fix attempts None of the speculative fixes on this branch addressed the root cause: Agent Manager RSS growth is upstream Bun memory leak oven-sh/bun#18265 - Bun.spawn with piped stdio retains native memory in mimalloc arenas on every call. Measured this branch at ~2.1 MB/spawn, flat, identical on Bun.spawn and node:child_process (the latter is a Bun polyfill over the same machinery). No code path in our tree is the problem. Restoring branch tree to the merge-base state so the Bun-blessed MIMALLOC_PURGE_DELAY=0 workaround can be applied cleanly in a follow-up commit. Commits whose effects are undone (kept in history for posterity): 96ce0cb fix(cli,vscode): fix native memory leak in Agent Manager git polling 7682f1a fix(review): surface truncated git output to callers 18d6867 fix(review): drain stderr concurrently to avoid hang 0965571 fix(cli,vscode): evict idle worktree instances and stream git output --- .../src/agent-manager/AgentManagerProvider.ts | 18 -- .../src/agent-manager/GitStatsPoller.ts | 2 +- .../agent-manager/worktree-diff-controller.ts | 2 +- .../unit/memory-instance-dispose.test.ts | 61 ------ .../unit/memory-polling-intervals.test.ts | 30 --- .../src/kilocode/review/worktree-diff.ts | 182 +++++------------- .../opencode/src/kilocode/server/server.ts | 37 ---- packages/opencode/src/project/instance.ts | 66 +------ packages/opencode/src/server/server.ts | 2 - .../kilocode/project/instance-evict.test.ts | 101 ---------- .../review/worktree-diff-buffer.test.ts | 76 -------- .../review/worktree-diff-cache.test.ts | 64 ------ .../review/worktree-diff-memory.test.ts | 74 ------- .../review/worktree-diff-stream.test.ts | 57 ------ 14 files changed, 53 insertions(+), 719 deletions(-) delete mode 100644 packages/kilo-vscode/tests/unit/memory-instance-dispose.test.ts delete mode 100644 packages/kilo-vscode/tests/unit/memory-polling-intervals.test.ts delete mode 100644 packages/opencode/test/kilocode/project/instance-evict.test.ts delete mode 100644 packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts delete mode 100644 packages/opencode/test/kilocode/review/worktree-diff-cache.test.ts delete mode 100644 packages/opencode/test/kilocode/review/worktree-diff-memory.test.ts delete mode 100644 packages/opencode/test/kilocode/review/worktree-diff-stream.test.ts diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 23ff01bcf18..1777c8c8e93 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -803,15 +803,6 @@ export class AgentManagerProvider implements Disposable { } for (const s of orphaned) this.panel?.sessions.clearSessionDirectory(s.id) this.pushState() - // Dispose the CLI Instance (file watchers, LSP, snapshot repo, PubSub) - // before removing the directory so the server can clean up properly. - try { - const client = this.connectionService.getClient() - await client.instance.dispose({ directory: worktree.path }) - this.log(`Disposed CLI instance for worktree ${worktreeId}`) - } catch (err) { - this.log(`instance.dispose() for worktree ${worktreeId} failed (non-fatal):`, err) - } // Disk removal after state is clean — pollers no longer reference this worktree. try { await manager.removeWorktree(worktree.path, worktree.originalBranch ?? worktree.branch) @@ -845,15 +836,6 @@ export class AgentManagerProvider implements Disposable { for (const session of orphaned) { this.panel?.sessions.clearSessionDirectory(session.id) } - // Dispose the CLI Instance even though the directory may be gone — the - // server cache entry still holds resources (PubSub queues, DB connections). - try { - const client = this.connectionService.getClient() - await client.instance.dispose({ directory: worktree.path }) - this.log(`Disposed CLI instance for stale worktree ${worktreeId}`) - } catch (err) { - this.log(`instance.dispose() for stale worktree ${worktreeId} failed (non-fatal):`, err) - } this.clearStaleTracking(worktreeId) this.pushState() this.log(`Removed stale worktree entry ${worktreeId} (${worktree.branch})`) diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index dc23633a158..30a79b827c5 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -66,7 +66,7 @@ export class GitStatsPoller { private visible = true constructor(private readonly options: GitStatsPollerOptions) { - this.intervalMs = options.intervalMs ?? 15_000 + this.intervalMs = options.intervalMs ?? 5000 this.hiddenIntervalMs = options.hiddenIntervalMs ?? 60000 this.git = options.git } diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index b40b824abb4..a4ec1dabbd9 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -200,7 +200,7 @@ export class WorktreeDiffController { if (this.session !== sessionId) return this.interval = setInterval(() => { void this.poll(sessionId) - }, 15_000) + }, 2500) }) } diff --git a/packages/kilo-vscode/tests/unit/memory-instance-dispose.test.ts b/packages/kilo-vscode/tests/unit/memory-instance-dispose.test.ts deleted file mode 100644 index 381aca23f29..00000000000 --- a/packages/kilo-vscode/tests/unit/memory-instance-dispose.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Guardrail tests: CLI instance disposal on worktree deletion. - * - * Deleted worktrees must have their CLI Instance disposed to release - * file watchers, LSP, snapshot repos, and PubSub queues. Without - * disposal, these resources accumulate permanently in the kilo serve - * process. - */ - -import { describe, it, expect } from "bun:test" -import fs from "node:fs" -import path from "node:path" -import { Project, SyntaxKind } from "ts-morph" - -const ROOT = path.resolve(import.meta.dir, "../..") -const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts") - -function body(name: string): string { - const project = new Project({ compilerOptions: { allowJs: true } }) - const source = project.addSourceFileAtPath(PROVIDER_FILE) - const cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration) - const method = cls?.getMethod(name) - expect(method, `method ${name} not found in AgentManagerProvider`).toBeTruthy() - return method!.getText() -} - -describe("Memory — CLI instance disposal", () => { - it("onDeleteWorktree calls instance.dispose() with the worktree directory", () => { - const text = body("onDeleteWorktree") - expect(text).toContain("instance.dispose(") - expect(text).toContain("worktree.path") - }) - - it("onRemoveStaleWorktree calls instance.dispose()", () => { - const text = body("onRemoveStaleWorktree") - expect(text).toContain("instance.dispose(") - expect(text).toContain("worktree.path") - }) - - it("instance.dispose() failure does not block worktree deletion", () => { - const text = body("onDeleteWorktree") - // The dispose call must be wrapped in try/catch so failures don't - // prevent disk removal or state cleanup. - const disposeIdx = text.indexOf("instance.dispose(") - const catchIdx = text.indexOf("catch", disposeIdx) - const removeIdx = text.indexOf("manager.removeWorktree", disposeIdx) - expect(disposeIdx, "dispose call must exist").toBeGreaterThan(-1) - expect(catchIdx, "catch must follow dispose").toBeGreaterThan(disposeIdx) - expect(removeIdx, "disk removal must follow dispose+catch").toBeGreaterThan(catchIdx) - }) - - it("instance.dispose() failure does not block stale worktree removal", () => { - const text = body("onRemoveStaleWorktree") - const disposeIdx = text.indexOf("instance.dispose(") - const catchIdx = text.indexOf("catch", disposeIdx) - const clearIdx = text.indexOf("clearStaleTracking", disposeIdx) - expect(disposeIdx, "dispose call must exist").toBeGreaterThan(-1) - expect(catchIdx, "catch must follow dispose").toBeGreaterThan(disposeIdx) - expect(clearIdx, "clearStaleTracking must follow dispose+catch").toBeGreaterThan(catchIdx) - }) -}) diff --git a/packages/kilo-vscode/tests/unit/memory-polling-intervals.test.ts b/packages/kilo-vscode/tests/unit/memory-polling-intervals.test.ts deleted file mode 100644 index f5f3ba9c19f..00000000000 --- a/packages/kilo-vscode/tests/unit/memory-polling-intervals.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Guardrail tests: polling interval minimums. - * - * Aggressive polling (< 10s) caused runaway native memory growth by spawning - * too many git processes per minute. These tests prevent accidental regression. - */ - -import { describe, it, expect } from "bun:test" -import fs from "node:fs" -import path from "node:path" - -const ROOT = path.resolve(import.meta.dir, "../..") - -describe("Memory — polling intervals", () => { - it("WorktreeDiffController polls at >= 10 000 ms", () => { - const src = fs.readFileSync(path.join(ROOT, "src/agent-manager/worktree-diff-controller.ts"), "utf-8") - const match = src.match(/setInterval\(\s*\(\)\s*=>\s*\{[^}]*\}\s*,\s*([\d_]+)\s*\)/) - expect(match, "setInterval call must exist in WorktreeDiffController").toBeTruthy() - const ms = Number(match![1]!.replace(/_/g, "")) - expect(ms).toBeGreaterThanOrEqual(10_000) - }) - - it("GitStatsPoller default interval is >= 10 000 ms", () => { - const src = fs.readFileSync(path.join(ROOT, "src/agent-manager/GitStatsPoller.ts"), "utf-8") - const match = src.match(/options\.intervalMs\s*\?\?\s*([\d_]+)/) - expect(match, "default intervalMs must exist in GitStatsPoller").toBeTruthy() - const ms = Number(match![1]!.replace(/_/g, "")) - expect(ms).toBeGreaterThanOrEqual(10_000) - }) -}) diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts index 47037b59598..3bccf81c79c 100644 --- a/packages/opencode/src/kilocode/review/worktree-diff.ts +++ b/packages/opencode/src/kilocode/review/worktree-diff.ts @@ -1,4 +1,5 @@ // kilocode_change - new file +import { $ } from "bun" import { createTwoFilesPatch } from "diff" import fs from "node:fs/promises" import path from "node:path" @@ -7,86 +8,6 @@ import { FileIgnore } from "@/file/ignore" import { Snapshot } from "@/snapshot" import { Log } from "@/util/log" -// --------------------------------------------------------------------------- -// Git subprocess helper — caps stdout to prevent unbounded native memory growth -// --------------------------------------------------------------------------- - -const MAX_STDOUT = 10 * 1024 * 1024 // 10 MB general cap -const MAX_FILE_STDOUT = 1 * 1024 * 1024 // 1 MB per-file cap (readBefore) - -// Shared decoder — one instance per module avoids re-allocating the ICU state -// on every git call and keeps the native allocator footprint small. -const decoder = new TextDecoder() - -async function git( - args: string[], - cwd: string, - limit = MAX_STDOUT, -): Promise<{ ok: boolean; stdout: string; stderr: string; truncated: boolean }> { - const proc = Bun.spawn(["git", ...args], { - cwd, - stdout: "pipe", - stderr: "pipe", - windowsHide: true, - }) - // Kick off stderr drain immediately so a full stderr pipe can't block the child. - // Both stdout and stderr must be drained concurrently to avoid deadlock. - const stderrPromise = new Response(proc.stderr).text() - - // Collect chunks by reference (the stream hands us freshly-allocated - // Uint8Arrays — no Buffer.from copy needed). A single join at the end - // keeps the allocator high-water to exactly one final buffer per call, - // which is what mimalloc actually retains in its arenas. - const chunks: Uint8Array[] = [] - let size = 0 - let truncated = false - const reader = proc.stdout.getReader() - while (true) { - const { done, value } = await reader.read() - if (done) break - if (truncated) continue // drain pipe but don't store - const space = limit - size - if (value.length >= space) { - if (space > 0) chunks.push(value.subarray(0, space)) - size = limit - truncated = true - continue - } - chunks.push(value) - size += value.length - } - const stderr = await stderrPromise - const code = await proc.exited - return { - ok: code === 0 && !truncated, - stdout: join(chunks, size), - stderr, - truncated, - } -} - -// Single-allocation decode: fast path for the common case of one chunk, -// otherwise one Uint8Array the exact size of the final output plus the -// decoded string. Avoids the extra copy of chunk-array concatenation. -function join(chunks: Uint8Array[], size: number): string { - if (size === 0) return "" - if (chunks.length === 1) return decoder.decode(chunks[0]) - const buf = new Uint8Array(size) - let pos = 0 - for (const c of chunks) { - buf.set(c, pos) - pos += c.length - } - return decoder.decode(buf) -} - -// --------------------------------------------------------------------------- -// Merge-base cache — avoids redundant git spawns across polling cycles -// --------------------------------------------------------------------------- - -const ancestors = new Map() -const ANCESTOR_TTL = 30_000 // 30 seconds - export namespace WorktreeDiff { export const Item = Snapshot.FileDiff.extend({ before: z.string(), @@ -116,37 +37,29 @@ export namespace WorktreeDiff { return FileIgnore.match(file) } - /** Clear the merge-base cache. Exported for testing. */ - export function clearCache() { - ancestors.clear() - } - async function ancestor(dir: string, base: string, log: Log.Logger) { - const key = `${dir}\0${base}` - const cached = ancestors.get(key) - if (cached && Date.now() < cached.expires) return cached.hash - - const result = await git(["merge-base", "HEAD", base], dir) - if (!result.ok) { + const result = await $`git merge-base HEAD ${base}`.cwd(dir).quiet().nothrow() + if (result.exitCode !== 0) { log.warn("git merge-base failed", { - stderr: result.stderr.trim(), + exitCode: result.exitCode, + stderr: result.stderr.toString().trim(), dir, base, }) return } - const hash = result.stdout.trim() - ancestors.set(key, { hash, expires: Date.now() + ANCESTOR_TTL }) - return hash + return result.stdout.toString().trim() } - async function stats(dir: string, ancestor: string, log: Log.Logger) { - const result = await git(["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", ancestor], dir) + async function stats(dir: string, ancestor: string) { + const result = await $`git -c core.quotepath=false diff --numstat --no-renames ${ancestor}` + .cwd(dir) + .quiet() + .nothrow() const map = new Map() - if (result.truncated) log.warn("git diff --numstat output truncated, counts unavailable", { dir }) - if (!result.ok) return map + if (result.exitCode !== 0) return map - for (const line of result.stdout.trim().split("\n")) { + for (const line of result.stdout.toString().trim().split("\n")) { if (!line) continue const parts = line.split("\t") const add = parts[0] @@ -163,17 +76,17 @@ export namespace WorktreeDiff { } async function list(dir: string, ancestor: string, log: Log.Logger): Promise { - const nameStatus = await git(["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", ancestor], dir) - if (nameStatus.truncated) { - log.warn("git diff --name-status output truncated, diff omitted", { dir }) - } - if (!nameStatus.ok) return [] + const nameStatus = await $`git -c core.quotepath=false diff --name-status --no-renames ${ancestor}` + .cwd(dir) + .quiet() + .nothrow() + if (nameStatus.exitCode !== 0) return [] const result: Meta[] = [] const seen = new Set() - const stat = await stats(dir, ancestor, log) + const stat = await stats(dir, ancestor) - for (const line of nameStatus.stdout.trim().split("\n")) { + for (const line of nameStatus.stdout.toString().trim().split("\n")) { if (!line) continue const parts = line.split("\t") const code = parts[0] @@ -194,16 +107,16 @@ export namespace WorktreeDiff { }) } - const untracked = await git(["ls-files", "--others", "--exclude-standard"], dir) - if (untracked.truncated) { - log.warn("git ls-files output truncated, untracked list incomplete", { dir }) - } - if (!untracked.ok) { - log.warn("git ls-files failed", { stderr: untracked.stderr.trim() }) + const untracked = await $`git ls-files --others --exclude-standard`.cwd(dir).quiet().nothrow() + if (untracked.exitCode !== 0) { + log.warn("git ls-files failed", { + exitCode: untracked.exitCode, + stderr: untracked.stderr.toString().trim(), + }) return result } - const files = untracked.stdout.trim() + const files = untracked.stdout.toString().trim() if (files) { log.info("untracked files found", { count: files.split("\n").length }) } @@ -227,8 +140,8 @@ export namespace WorktreeDiff { } async function detailMeta(dir: string, ancestor: string, file: string): Promise { - const tracked = await git(["ls-files", "--error-unmatch", "--", file], dir) - if (!tracked.ok) { + const tracked = await $`git ls-files --error-unmatch -- ${file}`.cwd(dir).quiet().nothrow() + if (tracked.exitCode !== 0) { const after = Bun.file(path.join(dir, file)) if (!(await after.exists())) return undefined return { @@ -242,12 +155,12 @@ export namespace WorktreeDiff { } } - const nameStatus = await git( - ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", ancestor, "--", file], - dir, - ) - if (!nameStatus.ok) return undefined - const line = nameStatus.stdout.trim().split("\n")[0] + const nameStatus = await $`git -c core.quotepath=false diff --name-status --no-renames ${ancestor} -- ${file}` + .cwd(dir) + .quiet() + .nothrow() + if (nameStatus.exitCode !== 0) return undefined + const line = nameStatus.stdout.toString().trim().split("\n")[0] if (!line) return undefined const parts = line.split("\t") @@ -255,11 +168,11 @@ export namespace WorktreeDiff { const pathPart = parts.slice(1).join("\t") || file if (!code) return undefined - const numstat = await git( - ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", ancestor, "--", file], - dir, - ) - const statLine = numstat.stdout.trim().split("\n")[0] + const numstat = await $`git -c core.quotepath=false diff --numstat --no-renames ${ancestor} -- ${file}` + .cwd(dir) + .quiet() + .nothrow() + const statLine = numstat.stdout.toString().trim().split("\n")[0] const stat = statLine ? (() => { const values = statLine.split("\t") @@ -314,11 +227,10 @@ export namespace WorktreeDiff { return `${stat.size}:${stat.mtimeMs}` } - async function readBefore(dir: string, ancestor: string, file: string, status: Status, log: Log.Logger) { + async function readBefore(dir: string, ancestor: string, file: string, status: Status) { if (status === "added") return "" - const result = await git(["show", `${ancestor}:${file}`], dir, MAX_FILE_STDOUT) - if (result.truncated) log.warn("git show output truncated, before content omitted", { file }) - return result.ok ? result.stdout : "" + const result = await $`git show ${ancestor}:${file}`.cwd(dir).quiet().nothrow() + return result.exitCode === 0 ? result.stdout.toString() : "" } async function readAfter(dir: string, file: string, status: Status) { @@ -327,8 +239,8 @@ export namespace WorktreeDiff { return (await result.exists()) ? await result.text() : "" } - async function load(dir: string, ancestor: string, meta: Meta, log: Log.Logger): Promise { - const before = await readBefore(dir, ancestor, meta.file, meta.status, log) + async function load(dir: string, ancestor: string, meta: Meta): Promise { + const before = await readBefore(dir, ancestor, meta.file, meta.status) const after = await readAfter(dir, meta.file, meta.status) const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? lines(after) : meta.additions return { @@ -379,7 +291,7 @@ export namespace WorktreeDiff { if (!ancestorHash) return undefined const item = await detailMeta(input.dir, ancestorHash, input.file) if (!item) return undefined - return await load(input.dir, ancestorHash, item, log) + return await load(input.dir, ancestorHash, item) } export async function full(input: { dir: string; base: string; log?: Log.Logger }) { @@ -389,7 +301,7 @@ export namespace WorktreeDiff { if (!ancestorHash) return [] log.info("merge-base resolved", { ancestor: ancestorHash.slice(0, 12) }) const items = await list(input.dir, ancestorHash, log) - const result = await Promise.all(items.map((item) => load(input.dir, ancestorHash, item, log))) + const result = await Promise.all(items.map((item) => load(input.dir, ancestorHash, item))) log.info("diff complete", { totalFiles: result.length }) return result } diff --git a/packages/opencode/src/kilocode/server/server.ts b/packages/opencode/src/kilocode/server/server.ts index acc19ca827e..487892585e0 100644 --- a/packages/opencode/src/kilocode/server/server.ts +++ b/packages/opencode/src/kilocode/server/server.ts @@ -4,7 +4,6 @@ import { ModelCache } from "../../provider/model-cache" import { Instance } from "../../project/instance" -import { Log } from "../../util/log" /** Extra paths to skip request logging for */ export function skipLogging(path: string): boolean { @@ -27,39 +26,3 @@ export async function authChanged(providerID: string) { export const DOC_TITLE = "kilo" export const DOC_DESCRIPTION = "kilo api" - -// --------------------------------------------------------------------------- -// Idle instance eviction -// --------------------------------------------------------------------------- -// VS Code Agent Manager leaves one Instance alive per worktree for the whole -// session. Each Instance holds file watchers, LSP state, snapshot gitdir -// handles, DB connections, and PubSub queues. Without eviction these -// accumulate until the user closes VS Code — which is the main source of -// the multi-GB "kilo serve" RSS growth observed on Windows. -// -// The sweeper disposes any instance that hasn't served a request for -// IDLE_MS and has no in-flight work. The next request for that directory -// re-bootstraps from fresh state. - -const log = Log.create({ service: "instance-evictor" }) -const IDLE_MS = 10 * 60 * 1000 // 10 minutes -const SWEEP_MS = 60 * 1000 // check every minute - -const evictor = { timer: undefined as ReturnType | undefined } - -export function startIdleEviction() { - if (evictor.timer) return - evictor.timer = setInterval(() => { - Instance.evictIdle(IDLE_MS).catch((err) => { - log.error("evictIdle failed", { error: err instanceof Error ? err.message : String(err) }) - }) - }, SWEEP_MS) - evictor.timer.unref?.() - log.info("idle eviction started", { idleMs: IDLE_MS, sweepMs: SWEEP_MS }) -} - -export function stopIdleEviction() { - if (!evictor.timer) return - clearInterval(evictor.timer) - evictor.timer = undefined -} diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index f86c5edcd05..a0d6f2414a8 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -16,15 +16,6 @@ export interface InstanceContext { const context = Context.create("instance") const cache = new Map>() -// kilocode_change start - idle eviction tracking -// Tracks last-use time and active request count per cached instance so -// the idle eviction sweeper can dispose instances that have been quiet -// without killing one that still has an in-flight request (e.g. a -// running session). Both maps are keyed by the resolved directory. -const lastUsed = new Map() -const inflight = new Map() -// kilocode_change end - const disposal = { all: undefined as Promise | undefined, } @@ -85,20 +76,10 @@ export const Instance = { }), ) } - // kilocode_change start - track in-flight requests and last-use time - // so evictIdle() can dispose idle instances without racing live work. - inflight.set(directory, (inflight.get(directory) ?? 0) + 1) - lastUsed.set(directory, Date.now()) - try { - const ctx = await existing - return await context.provide(ctx, async () => { - return input.fn() - }) - } finally { - inflight.set(directory, Math.max(0, (inflight.get(directory) ?? 1) - 1)) - lastUsed.set(directory, Date.now()) - } - // kilocode_change end + const ctx = await existing + return context.provide(ctx, async () => { + return input.fn() + }) }, get current() { return context.use() @@ -149,8 +130,6 @@ export const Instance = { Log.Default.info("reloading instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) - lastUsed.delete(directory) // kilocode_change - inflight.delete(directory) // kilocode_change const next = track(directory, boot({ ...input, directory })) emit(directory) return await next @@ -160,8 +139,6 @@ export const Instance = { Log.Default.info("disposing instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) - lastUsed.delete(directory) // kilocode_change - inflight.delete(directory) // kilocode_change emit(directory) }, async disposeAll() { @@ -195,39 +172,4 @@ export const Instance = { return disposal.all }, - // kilocode_change start - idle eviction - /** - * Dispose instances that haven't been used for `idleMs` and have no - * in-flight requests. Releases file watchers, LSP, snapshot state, - * DB handles, and PubSub queues so the native allocator can actually - * return pages to the OS. The next request for that directory will - * re-bootstrap from scratch. - */ - async evictIdle(idleMs: number) { - const cutoff = Date.now() - idleMs - const stale: Array<[string, Promise]> = [] - for (const [dir, used] of lastUsed) { - if (used >= cutoff) continue - if ((inflight.get(dir) ?? 0) > 0) continue - const entry = cache.get(dir) - if (entry) stale.push([dir, entry]) - } - for (const [dir, entry] of stale) { - if (cache.get(dir) !== entry) continue - if ((inflight.get(dir) ?? 0) > 0) continue - const ctx = await entry.catch(() => undefined) - if (!ctx) { - if (cache.get(dir) === entry) cache.delete(dir) - lastUsed.delete(dir) - inflight.delete(dir) - continue - } - Log.Default.info("evicting idle instance", { directory: dir, idleMs }) - await context.provide(ctx, async () => { - await Instance.dispose() - }) - } - return stale.length - }, - // kilocode_change end } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 746ad7dfe5a..383294419de 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -312,7 +312,6 @@ export namespace Server { }) const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port) - KiloServer.startIdleEviction() // kilocode_change - release idle worktree instances const addr = server.address() if (!addr || typeof addr === "string") { throw new Error(`Failed to resolve server address for port ${opts.port}`) @@ -342,7 +341,6 @@ export namespace Server { url: next, stop(close?: boolean) { closing ??= new Promise((resolve, reject) => { - KiloServer.stopIdleEviction() // kilocode_change if (mdns) MDNS.unpublish() server.close((err) => { if (err) { diff --git a/packages/opencode/test/kilocode/project/instance-evict.test.ts b/packages/opencode/test/kilocode/project/instance-evict.test.ts deleted file mode 100644 index db47ff527d3..00000000000 --- a/packages/opencode/test/kilocode/project/instance-evict.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Tests for Instance.evictIdle() — the idle eviction sweeper. - * - * PR #9046 disposed Instance contexts only when a worktree was deleted, - * but long-lived Agent Manager sessions don't delete their worktrees. - * Every active worktree's Instance held file watchers, LSP state, - * snapshot handles, and PubSub queues forever — the main source of - * native RSS growth observed in the kilo.DMP memory report. - * - * This test exercises the eviction path with short idle thresholds so - * the cache can be verified to release entries and re-bootstrap on the - * next request. - */ - -import { describe, test, expect } from "bun:test" -import { Instance } from "../../../src/project/instance" -import { tmpdir } from "../../fixture/fixture" - -describe("Instance.evictIdle", () => { - test("disposes instances older than the cutoff and preserves fresh ones", async () => { - await using a = await tmpdir({ git: true }) - await using b = await tmpdir({ git: true }) - - const inits: string[] = [] - const bootA = () => { - inits.push("a") - return Promise.resolve() - } - const bootB = () => { - inits.push("b") - return Promise.resolve() - } - - await Instance.provide({ directory: a.path, init: bootA, fn: async () => undefined }) - await Instance.provide({ directory: b.path, init: bootB, fn: async () => undefined }) - expect(inits).toEqual(["a", "b"]) - - // Let a become idle while b is kept warm with a second touch. - await new Promise((r) => setTimeout(r, 50)) - await Instance.provide({ directory: b.path, init: bootB, fn: async () => undefined }) - - // Threshold sits between the two lastUsed timestamps so only a is evicted. - const evicted = await Instance.evictIdle(30) - expect(evicted).toBeGreaterThanOrEqual(1) - - // a is gone — next provide re-runs init. b is cached — init does not run. - await Instance.provide({ directory: a.path, init: bootA, fn: async () => undefined }) - await Instance.provide({ directory: b.path, init: bootB, fn: async () => undefined }) - expect(inits.filter((x) => x === "a")).toHaveLength(2) - expect(inits.filter((x) => x === "b")).toHaveLength(1) - - await Instance.disposeAll() - }, 30_000) - - test("skips instances with in-flight requests regardless of age", async () => { - await using t = await tmpdir({ git: true }) - - // Hold provide() open so the in-flight counter stays > 0. - const release = Promise.withResolvers() - const running = Instance.provide({ - directory: t.path, - fn: async () => { - await release.promise - }, - }) - - // Give the request a moment to register then evict with a zero cutoff - // which would evict every cache entry if in-flight were ignored. - await new Promise((r) => setTimeout(r, 50)) - await Instance.evictIdle(0) - - release.resolve() - await running - - // The cache entry survived — provide() with no init callback returns - // without re-bootstrapping. We verify by measuring that a brand new - // init callback is NOT invoked. - const inits: string[] = [] - await Instance.provide({ - directory: t.path, - init: () => { - inits.push("x") - return Promise.resolve() - }, - fn: async () => undefined, - }) - expect(inits).toHaveLength(0) - - await Instance.disposeAll() - }, 30_000) - - test("returns 0 when nothing is idle", async () => { - await using t = await tmpdir({ git: true }) - await Instance.provide({ directory: t.path, fn: async () => undefined }) - - const evicted = await Instance.evictIdle(60_000) - expect(evicted).toBe(0) - - await Instance.disposeAll() - }, 30_000) -}) diff --git a/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts deleted file mode 100644 index 5badec5340f..00000000000 --- a/packages/opencode/test/kilocode/review/worktree-diff-buffer.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Tests for the git() buffer-capped subprocess helper in worktree-diff.ts. - * - * Verifies that: - * - Output exceeding MAX_STDOUT is truncated (not accumulated unboundedly) - * - Truncated results don't crash downstream parsing - * - windowsHide is set on spawned processes - * - readBefore respects the per-file 1 MB limit - */ - -import { describe, test, expect } from "bun:test" -import fs from "node:fs" -import path from "node:path" - -const SRC = path.resolve(import.meta.dir, "../../../src/kilocode/review/worktree-diff.ts") - -describe("worktree-diff buffer caps", () => { - const src = fs.readFileSync(SRC, "utf-8") - - test("MAX_STDOUT is defined and <= 10 MB", () => { - const match = src.match(/const MAX_STDOUT\s*=\s*(.+)/) - expect(match).toBeTruthy() - // Evaluate: 10 * 1024 * 1024 = 10485760 - expect(src).toContain("10 * 1024 * 1024") - }) - - test("MAX_FILE_STDOUT is defined and <= 1 MB", () => { - const match = src.match(/const MAX_FILE_STDOUT\s*=\s*(.+)/) - expect(match).toBeTruthy() - expect(src).toContain("1 * 1024 * 1024") - }) - - test("git() helper sets windowsHide: true", () => { - // Find the git() function and verify windowsHide - const fnStart = src.indexOf("async function git(") - expect(fnStart).toBeGreaterThan(-1) - const fnBody = src.slice(fnStart, fnStart + 600) - expect(fnBody).toContain("windowsHide: true") - }) - - test("git() helper uses Bun.spawn (not $ template)", () => { - // The file should not import $ from bun - expect(src).not.toContain('import { $ } from "bun"') - // Should use Bun.spawn - const fnStart = src.indexOf("async function git(") - const fnBody = src.slice(fnStart, fnStart + 600) - expect(fnBody).toContain("Bun.spawn") - }) - - test("git() helper drains pipe after truncation", () => { - const fnStart = src.indexOf("async function git(") - const fnBody = src.slice(fnStart, fnStart + 1600) - // After setting truncated=true, the loop must continue reading (drain) - expect(fnBody).toContain("if (truncated) continue") - }) - - test("git() helper consumes stderr to prevent pipe blocking", () => { - const fnStart = src.indexOf("async function git(") - const fnBody = src.slice(fnStart, fnStart + 1600) - expect(fnBody).toContain("proc.stderr") - }) - - test("readBefore uses MAX_FILE_STDOUT limit", () => { - const fnStart = src.indexOf("async function readBefore(") - expect(fnStart).toBeGreaterThan(-1) - const fnBody = src.slice(fnStart, fnStart + 300) - expect(fnBody).toContain("MAX_FILE_STDOUT") - }) - - test("no $ template git calls remain in the file", () => { - // All git commands should go through the git() helper now - // Match the Bun shell template pattern: $`git ...` - const templateCalls = src.match(/\$`git\s/g) - expect(templateCalls).toBeNull() - }) -}) diff --git a/packages/opencode/test/kilocode/review/worktree-diff-cache.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-cache.test.ts deleted file mode 100644 index d9bfac5aaf8..00000000000 --- a/packages/opencode/test/kilocode/review/worktree-diff-cache.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Tests for merge-base caching in worktree-diff.ts. - * - * Verifies that: - * - The cache exists and has a reasonable TTL - * - clearCache() is exported and functional - * - Different dir/base combinations use separate cache keys - */ - -import { describe, test, expect } from "bun:test" -import fs from "node:fs" -import path from "node:path" - -const SRC = path.resolve(import.meta.dir, "../../../src/kilocode/review/worktree-diff.ts") - -describe("worktree-diff merge-base cache", () => { - const src = fs.readFileSync(SRC, "utf-8") - - test("ancestors cache is a Map with TTL tracking", () => { - expect(src).toContain("const ancestors = new Map") - expect(src).toContain("expires") - }) - - test("ANCESTOR_TTL is defined and >= 10 seconds", () => { - const match = src.match(/const ANCESTOR_TTL\s*=\s*([\d_]+)/) - expect(match).toBeTruthy() - const ttl = Number(match![1]!.replace(/_/g, "")) - expect(ttl).toBeGreaterThanOrEqual(10_000) - }) - - test("ancestor() checks cache before spawning git", () => { - const fnStart = src.indexOf("async function ancestor(") - expect(fnStart).toBeGreaterThan(-1) - const fnBody = src.slice(fnStart, fnStart + 600) - // Must check cache before calling git() - const cacheCheck = fnBody.indexOf("ancestors.get(") - const gitCall = fnBody.indexOf('git(["merge-base"') - expect(cacheCheck, "cache lookup must exist").toBeGreaterThan(-1) - expect(gitCall, "git call must exist").toBeGreaterThan(-1) - expect(cacheCheck, "cache lookup must come before git call").toBeLessThan(gitCall) - }) - - test("ancestor() stores result in cache after successful git call", () => { - const fnStart = src.indexOf("async function ancestor(") - const fnBody = src.slice(fnStart, fnStart + 600) - expect(fnBody).toContain("ancestors.set(") - expect(fnBody).toContain("ANCESTOR_TTL") - }) - - test("cache key uses dir and base to avoid collisions", () => { - const fnStart = src.indexOf("async function ancestor(") - const fnBody = src.slice(fnStart, fnStart + 600) - // Key should incorporate both dir and base - expect(fnBody).toMatch(/`\$\{dir\}.*\$\{base\}`/) - }) - - test("clearCache() is exported", () => { - expect(src).toContain("export function clearCache()") - // Must clear the ancestors map - const fnStart = src.indexOf("export function clearCache()") - const fnBody = src.slice(fnStart, fnStart + 100) - expect(fnBody).toContain("ancestors.clear()") - }) -}) diff --git a/packages/opencode/test/kilocode/review/worktree-diff-memory.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-memory.test.ts deleted file mode 100644 index 21bbaffc96d..00000000000 --- a/packages/opencode/test/kilocode/review/worktree-diff-memory.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Memory regression test for WorktreeDiff.summary(). - * - * Runs summary() in a loop and asserts RSS doesn't grow beyond a threshold. - * This catches regressions where git output buffering changes could - * re-introduce unbounded native memory growth. - * - * Uses the kilocode repo itself as the test fixture — it always has diffs - * available against the default branch. - */ - -import { describe, test, expect, afterEach } from "bun:test" -import path from "node:path" -import { WorktreeDiff } from "@/kilocode/review/worktree-diff" - -const REPO = path.resolve(import.meta.dir, "../../../../..") -const ITERATIONS = 30 -// Generous margin: mimalloc retains 64 MB segments and this repo has a large -// diff surface. The test guards against catastrophic regressions (multi-GB -// leaks), not tight bounds. Pre-fix behavior was 6+ GB; post-fix should stay -// well under 1 GB even on large repos. -const MAX_GROWTH_MB = 512 - -describe("worktree-diff memory", () => { - afterEach(() => { - WorktreeDiff.clearCache() - }) - - test( - "summary() does not leak memory over repeated calls", - async () => { - // Resolve a base branch that exists in this repo - const base = await resolveBase() - if (!base) { - console.log("Skipping memory test: no suitable base branch found") - return - } - - // Force GC and take baseline - Bun.gc(true) - const baseline = process.memoryUsage().rss - - for (let i = 0; i < ITERATIONS; i++) { - await WorktreeDiff.summary({ dir: REPO, base }) - } - - // Force GC and measure - Bun.gc(true) - const after = process.memoryUsage().rss - const growth = (after - baseline) / 1024 / 1024 - - console.log( - `Memory: baseline=${(baseline / 1024 / 1024).toFixed(1)} MB, after=${(after / 1024 / 1024).toFixed(1)} MB, growth=${growth.toFixed(1)} MB`, - ) - - expect(growth).toBeLessThan(MAX_GROWTH_MB) - }, - { timeout: 120_000 }, - ) -}) - -/** Find a base branch that exists in the repo (main or master). */ -async function resolveBase(): Promise { - for (const branch of ["main", "master", "origin/main", "origin/master"]) { - const proc = Bun.spawnSync(["git", "rev-parse", "--verify", branch], { - cwd: REPO, - stdout: "pipe", - stderr: "pipe", - windowsHide: true, - }) - if (proc.exitCode === 0) return branch - } - return undefined -} diff --git a/packages/opencode/test/kilocode/review/worktree-diff-stream.test.ts b/packages/opencode/test/kilocode/review/worktree-diff-stream.test.ts deleted file mode 100644 index c103645fdf1..00000000000 --- a/packages/opencode/test/kilocode/review/worktree-diff-stream.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Tests for the single-allocation git() output helper. - * - * Where the old helper copied every pipe chunk into a Buffer and then - * concat+toString'd them, the new helper references the chunks directly - * and collapses to one Uint8Array + one decode at the end. This catches - * regressions that would re-introduce the per-chunk Buffer.from/concat - * pattern (the actual source of mimalloc arena retention in the PR #9046 - * memory report). - */ - -import { describe, test, expect } from "bun:test" -import fs from "node:fs" -import path from "node:path" - -const SRC = path.resolve(import.meta.dir, "../../../src/kilocode/review/worktree-diff.ts") - -describe("worktree-diff stream helper", () => { - const src = fs.readFileSync(SRC, "utf-8") - - test("no per-chunk Buffer.from copy", () => { - // The old helper did `chunks.push(Buffer.from(value))` which allocates - // once per chunk. Each small allocation is what mimalloc retains - // forever in its arenas. - expect(src).not.toContain("Buffer.from(value)") - }) - - test("no Buffer.concat in git helper", () => { - const start = src.indexOf("async function git(") - expect(start).toBeGreaterThan(-1) - const end = src.indexOf("\nfunction ", start) - const body = src.slice(start, end > 0 ? end : start + 2000) - expect(body).not.toContain("Buffer.concat") - }) - - test("chunks are Uint8Array, not Buffer", () => { - const start = src.indexOf("async function git(") - const body = src.slice(start, start + 1200) - expect(body).toContain("Uint8Array[]") - expect(body).not.toContain("chunks: Buffer[]") - }) - - test("decoder is reused across calls", () => { - expect(src).toContain("const decoder = new TextDecoder()") - // The only decode() call should go through the shared instance. - const inlineDecoder = src.match(/new TextDecoder\(\)\.decode/g) - expect(inlineDecoder ?? []).toHaveLength(0) - }) - - test("bounded fast path when output fits in one chunk", () => { - const joinStart = src.indexOf("function join(") - expect(joinStart).toBeGreaterThan(-1) - const body = src.slice(joinStart, joinStart + 400) - // Single-chunk fast path avoids even the intermediate buffer. - expect(body).toContain("chunks.length === 1") - }) -}) From 81173e3e803af01ac5d0e72bb6081b4c734c72c7 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 15:06:02 +0300 Subject: [PATCH 19/35] fix(vscode): set MIMALLOC_PURGE_DELAY=0 on spawned kilo serve Workaround for upstream Bun memory leak oven-sh/bun#18265: Bun.spawn with piped stdio accumulates ~2 MB of native RSS per call on Windows because mimalloc retains freed pages in its arenas instead of returning them to the OS. The Agent Manager polls git once per second per worktree via the CLI, so a few minutes of use reaches multi-GB RSS. Jarred (Bun) confirmed the workaround in oven-sh/bun#21560: setting MIMALLOC_PURGE_DELAY=0 forces immediate page return, greatly reducing the RSS growth. Applied only to the kilo serve process spawned by the VS Code extension - no other code changes. --- .../kilo-vscode/src/services/cli-backend/server-manager.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index ab8ba672df4..d4e79da1b51 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -70,6 +70,13 @@ export class ServerManager { const serverProcess = spawn(cliPath, ["serve", "--port", "0"], { env: { ...process.env, + // Force mimalloc (the allocator Bun ships with) to return freed pages + // to the OS immediately instead of retaining them in its arenas. + // Without this, Bun.spawn's piped stdio accumulates ~2 MB of native + // RSS per call on Windows, causing the Agent Manager (which polls git + // once per second per worktree) to reach multi-GB RSS in minutes. + // See oven-sh/bun#18265 and Jarred's workaround note in #21560. + MIMALLOC_PURGE_DELAY: "0", KILO_SERVER_PASSWORD: password, KILO_CLIENT: "vscode", KILO_ENABLE_QUESTION_TOOL: "true", From c8fd4218236afb7d9f525ca667ddf53734c47d4a Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 14:12:19 +0200 Subject: [PATCH 20/35] fix(vscode): restore sidebar diff viewer parity with Agent Manager (#9121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(vscode): restore sidebar diff viewer parity with Agent Manager The sidebar diff viewer bundle was missing agent-manager-review.css, so FullScreenDiffView rendered with a broken file tree (no flex layout, no revert styles) since PR #7455. Co-locate the CSS imports with the component so every consumer gets them automatically, and wire per-file revert end-to-end via a shared WorktreeDiffClient used by both the sidebar provider and the Agent Manager controller. * fix(cli): skip flaky shell-completion test on Windows CI The 'shell completion resumes queued loop callers' test relies on shell process spawn timing that is unreliable on Windows CI and times out at 3s. Every other shell-process test in this file already uses the existing `unix(...)` helper for the same reason — align this test with that pattern. --- .changeset/diff-viewer-parity.md | 5 ++ .../kilo-vscode/src/DiffViewerProvider.ts | 41 +++++++++++++- .../agent-manager/worktree-diff-controller.ts | 22 ++------ .../kilo-vscode/src/worktree-diff-client.ts | 54 +++++++++++++++++++ .../tests/unit/diff-viewer-css-arch.test.ts | 42 +++++++++++++++ .../agent-manager/FullScreenDiffView.tsx | 5 ++ .../webview-ui/diff-viewer/DiffViewerApp.tsx | 20 +++++++ .../webview-ui/src/types/messages.ts | 8 +++ .../test/session/prompt-effect.test.ts | 5 +- 9 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 .changeset/diff-viewer-parity.md create mode 100644 packages/kilo-vscode/src/worktree-diff-client.ts create mode 100644 packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts diff --git a/.changeset/diff-viewer-parity.md b/.changeset/diff-viewer-parity.md new file mode 100644 index 00000000000..bfc5040b863 --- /dev/null +++ b/.changeset/diff-viewer-parity.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix the sidebar "Show Changes" diff viewer: the file tree now renders correctly (previously the file rows were cramped onto a single line due to missing styles), and per-file revert buttons are available, matching the Agent Manager. diff --git a/packages/kilo-vscode/src/DiffViewerProvider.ts b/packages/kilo-vscode/src/DiffViewerProvider.ts index 84196113351..a26b71a18a7 100644 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/DiffViewerProvider.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "./services/cli-backend" import { buildWebviewHtml } from "./utils" import { GitOps } from "./agent-manager/GitOps" +import { WorktreeDiffClient, type DiffTarget } from "./worktree-diff-client" import { appendOutput, getWorkspaceRoot, @@ -20,7 +21,7 @@ export class DiffViewerProvider implements vscode.Disposable { private panel: vscode.WebviewPanel | undefined private diffInterval: ReturnType | undefined private lastDiffHash: string | undefined - private cachedDiffTarget: { directory: string; baseBranch: string } | undefined + private cachedDiffTarget: DiffTarget | undefined private gitOps: GitOps private outputChannel: vscode.OutputChannel private onSendComments: ((comments: unknown[], autoSend: boolean) => void) | undefined @@ -107,12 +108,48 @@ export class DiffViewerProvider implements vscode.Disposable { return } + if (type === "diffViewer.revertFile" && typeof msg.file === "string") { + void this.revertFile(msg.file) + return + } + if (type === "openFile" && typeof msg.filePath === "string") { openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) } } - private async resolveLocalDiffTarget(): Promise<{ directory: string; baseBranch: string } | undefined> { + private async revertFile(file: string): Promise { + const target = this.cachedDiffTarget ?? (await this.resolveLocalDiffTarget()) + if (!target) { + this.post({ + type: "diffViewer.revertFileResult", + file, + status: "error", + message: "Could not resolve diff target", + }) + return + } + + try { + const diff = new WorktreeDiffClient(this.connectionService.getClient(), this.gitOps, (...args) => + this.log(...args), + ) + const result = await diff.revertFile(target, file) + this.post({ + type: "diffViewer.revertFileResult", + file, + status: result.ok ? "success" : "error", + message: result.message, + }) + if (result.ok) void this.pollDiff() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.log("Failed to revert file:", message) + this.post({ type: "diffViewer.revertFileResult", file, status: "error", message }) + } + } + + private async resolveLocalDiffTarget(): Promise { return await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), getWorkspaceRoot()) } diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index a4ec1dabbd9..1202ce92a21 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,5 +1,6 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils" +import { WorktreeDiffClient } from "../worktree-diff-client" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" @@ -8,7 +9,6 @@ import type { AgentManagerOutMessage } from "./types" const LOCAL_DIFF_ID = "local" as const type Target = { sessionId: string; directory: string; baseBranch: string } -type Status = "added" | "deleted" | "modified" export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined @@ -111,12 +111,8 @@ export class WorktreeDiffController { } try { - const result = await this.ctx.git.revertFile( - target.directory, - target.baseBranch, - file, - await this.status(target, file), - ) + const diff = new WorktreeDiffClient(this.ctx.getClient(), this.ctx.git, (...args) => this.ctx.log(...args)) + const result = await diff.revertFile(target, file) this.ctx.post({ type: "agentManager.revertWorktreeFileResult", sessionId, @@ -266,18 +262,6 @@ export class WorktreeDiffController { return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot()) } - private async status(target: { directory: string; baseBranch: string }, file: string): Promise { - try { - const { data } = await this.ctx - .getClient() - .worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true }) - return data?.status - } catch (error) { - this.ctx.log("Failed to look up file status for revert:", error) - return undefined - } - } - private async ready(msg: string): Promise { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } diff --git a/packages/kilo-vscode/src/worktree-diff-client.ts b/packages/kilo-vscode/src/worktree-diff-client.ts new file mode 100644 index 00000000000..0b8ecce7df6 --- /dev/null +++ b/packages/kilo-vscode/src/worktree-diff-client.ts @@ -0,0 +1,54 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { GitOps } from "./agent-manager/GitOps" + +/** + * A worktree diff target: the working directory and the base branch we diff + * against (usually the tracking branch). + */ +export type DiffTarget = { directory: string; baseBranch: string } + +type Status = "added" | "deleted" | "modified" + +/** + * Thin coordinator that wraps (KiloClient, GitOps, DiffTarget) and exposes the + * small set of operations used by both the sidebar DiffViewerProvider and the + * agent manager's WorktreeDiffController. + * + * Keeping the helper off review-utils.ts: this deals in HTTP + git orchestration, + * not the small path/vscode helpers that file is scoped to. + */ +export class WorktreeDiffClient { + constructor( + private readonly client: KiloClient, + private readonly git: GitOps, + private readonly log: (...args: unknown[]) => void, + ) {} + + /** + * Look up the diff status for a single file. Used by revert flows to pick + * the right git strategy (added → delete, modified/deleted → checkout). + * Returns `undefined` on error so callers can still attempt a best-effort + * revert — `GitOps.revertFile` defaults to a modified-file strategy. + */ + async fileStatus(target: DiffTarget, file: string): Promise { + try { + const { data } = await this.client.worktree.diffFile( + { directory: target.directory, base: target.baseBranch, file }, + { throwOnError: true }, + ) + return data?.status + } catch (err) { + this.log("Failed to look up file status for revert:", err) + return undefined + } + } + + /** + * Revert a single file in the worktree. Composes `fileStatus` + `GitOps.revertFile`. + * Returns a normalized result; callers handle UI/messaging. + */ + async revertFile(target: DiffTarget, file: string): Promise<{ ok: boolean; message: string }> { + const status = await this.fileStatus(target, file) + return this.git.revertFile(target.directory, target.baseBranch, file, status) + } +} diff --git a/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts new file mode 100644 index 00000000000..ce6614c519b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts @@ -0,0 +1,42 @@ +/** + * Architecture test: FullScreenDiffView CSS co-location. + * + * `FullScreenDiffView` and its children (`FileTree`, etc.) rely on classes + * defined in BOTH `agent-manager.css` and `agent-manager-review.css`. The + * component is shared by multiple webview bundles (sidebar diff viewer, + * agent manager, storybook). Historically, each bundle was responsible for + * importing its own CSS, which led to regressions when someone forgot to + * wire the review stylesheet into a new entry point (see PR #7455 fallout). + * + * Current invariant: `FullScreenDiffView.tsx` imports both stylesheets at the + * top of the file, so any bundle pulling in the component transitively gets + * the styles via esbuild's CSS bundling. + * + * If this test fails, do NOT move the CSS imports elsewhere — fix the + * component file to import the missing stylesheet, or add a new stylesheet + * to the REQUIRED list if you intentionally split the styles. + */ + +import { describe, it, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const FULL_SCREEN_DIFF_VIEW = path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx") +const REQUIRED = ["./agent-manager.css", "./agent-manager-review.css"] as const + +describe("FullScreenDiffView — CSS co-location", () => { + it("imports every stylesheet required to render correctly", () => { + const src = fs.readFileSync(FULL_SCREEN_DIFF_VIEW, "utf-8") + const missing = REQUIRED.filter((css) => !src.includes(`import "${css}"`)) + + expect( + missing, + `FullScreenDiffView is missing required CSS imports:\n` + + missing.map((m) => ` - import "${m}"`).join("\n") + + `\n\nAdd them at the top of FullScreenDiffView.tsx. The component is\n` + + `shared by multiple webview bundles (sidebar diff viewer, agent manager,\n` + + `storybook) and every bundle relies on these imports for complete styling.\n`, + ).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index 0870c1c4b9e..bba856b6a9a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -1,4 +1,9 @@ import { type Component, createSignal, createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" +// Styles are co-located with the component so every consumer (sidebar diff viewer, +// agent manager, storybook) picks them up automatically. Do not move these out — +// see tests/unit/diff-viewer-css-arch.test.ts for the invariant. +import "./agent-manager.css" +import "./agent-manager-review.css" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header" diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index e8aecd76bad..d009445202e 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -26,6 +26,16 @@ const DiffViewerContent: Component = () => { const [loading, setLoading] = createSignal(true) const [comments, setComments] = createSignal([]) const [diffStyle, setDiffStyle] = createSignal("unified") + const [reverting, setReverting] = createSignal>(new Set()) + + const markReverting = (file: string, active: boolean) => { + setReverting((prev) => { + const next = new Set(prev) + if (active) next.add(file) + else next.delete(file) + return next + }) + } const unsubscribe = vscode.onMessage((msg) => { if (msg.type === "diffViewer.diffs") { @@ -37,6 +47,11 @@ const DiffViewerContent: Component = () => { setLoading(msg.loading) return } + + if (msg.type === "diffViewer.revertFileResult") { + markReverting(msg.file, false) + return + } }) const handler = (event: MessageEvent) => { @@ -67,6 +82,11 @@ const DiffViewerContent: Component = () => { onOpenFile={(relativePath) => { post({ type: "openFile", filePath: relativePath }) }} + onRevertFile={(file) => { + markReverting(file, true) + post({ type: "diffViewer.revertFile", file }) + }} + revertingFiles={reverting()} onClose={() => { post({ type: "diffViewer.close" }) }} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 1e6c18dc35f..6ddbcda6842 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -1403,6 +1403,13 @@ export interface DiffViewerLoadingMessage { loading: boolean } +export interface DiffViewerRevertFileResultMessage { + type: "diffViewer.revertFileResult" + file: string + status: "success" | "error" + message: string +} + export interface ClearPendingPromptsMessage { type: "clearPendingPrompts" } @@ -1598,6 +1605,7 @@ export type ExtensionMessage = | ViewSubAgentSessionMessage | DiffViewerDiffsMessage | DiffViewerLoadingMessage + | DiffViewerRevertFileResultMessage | MarketplaceDataMessage | MarketplaceInstallResultMessage | MarketplaceRemoveResultMessage diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 8e496f60e3b..b5701aeb910 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1160,7 +1160,10 @@ it.live( 3_000, ) -it.live( +// kilocode_change start - shell process timing is unreliable on Windows CI; +// aligns with every other shell-* test in this file that uses `unix(...)`. +unix( + // kilocode_change end "shell completion resumes queued loop callers", () => provideTmpdirServer( From 2ba069350de8cd74ce24fb7fc8c773dd73b6fbbd Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:14:46 +0000 Subject: [PATCH 21/35] docs(kilo-docs): note that kilo-auto underlying models can change --- packages/kilo-docs/pages/code-with-ai/agents/auto-model.md | 4 ++++ packages/kilo-docs/pages/gateway/models-and-providers.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index ac7bc6854c8..7cf19caf359 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -23,6 +23,10 @@ That's it. No configuration needed. You can see which underlying models are used, as well as the cost, in the expanded model picker. Model mapping information is also available on the [Gateway Model page](/docs/gateway/models-and-providers#kilo-autofrontier). +{% callout type="info" title="Models can change" %} +The underlying models behind each Auto Model tier are updated server-side as better options become available or as providers change pricing and availability. The tier you select stays the same; the model it routes to may change over time. +{% /callout %} + ## Tiers - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 63cff2caef1..2eb81138698 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -76,6 +76,10 @@ Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia Kilo Auto virtual models automatically select the best underlying model based on the task type. The selection is controlled by the `x-kilocode-mode` request header. +{% callout type="info" title="Underlying models can change" %} +The mappings below reflect the current routing. The underlying models behind each `kilo-auto/*` tier are updated server-side as better options become available or as providers change pricing and availability — the tier IDs themselves remain stable. +{% /callout %} + ### `kilo-auto/frontier` Highest performance and capability for any task. Frontier requests are sent with medium reasoning effort and medium verbosity. From 3895139f5ca11db6236492721a961873ee85c384 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:16:09 +0000 Subject: [PATCH 22/35] chore: add dependabot[bot] to team list dependabot[bot] opens PRs regularly; suppress the 'Thanks' line in release notes the same way we do for other team bots. --- packages/script/src/index.ts | 1 + script/changelog-github.cjs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index c6109f80a34..99402538cb3 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -118,6 +118,7 @@ const team = [ "chrarnoldus", "codingelves", "darkogj", + "dependabot[bot]", "dosire", "DScdng", "emilieschario", diff --git a/script/changelog-github.cjs b/script/changelog-github.cjs index f0aecd0e21d..a8301cf68fc 100644 --- a/script/changelog-github.cjs +++ b/script/changelog-github.cjs @@ -16,6 +16,7 @@ const team = new Set([ "chrarnoldus", "codingelves", "darkogj", + "dependabot[bot]", "dosire", "DScdng", "emilieschario", From 96bd75067a4c414afb8aaedbd3d49cddb37902e1 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:29:16 +0000 Subject: [PATCH 23/35] fix: suppress 'Thanks' credit for kilo-code-bot app in changelog @changesets/changelog-github renders GitHub App credits with the app slug (e.g. [@kilo-code-bot](https://github.com/apps/kilo-code-bot)), not the '[bot]'-suffixed commit author login. v7.2.12 leaked a 'Thanks @kilo-code-bot!' line because the team set only contained 'kilo-code-bot[bot]'. Add the bare slug. --- packages/script/src/index.ts | 1 + script/changelog-github.cjs | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 99402538cb3..7ee6d5e2e7e 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -132,6 +132,7 @@ const team = [ "alex-alecu", "imanolmzd-svg", "kilocode-bot", + "kilo-code-bot", "kilo-code-bot[bot]", "kirillk", "lambertjosh", diff --git a/script/changelog-github.cjs b/script/changelog-github.cjs index a8301cf68fc..7669e589e8f 100644 --- a/script/changelog-github.cjs +++ b/script/changelog-github.cjs @@ -30,6 +30,7 @@ const team = new Set([ "alex-alecu", "imanolmzd-svg", "kilocode-bot", + "kilo-code-bot", "kilo-code-bot[bot]", "kirillk", "lambertjosh", From d02a658db19ef41e05989ff4aba59ea8d8949ac5 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 16:38:27 +0300 Subject: [PATCH 24/35] fix(vscode): move Agent Manager git polling into extension host Runs the diff summary and detail computations in-process using the existing GitOps child_process.spawn path instead of routing through kilo serve over HTTP. This avoids the Bun spawn native-memory leak on Windows (oven-sh/bun#18265) that was driving kilo serve RSS into the multi-GB range within minutes of opening the Agent Manager. --- packages/kilo-vscode/src/KiloProvider.ts | 3 +- .../src/agent-manager/AgentManagerProvider.ts | 6 +- .../kilo-vscode/src/agent-manager/GitOps.ts | 13 +- .../src/agent-manager/GitStatsPoller.ts | 63 ++-- .../src/agent-manager/local-diff.ts | 352 ++++++++++++++++++ .../kilo-vscode/src/agent-manager/types.ts | 2 + .../agent-manager/worktree-diff-controller.ts | 30 +- .../tests/unit/git-stats-poller.test.ts | 182 ++++----- .../kilo-vscode/tests/unit/local-diff.test.ts | 211 +++++++++++ 9 files changed, 712 insertions(+), 150 deletions(-) create mode 100644 packages/kilo-vscode/src/agent-manager/local-diff.ts create mode 100644 packages/kilo-vscode/tests/unit/local-diff.test.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index d7c66637e03..a658837c2ef 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -37,6 +37,7 @@ import { } from "./kilo-provider-utils" import { GitOps } from "./agent-manager/GitOps" import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller" +import { diffSummary as localDiffSummary } from "./agent-manager/local-diff" import { getWorkspaceRoot } from "./review-utils" import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace" import type { RemoteStatusService } from "./services/RemoteStatusService" @@ -3282,7 +3283,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => getWorkspaceRoot(), - getClient: () => this.connectionService.getClient(), + localDiff: (dir, base) => localDiffSummary(git, dir, base), git, onStats: () => {}, onLocalStats: (stats: LocalStats) => { diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d9ffd42f01d..9e1dac738f4 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -26,6 +26,7 @@ import { forkSession } from "./fork-session" import { continueInWorktree } from "./continue-in-worktree" import { WorktreeDiffController } from "./worktree-diff-controller" import { WorktreeImporter } from "./worktree-importer" +import { diffSummary as localDiffSummary, diffFile as localDiffFile } from "./local-diff" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" @@ -102,15 +103,16 @@ export class AgentManagerProvider implements Disposable { getState: () => this.getStateManager(), getRoot: () => this.getRoot(), getStateReady: () => this.stateReady, - getClient: () => this.connectionService.getClient(), git: this.gitOps, + localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)), + localDiffFile: (dir, base, file) => localDiffFile(this.gitOps, dir, base, file, (...args) => this.log(...args)), post: (msg) => this.postToWebview(msg), log: (...args) => this.log(...args), }) this.statsPoller = new GitStatsPoller({ getWorktrees: () => this.state?.getWorktrees() ?? [], getWorkspaceRoot: () => this.getRoot(), - getClient: () => this.connectionService.getClient(), + localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)), semaphore, onStats: (stats) => { const msg = { type: "agentManager.worktreeStats" as const, stats } diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 66c74cb464b..f835cf3832c 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -36,7 +36,7 @@ interface ExecOptions { stdin?: string } -interface ExecResult { +export interface ExecResult { code: number stdout: string stderr: string @@ -473,6 +473,17 @@ export class GitOps { return [{ reason: "Patch does not apply cleanly" }] } + /** + * Run a git command returning `{code, stdout, stderr}`. Gated by the shared + * semaphore and respects the dispose abort signal. Never throws — commands + * with non-zero exit codes resolve normally (nothrow semantics), making this + * suitable for callers that need to tolerate legitimate failures (e.g. + * `merge-base` on an orphan branch, `ls-files --error-unmatch`). + */ + execGit(args: string[], cwd: string, options?: { stdin?: string }): Promise { + return this.exec(args, cwd, options) + } + private exec(args: string[], cwd: string, options?: ExecOptions): Promise { if (this.controller.signal.aborted) { return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" }) diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 30a79b827c5..6b08f9f6ba2 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -1,10 +1,10 @@ import * as fs from "fs" import * as path from "path" -import type { KiloClient, SnapshotFileDiff } from "@kilocode/sdk/v2/client" import { remoteRef, type Worktree } from "./WorktreeStateManager" import type { GitOps } from "./GitOps" import type { Semaphore } from "./semaphore" import { normalizePath } from "./git-import" +import type { WorktreeDiffEntry } from "./types" export interface WorktreeStats { worktreeId: string @@ -39,7 +39,12 @@ export interface WorktreePresenceResult { interface GitStatsPollerOptions { getWorktrees: () => Worktree[] getWorkspaceRoot: () => string | undefined - getClient: () => KiloClient + /** + * Compute diff summaries locally (in the extension host) rather than over + * HTTP to `kilo serve`. Keeps git spawning out of the Bun process, which + * leaks native memory on Windows (oven-sh/bun#18265). + */ + localDiff: (dir: string, base: string) => Promise git: GitOps onStats: (stats: WorktreeStats[]) => void onLocalStats: (stats: LocalStats) => void @@ -142,27 +147,16 @@ export class GitStatsPoller { } private async fetch(): Promise { - const client = (() => { - try { - return this.options.getClient() - } catch (err) { - this.options.log("Failed to get client for stats:", err) - return undefined - } - })() - - await Promise.all([this.fetchWorktreeStats(client), this.fetchLocalStats(client)]) + await Promise.all([this.fetchWorktreeStats(), this.fetchLocalStats()]) } - private async fetchWorktreeStats(client: KiloClient | undefined): Promise { + private async fetchWorktreeStats(): Promise { const worktrees = this.options.getWorktrees() if (worktrees.length === 0) return const presence = await this.probeWorktreePresence(worktrees) this.options.onWorktreePresence?.(presence) - if (!client) return - const missing = new Set( presence.degraded ? [] : presence.worktrees.filter((item) => item.missing).map((item) => item.worktreeId), ) @@ -181,23 +175,21 @@ export class GitStatsPoller { return } - // Gate the HTTP diffSummary call through the semaphore but NOT the - // aheadBehind call — that goes through GitOps.raw() which already - // acquires the same semaphore. Wrapping both would deadlock. - const gate = this.options.semaphore - const diff = (dir: string, base: string) => { - const invoke = () => client.worktree.diffSummary({ directory: dir, base }, { throwOnError: true }) - return gate ? gate.run(invoke) : invoke() - } + // localDiff runs in-process via GitOps.execGit() which already acquires + // the shared semaphore internally; same goes for aheadBehind via + // GitOps.raw(). Wrapping either again here would deadlock. const stats = ( await Promise.all( active.map(async (wt) => { try { const base = remoteRef(wt) - const [{ data: diffs }, ab] = await Promise.all([diff(wt.path, base), this.git.aheadBehind(wt.path, base)]) + const [diffs, ab] = await Promise.all([ + this.options.localDiff(wt.path, base), + this.git.aheadBehind(wt.path, base), + ]) const files = diffs.length - const additions = diffs.reduce((sum: number, diff: SnapshotFileDiff) => sum + diff.additions, 0) - const deletions = diffs.reduce((sum: number, diff: SnapshotFileDiff) => sum + diff.deletions, 0) + const additions = diffs.reduce((sum, diff) => sum + diff.additions, 0) + const deletions = diffs.reduce((sum, diff) => sum + diff.deletions, 0) return { worktreeId: wt.id, files, additions, deletions, ahead: ab.ahead, behind: ab.behind } } catch (err) { this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err) @@ -257,7 +249,7 @@ export class GitStatsPoller { return { worktrees: worktreeStatuses, degraded: false } } - private async fetchLocalStats(client: KiloClient | undefined): Promise { + private async fetchLocalStats(): Promise { const root = this.options.getWorkspaceRoot() if (!root) return @@ -274,21 +266,16 @@ export class GitStatsPoller { let ahead: number let behind: number try { - if (base && client) { - this.options.log(`Local stats: using HTTP client with base=${base}`) - const gate = this.options.semaphore - const invoke = () => client.worktree.diffSummary({ directory: root, base }, { throwOnError: true }) - const [{ data: diffs }, ab] = await Promise.all([ - gate ? gate.run(invoke) : invoke(), - this.git.aheadBehind(root, base), - ]) + if (base) { + this.options.log(`Local stats: using localDiff with base=${base}`) + const [diffs, ab] = await Promise.all([this.options.localDiff(root, base), this.git.aheadBehind(root, base)]) files = diffs.length - additions = diffs.reduce((sum: number, d: SnapshotFileDiff) => sum + d.additions, 0) - deletions = diffs.reduce((sum: number, d: SnapshotFileDiff) => sum + d.deletions, 0) + additions = diffs.reduce((sum, d) => sum + d.additions, 0) + deletions = diffs.reduce((sum, d) => sum + d.deletions, 0) ahead = ab.ahead behind = ab.behind } else { - this.options.log(`Local stats: fallback to workingTreeStats (base=${base ?? "none"} client=${!!client})`) + this.options.log(`Local stats: fallback to workingTreeStats (no base branch)`) const wt = await this.git.workingTreeStats(root) files = wt.files additions = wt.additions diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts new file mode 100644 index 00000000000..151a2943a75 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -0,0 +1,352 @@ +import * as fs from "fs/promises" +import * as path from "path" +import type { GitOps } from "./GitOps" +import type { WorktreeDiffEntry } from "./types" + +type Status = "added" | "deleted" | "modified" + +type Meta = { + file: string + additions: number + deletions: number + status: Status + tracked: boolean + generatedLike: boolean + stamp: string +} + +type Log = (...args: unknown[]) => void + +/** Cap untracked file reads so line-counting a multi-megabyte log file does + * not stall the poll. Matches `GitOps.workingTreeStats()`. */ +const MAX_UNTRACKED_BYTES = 1_000_000 + +/** + * Local, Node.js-side replacement for the server's `WorktreeDiff.summary()` and + * `WorktreeDiff.detail()` routes. Keeps Agent Manager polling out of the Bun + * `kilo serve` process, which leaks native memory on every `Bun.spawn` on + * Windows (oven-sh/bun#18265). + * + * All git calls go through `GitOps.execGit()` → `child_process.spawn` with + * `windowsHide: true` and the shared semaphore. No Bun involvement. + */ + +/** Ported from `packages/opencode/src/file/ignore.ts` — identical patterns, + * no runtime dependency on minimatch/picomatch. */ +const FOLDERS = new Set([ + "node_modules", + "bower_components", + ".pnpm-store", + "vendor", + ".npm", + "dist", + "build", + "out", + ".next", + "target", + "bin", + "obj", + ".git", + ".svn", + ".hg", + ".vscode", + ".idea", + ".turbo", + ".output", + "desktop", + ".sst", + ".cache", + ".webkit-cache", + "__pycache__", + ".pytest_cache", + "mypy_cache", + ".history", + ".gradle", +]) + +const SUFFIXES = [".swp", ".swo", ".pyc", ".log"] +const BASENAMES = new Set([".DS_Store", "Thumbs.db"]) +const CONTAINS_SEGMENTS = ["logs", "tmp", "temp", "coverage", ".nyc_output"] + +export function generatedLike(file: string): boolean { + const parts = file.split(/[/\\]/) + for (const part of parts) { + if (FOLDERS.has(part)) return true + if (CONTAINS_SEGMENTS.includes(part)) return true + } + for (const suffix of SUFFIXES) { + if (file.endsWith(suffix)) return true + } + const base = parts[parts.length - 1] ?? "" + if (BASENAMES.has(base)) return true + return false +} + +async function ancestor(git: GitOps, dir: string, base: string, log?: Log): Promise { + const result = await git.execGit(["merge-base", "HEAD", base], dir) + if (result.code !== 0) { + log?.("git merge-base failed", { code: result.code, stderr: result.stderr.trim(), dir, base }) + return undefined + } + return result.stdout.trim() +} + +async function numstat(git: GitOps, dir: string, base: string, file?: string) { + const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base] + if (file) args.push("--", file) + const result = await git.execGit(args, dir) + const map = new Map() + if (result.code !== 0) return map + for (const line of result.stdout.trim().split("\n")) { + if (!line) continue + const parts = line.split("\t") + const add = parts[0] + const del = parts[1] + const name = parts.slice(2).join("\t") + if (!name) continue + map.set(name, { + additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0, + deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0, + }) + } + return map +} + +async function statStamp(dir: string, file: string): Promise { + const stat = await fs.stat(path.join(dir, file)).catch(() => undefined) + if (!stat) return `missing:${file}` + return `${stat.size}:${stat.mtimeMs}` +} + +async function lineCount(file: string): Promise { + const stat = await fs.stat(file).catch(() => undefined) + if (!stat || stat.size === 0) return 0 + if (stat.size > MAX_UNTRACKED_BYTES) return 0 + const content = await fs.readFile(file, "utf-8").catch(() => "") + if (!content) return 0 + if (content.endsWith("\n")) return content.split("\n").length - 1 + return content.split("\n").length +} + +function statusFromCode(code: string): Status { + if (code === "A") return "added" + if (code === "D") return "deleted" + return "modified" +} + +async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise { + const nameStatus = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc], + dir, + ) + if (nameStatus.code !== 0) { + log?.("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() }) + return [] + } + + const counts = await numstat(git, dir, anc) + const result: Meta[] = [] + const seen = new Set() + + for (const line of nameStatus.stdout.trim().split("\n")) { + if (!line) continue + const parts = line.split("\t") + const code = parts[0] + const file = parts.slice(1).join("\t") + if (!file || !code) continue + seen.add(file) + const status = statusFromCode(code) + const stat = counts.get(file) ?? { additions: 0, deletions: 0 } + result.push({ + file, + additions: stat.additions, + deletions: stat.deletions, + status, + tracked: true, + generatedLike: generatedLike(file), + stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, file), + }) + } + + const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard"], dir) + if (untracked.code !== 0) { + log?.("git ls-files --others failed", { code: untracked.code, stderr: untracked.stderr.trim() }) + return result + } + + const files = untracked.stdout.trim() + if (!files) return result + + for (const file of files.split("\n")) { + if (!file || seen.has(file)) continue + const full = path.join(dir, file) + const exists = await fs.stat(full).catch(() => undefined) + if (!exists) continue + result.push({ + file, + additions: await lineCount(full), + deletions: 0, + status: "added", + tracked: false, + generatedLike: generatedLike(file), + stamp: await statStamp(dir, file), + }) + } + + return result +} + +function summarize(meta: Meta): WorktreeDiffEntry { + return { + file: meta.file, + patch: "", + before: "", + after: "", + additions: meta.additions, + deletions: meta.deletions, + status: meta.status, + tracked: meta.tracked, + generatedLike: meta.generatedLike, + summarized: true, + stamp: meta.stamp, + } +} + +/** + * Hot polling path. Returns one summarized entry per changed file (tracked or + * untracked) relative to `merge-base HEAD base`. No file contents are read — + * `before`/`after`/`patch` are empty strings. Matches the shape the server's + * `WorktreeDiff.summary` emits. + */ +export async function diffSummary(git: GitOps, dir: string, base: string, log?: Log): Promise { + const anc = await ancestor(git, dir, base, log) + if (!anc) return [] + const items = await list(git, dir, anc, log) + return items.map(summarize) +} + +async function detailMeta(git: GitOps, dir: string, anc: string, file: string): Promise { + const tracked = await git.execGit(["ls-files", "--error-unmatch", "--", file], dir) + if (tracked.code !== 0) { + const full = path.join(dir, file) + const exists = await fs.stat(full).catch(() => undefined) + if (!exists) return undefined + return { + file, + additions: await lineCount(full), + deletions: 0, + status: "added", + tracked: false, + generatedLike: generatedLike(file), + stamp: await statStamp(dir, file), + } + } + + const nameStatus = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc, "--", file], + dir, + ) + if (nameStatus.code !== 0) return undefined + const line = nameStatus.stdout.trim().split("\n")[0] + if (!line) return undefined + const parts = line.split("\t") + const code = parts[0] + const pathPart = parts.slice(1).join("\t") || file + if (!code) return undefined + + const counts = await numstat(git, dir, anc, file) + const stat = counts.get(file) ?? counts.get(pathPart) ?? { additions: 0, deletions: 0 } + const status = statusFromCode(code) + return { + file: pathPart, + additions: stat.additions, + deletions: stat.deletions, + status, + tracked: true, + generatedLike: generatedLike(pathPart), + stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, pathPart), + } +} + +async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise { + if (status === "added") return "" + const result = await git.execGit(["show", `${anc}:${file}`], dir) + return result.code === 0 ? result.stdout : "" +} + +async function readAfter(dir: string, file: string, status: Status): Promise { + if (status === "deleted") return "" + const full = path.join(dir, file) + const exists = await fs.stat(full).catch(() => undefined) + if (!exists) return "" + return fs.readFile(full, "utf-8").catch(() => "") +} + +async function unifiedPatch(git: GitOps, dir: string, anc: string, file: string): Promise { + const result = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", anc, "--", file], + dir, + ) + return result.code === 0 ? result.stdout : "" +} + +function linesOf(text: string): number { + if (!text) return 0 + return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length +} + +/** + * Single-file detail view (infrequent — opened on demand when the user clicks + * a file in the review panel). Returns full `before`, `after`, and unified + * patch. Returns `null` if the file cannot be resolved. + */ +export async function diffFile( + git: GitOps, + dir: string, + base: string, + file: string, + log?: Log, +): Promise { + const anc = await ancestor(git, dir, base, log) + if (!anc) return null + const meta = await detailMeta(git, dir, anc, file) + if (!meta) return null + + const before = await readBefore(git, dir, anc, meta.file, meta.status) + const after = await readAfter(dir, meta.file, meta.status) + const patch = meta.tracked ? await unifiedPatch(git, dir, anc, meta.file) : buildUntrackedPatch(meta.file, after) + const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions + return { + file: meta.file, + patch, + before, + after, + additions, + deletions: meta.deletions, + status: meta.status, + tracked: meta.tracked, + generatedLike: meta.generatedLike, + summarized: false, + stamp: meta.stamp, + } +} + +/** Synthesize a unified-diff patch for an untracked (new) file. `git diff` + * only covers tracked paths, so we render the "everything added" patch + * ourselves. Format matches `git diff --no-index /dev/null `. */ +function buildUntrackedPatch(file: string, content: string): string { + if (!content) { + return `diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n` + } + const lines = content.split("\n") + const trailing = content.endsWith("\n") + const body = trailing ? lines.slice(0, -1) : lines + const header = + `diff --git a/${file} b/${file}\n` + + `new file mode 100644\n` + + `--- /dev/null\n` + + `+++ b/${file}\n` + + `@@ -0,0 +1,${body.length} @@\n` + const hunk = body.map((line) => `+${line}`).join("\n") + return header + hunk + (trailing ? "\n" : "\n\\ No newline at end of file\n") +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 314aa077aee..95565b6033d 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -24,6 +24,8 @@ type SessionMode = "worktree" | "local" export type ApplyDiffStatus = "checking" | "applying" | "success" | "conflict" | "error" export type WorktreeDiffEntry = SnapshotFileDiff & { + before?: string + after?: string tracked?: boolean generatedLike?: boolean summarized?: boolean diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index a4ec1dabbd9..7a2f115d0cd 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,9 +1,8 @@ -import type { KiloClient } from "@kilocode/sdk/v2/client" import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" -import type { AgentManagerOutMessage } from "./types" +import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types" const LOCAL_DIFF_ID = "local" as const @@ -14,8 +13,11 @@ export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined getRoot: () => string | undefined getStateReady: () => Promise | undefined - getClient: () => KiloClient git: GitOps + /** In-process diff summary (replaces client.worktree.diffSummary). */ + localDiff: (dir: string, base: string) => Promise + /** In-process single-file diff (replaces client.worktree.diffFile). */ + localDiffFile: (dir: string, base: string, file: string) => Promise post: (msg: AgentManagerOutMessage) => void log: (...args: unknown[]) => void } @@ -149,11 +151,7 @@ export class WorktreeDiffController { this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true }) try { - const { data } = await this.ctx - .getClient() - .worktree.diffSummary({ directory: target.directory, base: target.baseBranch }, { throwOnError: true }) - - const files = data ?? [] + const files = await this.ctx.localDiff(target.directory, target.baseBranch) this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) this.hash = hashFileDiffs(files) this.session = sessionId @@ -175,10 +173,8 @@ export class WorktreeDiffController { this.target = { sessionId, directory: target.directory, baseBranch: target.baseBranch } try { - const { data } = await this.ctx - .getClient() - .worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true }) - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data ?? null }) + const data = await this.ctx.localDiffFile(target.directory, target.baseBranch, file) + this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data }) } catch (error) { this.ctx.log("Failed to fetch worktree diff file:", error) this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) @@ -219,11 +215,7 @@ export class WorktreeDiffController { if (!target) return try { - const { data } = await this.ctx - .getClient() - .worktree.diffSummary({ directory: target.directory, base: target.baseBranch }, { throwOnError: true }) - - const files = data ?? [] + const files = await this.ctx.localDiff(target.directory, target.baseBranch) const hash = hashFileDiffs(files) if (hash === this.hash && this.session === sessionId) return this.hash = hash @@ -268,9 +260,7 @@ export class WorktreeDiffController { private async status(target: { directory: string; baseBranch: string }, file: string): Promise { try { - const { data } = await this.ctx - .getClient() - .worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true }) + const data = await this.ctx.localDiffFile(target.directory, target.baseBranch, file) return data?.status } catch (error) { this.ctx.log("Failed to look up file status for revert:", error) diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts index 2df698e3d0e..8b1844f7641 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect } from "bun:test" import * as fs from "fs" import * as os from "os" import * as path from "path" -import type { KiloClient } from "@kilocode/sdk/v2/client" import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller" import { GitOps } from "../../src/agent-manager/GitOps" import { Semaphore } from "../../src/agent-manager/semaphore" import type { Worktree } from "../../src/agent-manager/WorktreeStateManager" +import type { WorktreeDiffEntry } from "../../src/agent-manager/types" function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -31,8 +31,22 @@ function worktree(id: string, remote = "origin"): Worktree { } } -function diff(additions: number, deletions: number) { - return [{ file: "file.ts", before: "", after: "", additions, deletions, status: "modified" as const }] +function diff(additions: number, deletions: number): WorktreeDiffEntry[] { + return [ + { + file: "file.ts", + patch: "", + before: "", + after: "", + additions, + deletions, + status: "modified", + tracked: true, + generatedLike: false, + summarized: true, + stamp: `${additions}:${deletions}`, + }, + ] } function gitOps(handler: (args: string[], cwd: string) => Promise): GitOps { @@ -45,23 +59,19 @@ describe("GitStatsPoller", () => { let max = 0 let calls = 0 - const client = { - worktree: { - diffSummary: async () => { - calls += 1 - running += 1 - max = Math.max(max, running) - await sleep(40) - running -= 1 - return { data: diff(2, 1) } - }, - }, - } as unknown as KiloClient + const localDiff = async () => { + calls += 1 + running += 1 + max = Math.max(max, running) + await sleep(40) + running -= 1 + return diff(2, 1) + } const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a")], getWorkspaceRoot: () => undefined, - getClient: () => client, + localDiff, onStats: () => undefined, onLocalStats: () => undefined, log: () => undefined, @@ -85,20 +95,16 @@ describe("GitStatsPoller", () => { Array<{ worktreeId: string; files: number; additions: number; deletions: number; ahead: number; behind: number }> > = [] - const client = { - worktree: { - diffSummary: async () => { - calls += 1 - if (calls === 1) return { data: diff(7, 3) } - throw new Error("transient backend failure") - }, - }, - } as unknown as KiloClient + const localDiff = async () => { + calls += 1 + if (calls === 1) return diff(7, 3) + throw new Error("transient backend failure") + } const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a")], getWorkspaceRoot: () => undefined, - getClient: () => client, + localDiff, onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, log: () => undefined, @@ -134,8 +140,8 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], getWorkspaceRoot: () => root, - getClient: () => { - throw new Error("backend unavailable") + localDiff: async () => { + throw new Error("should not be called when backend unavailable path") }, onStats: () => undefined, onLocalStats: () => undefined, @@ -171,9 +177,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], getWorkspaceRoot: () => root, - getClient: () => { - throw new Error("backend unavailable") - }, + localDiff: async () => diff(0, 0), onStats: () => undefined, onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -202,25 +206,19 @@ describe("GitStatsPoller", () => { fs.mkdirSync(wtAPath, { recursive: true }) const calls: string[] = [] - const emitted: Array> = [] + const emitted: Array> = [] const presence: WorktreePresenceResult[] = [] - const client = { - worktree: { - diffSummary: async ({ directory }: { directory: string }) => { - calls.push(directory) - return { data: diff(1, 1) } - }, - }, - } as unknown as KiloClient - const poller = new GitStatsPoller({ getWorktrees: () => [ { ...worktree("a"), path: wtAPath }, { ...worktree("b"), path: wtBPath }, ], getWorkspaceRoot: () => root, - getClient: () => client, + localDiff: async (dir) => { + calls.push(dir) + return diff(1, 1) + }, onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -252,7 +250,7 @@ describe("GitStatsPoller", () => { expect(emitted[0]?.map((item) => item.worktreeId)).toEqual(["a"]) }) - it("preserves local stats when client fails after initial success", async () => { + it("preserves local stats when diff fails after initial success", async () => { let diffCalls = 0 const emitted: Array<{ branch: string @@ -263,20 +261,16 @@ describe("GitStatsPoller", () => { behind: number }> = [] - const client = { - worktree: { - diffSummary: async () => { - diffCalls += 1 - if (diffCalls === 1) return { data: diff(5, 2) } - throw new Error("transient backend failure") - }, - }, - } as unknown as KiloClient + const localDiff = async () => { + diffCalls += 1 + if (diffCalls === 1) return diff(5, 2) + throw new Error("transient backend failure") + } const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - getClient: () => client, + localDiff, onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -310,14 +304,10 @@ describe("GitStatsPoller", () => { behind: number }> = [] - const client = { - worktree: { diffSummary: async () => ({ data: diff(10, 4) }) }, - } as unknown as KiloClient - const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - getClient: () => client, + localDiff: async () => diff(10, 4), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -358,14 +348,10 @@ describe("GitStatsPoller", () => { behind: number }> = [] - const client = { - worktree: { diffSummary: async () => ({ data: diff(0, 0) }) }, - } as unknown as KiloClient - const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - getClient: () => client, + localDiff: async () => diff(0, 0), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -405,14 +391,10 @@ describe("GitStatsPoller", () => { Array<{ worktreeId: string; files: number; additions: number; deletions: number; ahead: number; behind: number }> > = [] - const client = { - worktree: { diffSummary: async () => ({ data: diff(0, 0) }) }, - } as unknown as KiloClient - const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a", "upstream"), worktree("b", "upstream")], getWorkspaceRoot: () => undefined, - getClient: () => client, + localDiff: async () => diff(0, 0), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, log: () => undefined, @@ -432,32 +414,57 @@ describe("GitStatsPoller", () => { expect(fetches.length).toBe(0) }) - it("limits concurrent diffSummary calls when semaphore is provided", async () => { + it("runs diffs in parallel without stalling (no extra semaphore layer)", async () => { + // localDiff is a synchronous promise — since the poller no longer wraps + // it in a semaphore (GitOps.execGit() gates at the child-process layer), + // many worktrees can have their diffs computed concurrently without + // contending for a dedicated outer gate. let running = 0 let peak = 0 let ticks = 0 - const sem = new Semaphore(2) - const client = { - worktree: { - diffSummary: async () => { - running++ - peak = Math.max(peak, running) - await sleep(20) - running-- - return { data: diff(1, 0) } - }, - }, - } as unknown as KiloClient - - // Wire the SAME semaphore into GitOps to prove there's no deadlock — - // aheadBehind acquires the semaphore independently, not nested inside - // the diffSummary gate. const wts = Array.from({ length: 5 }, (_, i) => worktree(String(i))) const poller = new GitStatsPoller({ getWorktrees: () => wts, getWorkspaceRoot: () => undefined, - getClient: () => client, + localDiff: async () => { + running++ + peak = Math.max(peak, running) + await sleep(20) + running-- + return diff(1, 0) + }, + onStats: () => { + ticks++ + }, + onLocalStats: () => undefined, + log: () => undefined, + intervalMs: 5, + git: gitOps(async (args) => { + if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t0" + return "" + }), + }) + + poller.setEnabled(true) + await waitFor(() => ticks >= 1) + poller.stop() + + // All 5 diffs can run in parallel (no artificial cap at this layer). + expect(peak).toBeGreaterThan(1) + }) + + it("runs concurrent diffs without deadlock when GitOps semaphore is shared", async () => { + // Wire the SAME semaphore into GitOps to prove the aheadBehind path + // (which goes through GitOps.raw) does not deadlock with the diff path. + const sem = new Semaphore(2) + let ticks = 0 + + const wts = Array.from({ length: 5 }, (_, i) => worktree(String(i))) + const poller = new GitStatsPoller({ + getWorktrees: () => wts, + getWorkspaceRoot: () => undefined, + localDiff: async () => diff(1, 0), onStats: () => { ticks++ }, @@ -479,7 +486,6 @@ describe("GitStatsPoller", () => { await waitFor(() => ticks >= 1) poller.stop() - // Only diffSummary calls are tracked — they should be bounded. - expect(peak).toBeLessThanOrEqual(2) + expect(ticks).toBeGreaterThan(0) }) }) diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts new file mode 100644 index 00000000000..d456dc312ac --- /dev/null +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -0,0 +1,211 @@ +import { describe, it, expect } from "bun:test" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import { diffSummary, diffFile, generatedLike } from "../../src/agent-manager/local-diff" +import { GitOps } from "../../src/agent-manager/GitOps" + +function git(): GitOps { + return new GitOps({ log: () => undefined }) +} + +function runSync(cwd: string, args: string[]): string { + const result = Bun.spawnSync({ + cmd: ["git", ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }) + if (result.exitCode !== 0) { + throw new Error(Buffer.from(result.stderr).toString("utf8") || Buffer.from(result.stdout).toString("utf8")) + } + return Buffer.from(result.stdout).toString("utf8").trim() +} + +async function withRepo(run: (dir: string, base: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "local-diff-test-")) + try { + runSync(dir, ["init", "-b", "main"]) + runSync(dir, ["config", "user.email", "test@example.com"]) + runSync(dir, ["config", "user.name", "Test"]) + runSync(dir, ["config", "commit.gpgsign", "false"]) + // Seed commit so `merge-base HEAD main` resolves. + await fs.writeFile(path.join(dir, "seed.txt"), "seed\n") + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "seed"]) + runSync(dir, ["branch", "base-branch"]) + await run(dir, "base-branch") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +} + +describe("generatedLike", () => { + it("matches files in ignored folders", () => { + expect(generatedLike("node_modules/foo.js")).toBe(true) + expect(generatedLike("packages/app/node_modules/foo/index.js")).toBe(true) + expect(generatedLike("dist/bundle.js")).toBe(true) + expect(generatedLike("build/out.js")).toBe(true) + expect(generatedLike(".git/HEAD")).toBe(true) + expect(generatedLike("__pycache__/mod.cpython-39.pyc")).toBe(true) + }) + + it("matches files by suffix", () => { + expect(generatedLike("src/app.log")).toBe(true) + expect(generatedLike("something.swp")).toBe(true) + expect(generatedLike("something.swo")).toBe(true) + expect(generatedLike("src/module.pyc")).toBe(true) + }) + + it("matches known basenames", () => { + expect(generatedLike("src/.DS_Store")).toBe(true) + expect(generatedLike("Thumbs.db")).toBe(true) + }) + + it("matches contained directory segments", () => { + expect(generatedLike("src/logs/app.txt")).toBe(true) + expect(generatedLike("tmp/foo")).toBe(true) + expect(generatedLike("a/temp/b")).toBe(true) + expect(generatedLike("coverage/report.html")).toBe(true) + expect(generatedLike(".nyc_output/out.json")).toBe(true) + }) + + it("rejects normal source files", () => { + expect(generatedLike("src/index.ts")).toBe(false) + expect(generatedLike("README.md")).toBe(false) + expect(generatedLike("packages/kilo-vscode/src/extension.ts")).toBe(false) + }) + + it("handles Windows-style separators", () => { + expect(generatedLike("node_modules\\foo\\bar.js")).toBe(true) + expect(generatedLike("src\\index.ts")).toBe(false) + }) +}) + +describe("diffSummary", () => { + it("returns empty array when ancestor cannot be resolved", async () => { + await withRepo(async (dir) => { + const result = await diffSummary(git(), dir, "nonexistent-branch") + expect(result).toEqual([]) + }) + }) + + it("reports modified, added, and deleted tracked files", async () => { + await withRepo(async (dir, base) => { + // seed.txt is tracked on base. Modify it; add new.txt; delete seed.txt on HEAD. + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nextra line\n") + await fs.writeFile(path.join(dir, "new.txt"), "hello\nworld\n") + runSync(dir, ["add", "."]) + runSync(dir, ["commit", "-m", "modify+add"]) + await fs.rm(path.join(dir, "seed.txt")) + runSync(dir, ["add", "-A"]) + runSync(dir, ["commit", "-m", "delete seed"]) + + const result = await diffSummary(git(), dir, base) + const byFile = new Map(result.map((entry) => [entry.file, entry])) + + expect(byFile.get("new.txt")?.status).toBe("added") + expect(byFile.get("new.txt")?.additions).toBe(2) + expect(byFile.get("new.txt")?.tracked).toBe(true) + expect(byFile.get("seed.txt")?.status).toBe("deleted") + }) + }) + + it("includes untracked files as added with tracked=false", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "untracked.txt"), "a\nb\nc\n") + const result = await diffSummary(git(), dir, base) + const entry = result.find((e) => e.file === "untracked.txt") + expect(entry).toBeTruthy() + expect(entry?.status).toBe("added") + expect(entry?.tracked).toBe(false) + expect(entry?.additions).toBe(3) + }) + }) + + it("all entries are summarized with empty before/after/patch", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "untracked.txt"), "x\n") + await fs.writeFile(path.join(dir, "seed.txt"), "changed\n") + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "change seed"]) + const result = await diffSummary(git(), dir, base) + expect(result.length).toBeGreaterThan(0) + for (const entry of result) { + expect(entry.summarized).toBe(true) + expect(entry.before).toBe("") + expect(entry.after).toBe("") + expect(entry.patch).toBe("") + expect(typeof entry.stamp).toBe("string") + } + }) + }) + + it("marks generated-like files via generatedLike flag", async () => { + await withRepo(async (dir, base) => { + await fs.mkdir(path.join(dir, "dist"), { recursive: true }) + await fs.writeFile(path.join(dir, "dist/app.js"), "console.log(1)\n") + await fs.writeFile(path.join(dir, "src.ts"), "export {}\n") + const result = await diffSummary(git(), dir, base) + const dist = result.find((e) => e.file === "dist/app.js") + const src = result.find((e) => e.file === "src.ts") + expect(dist?.generatedLike).toBe(true) + expect(src?.generatedLike).toBe(false) + }) + }) +}) + +describe("diffFile", () => { + it("returns null when ancestor cannot be resolved", async () => { + await withRepo(async (dir) => { + const result = await diffFile(git(), dir, "nonexistent-branch", "any.txt") + expect(result).toBeNull() + }) + }) + + it("returns null for a missing file that isn't tracked either", async () => { + await withRepo(async (dir, base) => { + const result = await diffFile(git(), dir, base, "does-not-exist.txt") + expect(result).toBeNull() + }) + }) + + it("returns before/after/patch for a modified tracked file", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nmore\n") + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "modify seed"]) + const result = await diffFile(git(), dir, base, "seed.txt") + expect(result).toBeTruthy() + expect(result?.status).toBe("modified") + expect(result?.tracked).toBe(true) + expect(result?.before).toBe("seed\n") + expect(result?.after).toBe("seed\nmore\n") + expect(result?.patch.length).toBeGreaterThan(0) + expect(result?.summarized).toBe(false) + }) + }) + + it("returns synthetic patch for an untracked added file", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "fresh.txt"), "one\ntwo\n") + const result = await diffFile(git(), dir, base, "fresh.txt") + expect(result).toBeTruthy() + expect(result?.status).toBe("added") + expect(result?.tracked).toBe(false) + expect(result?.before).toBe("") + expect(result?.after).toBe("one\ntwo\n") + expect(result?.patch).toContain("new file mode") + expect(result?.patch).toContain("+one") + expect(result?.patch).toContain("+two") + }) + }) +}) From 77e52748cd59150e1d61377b2e41453aea347fb0 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 17:09:09 +0300 Subject: [PATCH 25/35] fix(vscode): cap diff detail reads at 2MB per side Addresses PR #9046 review feedback. diffFile() used to read the entire ancestor blob, working copy, and unified patch into memory unconditionally, which could spike the extension host's RSS when opening a very large tracked file. Probes the sizes first via `git cat-file -s` and `fs.stat`, and falls back to a summarized entry (empty before/after/patch, counts preserved) when either side exceeds 2 MB. --- .../src/agent-manager/local-diff.ts | 34 ++++++++++ .../kilo-vscode/tests/unit/local-diff.test.ts | 65 ++++++++++++++++++- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index 151a2943a75..23ef20517c1 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -21,6 +21,14 @@ type Log = (...args: unknown[]) => void * not stall the poll. Matches `GitOps.workingTreeStats()`. */ const MAX_UNTRACKED_BYTES = 1_000_000 +/** Cap per-side reads in the detail view. Opening a 50 MB tracked file used + * to spike `kilo serve`; now that the detail path runs in the extension + * host, the same file would spike VS Code's RSS. Over this threshold we + * return a summarized entry (empty `before`/`after`/`patch`, metadata + * preserved) so the webview can render counts without materializing the + * content. */ +export const MAX_DETAIL_BYTES = 2_000_000 + /** * Local, Node.js-side replacement for the server's `WorktreeDiff.summary()` and * `WorktreeDiff.detail()` routes. Keeps Agent Manager polling out of the Bun @@ -268,6 +276,17 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string): } } +async function blobSize(git: GitOps, dir: string, anc: string, file: string): Promise { + const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir) + if (result.code !== 0) return 0 + return parseInt(result.stdout.trim(), 10) || 0 +} + +async function fileSize(dir: string, file: string): Promise { + const stat = await fs.stat(path.join(dir, file)).catch(() => undefined) + return stat?.size ?? 0 +} + async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise { if (status === "added") return "" const result = await git.execGit(["show", `${anc}:${file}`], dir) @@ -312,6 +331,21 @@ export async function diffFile( const meta = await detailMeta(git, dir, anc, file) if (!meta) return null + // Cheap size probe before materializing content — protects the extension + // host from OOM on huge tracked files. `git cat-file -s` returns the blob + // size without streaming its contents, and `fs.stat` is a plain syscall. + const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file) + const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file) + if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) { + log?.("diffFile: file too large for detail view, returning summarized entry", { + file: meta.file, + beforeBytes, + afterBytes, + cap: MAX_DETAIL_BYTES, + }) + return summarize(meta) + } + const before = await readBefore(git, dir, anc, meta.file, meta.status) const after = await readAfter(dir, meta.file, meta.status) const patch = meta.tracked ? await unifiedPatch(git, dir, anc, meta.file) : buildUntrackedPatch(meta.file, after) diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index d456dc312ac..0c95da09150 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "bun:test" import * as fs from "fs/promises" import * as os from "os" import * as path from "path" -import { diffSummary, diffFile, generatedLike } from "../../src/agent-manager/local-diff" +import { diffSummary, diffFile, generatedLike, MAX_DETAIL_BYTES } from "../../src/agent-manager/local-diff" import { GitOps } from "../../src/agent-manager/GitOps" function git(): GitOps { @@ -208,4 +208,67 @@ describe("diffFile", () => { expect(result?.patch).toContain("+two") }) }) + + it("falls back to summarized entry when the working-copy file exceeds the detail cap", async () => { + await withRepo(async (dir, base) => { + // Write a tracked file that's ~2.5x the cap on the working-copy side. + const big = "a".repeat(MAX_DETAIL_BYTES + 500_000) + "\n" + await fs.writeFile(path.join(dir, "seed.txt"), big) + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "grow seed"]) + + const result = await diffFile(git(), dir, base, "seed.txt") + expect(result).toBeTruthy() + // Metadata (status, counts, stamp) is preserved so the UI can still + // show the file and its add/delete totals. + expect(result?.status).toBe("modified") + expect(result?.tracked).toBe(true) + expect(result?.additions).toBeGreaterThan(0) + // Content is intentionally blank — the cap prevents materialization. + expect(result?.before).toBe("") + expect(result?.after).toBe("") + expect(result?.patch).toBe("") + expect(result?.summarized).toBe(true) + }) + }) + + it("falls back to summarized entry when the ancestor blob exceeds the detail cap", async () => { + await withRepo(async (dir, base) => { + // Put the large content in the base commit, then delete the file on HEAD. + // `before` is read from the base blob (over cap); `after` is empty. + const big = "b".repeat(MAX_DETAIL_BYTES + 500_000) + "\n" + await fs.writeFile(path.join(dir, "big.txt"), big) + runSync(dir, ["add", "big.txt"]) + runSync(dir, ["commit", "-m", "add big"]) + // Re-create the base-branch pointer so it includes the big blob. + runSync(dir, ["branch", "-f", base]) + // Shrink on HEAD. + await fs.writeFile(path.join(dir, "big.txt"), "small\n") + runSync(dir, ["add", "big.txt"]) + runSync(dir, ["commit", "-m", "shrink"]) + + const result = await diffFile(git(), dir, base, "big.txt") + expect(result).toBeTruthy() + expect(result?.tracked).toBe(true) + expect(result?.before).toBe("") + expect(result?.after).toBe("") + expect(result?.patch).toBe("") + expect(result?.summarized).toBe(true) + }) + }) + + it("still returns full detail when both sides are under the cap", async () => { + await withRepo(async (dir, base) => { + // Modest file, well under cap — behaves as before. + const content = "a".repeat(50_000) + "\n" + await fs.writeFile(path.join(dir, "seed.txt"), content) + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "modest change"]) + + const result = await diffFile(git(), dir, base, "seed.txt") + expect(result?.summarized).toBe(false) + expect((result?.after ?? "").length).toBeGreaterThan(0) + expect((result?.patch ?? "").length).toBeGreaterThan(0) + }) + }) }) From 671129d4d70587352f963f9f409d6c24e9e86436 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 17:29:26 +0300 Subject: [PATCH 26/35] chore: add changeset for agent manager memory leak fix --- .changeset/fix-agent-manager-memory-leak.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-agent-manager-memory-leak.md diff --git a/.changeset/fix-agent-manager-memory-leak.md b/.changeset/fix-agent-manager-memory-leak.md new file mode 100644 index 00000000000..72f1b0210b7 --- /dev/null +++ b/.changeset/fix-agent-manager-memory-leak.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix a native memory leak on Windows where `kilo serve` would grow to several GB of RAM within minutes of opening the Agent Manager. Git diff polling now runs directly in the extension host instead of routing through the CLI subprocess, and the diff detail view caps per-file reads at 2 MB to prevent memory spikes when opening very large files. From eac2dbafa009adedeb4b44016956f2c6cd96b715 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 17 Apr 2026 16:47:45 +0200 Subject: [PATCH 27/35] perf(vscode): paginated message loading with virtualized scroll (#8911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(vscode): paginated message loading with virtualized scroll Reimplement session message loading with cursor-based pagination and virtual list rendering to reduce initial load time and memory for long sessions. * chore: update kilo-vscode visual regression baselines * fix(vscode): update VscodeSessionTurn props in storybook * chore: update kilo-vscode visual regression baselines * perf(vscode): skip reconcile on session switch, lazy part hydration, fast markdown render Skip SolidJS reconcile() for replace-mode message loads — direct array assignment avoids expensive O(n) diffing and proxy creation for 80+ messages on every session switch. Defer part hydration until the virtualizer renders each turn, reducing reactive store writes by 85% on initial load. Double-rAF scroll restoration avoids forced layout reflow mid-paint. Extract markdown fast-path render into packages/ui/src/kilocode/ to minimize shared file changes. * fix(vscode): keep lazy part hydration correct in stories * fix(vscode): resolve state-risk regressions in message pagination Five correctness issues surfaced during review of the paginated-message- loading stack, plus the follow-through performance tuning needed to keep session switching near-instant: - focus-mode selection re-enters the server for the tail (new "reconcile" load mode) so SSE drops self-heal on the next session switch instead of stranding the webview on a stale snapshot. Throttled to 1s to avoid stacking up fetches on rapid tab switching, and early-outs in the webview when the server tail matches local state. - cursor pagination falls back to a client-synthesized `{id,time}` cursor when a proxy or older binary strips the X-Next-Cursor header, so "load earlier" keeps working. - in-flight loadMessages results for a session deleted mid-fetch are dropped instead of resurrecting a ghost entry in the webview store. - sub-agent viewer now loads the full transcript via `limit: 0` instead of silently truncating to MESSAGE_PAGE_LIMIT (it has no "load earlier" UI to recover from the cap). - stashed message parts are now cleared on messageRemoved. Extracted the stash-access helpers into a PartStash class so every lifecycle event coordinates store + stash cleanup in one place. Also restores the two-pass markdown rendering intended by PR #7102 — an upstream merge silently re-added marked-shiki, which made Shiki run synchronously during parse and froze the main thread for up to 1.3s on session switches with many code blocks. Code blocks render as plain
 first and deferredHighlight() upgrades them after paint.

Regression tests cover all five state risks (fetchMessagePage cursor
fallback, KiloProvider focus reconcile, ghost session on prepend,
sub-agent full load, focus-mode throttle, PartStash leak on
messageRemoved).

* chore(vscode): restore unrelated doc comments in KiloProvider

PR review: the previous fix commit trimmed four unrelated doc comments
(loadMessagesAbort, handleSyncSession JSDoc, "inherit parent directory"
comment, handleDeleteSession JSDoc) to squeeze under the 3350-line
max-lines cap. Restoring them — the cap is hit exactly at 3350 and the
PR surface stays focused on the state-risk fixes.

* chore: update kilo-vscode visual regression baselines

* refactor: simplify pagination helpers without behavior change

- fetchMessagePage: drop the conditional spread for `limit`/`before` — the
  server schema accepts `limit: 0` the same as omitted (`z.coerce.number()
  .int().min(0).optional()`), so always passing the values directly works.
  Inline the `oldest` temp and drop `?? undefined` (`headers.get()` returns
  `null | string` which `??` handles identically downstream).
- handleLoadMessages: inline the single-use `focus` boolean, fold the
  `mode === "replace"` refresh call into the `if (abort)` block since
  they're gated by the same condition.
- sameReconcileShape: destructure `c`/`n` once per iteration instead of
  accessing `current[i]!` / `incoming[i]!` three times each.

All 1815 tests pass, typecheck + lint clean.

* docs: clarify intent of helpers flagged in PR review

- commands.ts: comment explains the in-flight dedup pattern and why the
  identity check in the `finally` guards against clear-then-restart races.
- sessionsForWorktree: comment notes the oldest-first sort is the canonical
  order before applyTabOrder, and why both the worktree label and the tab
  bar must agree on "which session is first".

No behavior change — both helpers already did this; now the intent is on
the page for future readers.

* chore: address PR #8911 review feedback from chrarnoldus

- Rewrite changeset as user-facing imperative summary (per AGENTS.md
  guidance: changesets appear in release notes; keep concise and feature-
  oriented, not implementation details).
- Drop redundant `kilocode_change - new file` marker on
  packages/ui/src/kilocode/markdown-fast-path.ts — the kilocode/ directory
  already signals the file is a Kilo addition; markers aren't needed in
  paths containing "kilocode".

---------

Co-authored-by: github-actions[bot] 
---
 .changeset/session-switch-perf-fixes.md       |   6 +
 bun.lock                                      |   1 +
 packages/kilo-vscode/package.json             |   1 +
 packages/kilo-vscode/src/KiloProvider.ts      | 215 ++++++++-------
 .../kilo-vscode/src/SubAgentViewerProvider.ts |  14 +-
 .../kilo-vscode/src/agent-manager/types.ts    |   3 +
 packages/kilo-vscode/src/extension.ts         |   1 +
 .../kilo-vscode/src/kilo-provider/commands.ts |  34 +++
 .../src/kilo-provider/message-page.ts         |  64 +++++
 .../src/kilo-provider/slim-metadata.ts        |  20 +-
 .../unit/kilo-provider-load-messages.test.ts  | 244 +++++++++++++++++
 .../tests/unit/message-page.test.ts           | 118 ++++++++
 .../kilo-vscode/tests/unit/part-stash.test.ts |  54 ++++
 .../mcp-tool-expanded-chromium-linux.png      |   4 +-
 .../agent-manager/AgentManagerApp.tsx         |  51 ++--
 .../src/components/chat/MessageList.tsx       | 143 +++++++---
 .../src/components/chat/VscodeSessionTurn.tsx |  45 +---
 .../webview-ui/src/context/part-stash.ts      |  63 +++++
 .../webview-ui/src/context/session.tsx        | 255 ++++++++++++++++--
 .../webview-ui/src/hooks/useSlashCommand.ts   |  14 +-
 .../webview-ui/src/stories/StoryProviders.tsx |   5 +
 .../src/stories/composite.stories.tsx         |   8 +-
 .../webview-ui/src/styles/chat-layout.css     |  27 ++
 .../webview-ui/src/types/messages.ts          |   8 +
 packages/sdk/js/src/v2/gen/types.gen.ts       |  72 ++---
 packages/ui/src/components/markdown.tsx       |  44 ++-
 packages/ui/src/components/message-part.tsx   |   4 +
 packages/ui/src/context/marked.tsx            |  31 +--
 .../ui/src/kilocode/markdown-fast-path.ts     |  29 ++
 29 files changed, 1277 insertions(+), 301 deletions(-)
 create mode 100644 .changeset/session-switch-perf-fixes.md
 create mode 100644 packages/kilo-vscode/src/kilo-provider/commands.ts
 create mode 100644 packages/kilo-vscode/src/kilo-provider/message-page.ts
 create mode 100644 packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts
 create mode 100644 packages/kilo-vscode/tests/unit/message-page.test.ts
 create mode 100644 packages/kilo-vscode/tests/unit/part-stash.test.ts
 create mode 100644 packages/kilo-vscode/webview-ui/src/context/part-stash.ts
 create mode 100644 packages/ui/src/kilocode/markdown-fast-path.ts

diff --git a/.changeset/session-switch-perf-fixes.md b/.changeset/session-switch-perf-fixes.md
new file mode 100644
index 00000000000..4936c4e9e0b
--- /dev/null
+++ b/.changeset/session-switch-perf-fixes.md
@@ -0,0 +1,6 @@
+---
+"kilo-code": patch
+"@opencode-ai/ui": patch
+---
+
+Make switching between sessions in Agent Manager near-instant. Long sessions no longer freeze the UI when selected, and the chat view self-heals if it missed any messages while the session was in the background.
diff --git a/bun.lock b/bun.lock
index 671eeb9b224..2149cddce63 100644
--- a/bun.lock
+++ b/bun.lock
@@ -322,6 +322,7 @@
         "simple-git": "3.35.2",
         "solid-js": "^1.9.11",
         "uri-js": "^4.4.1",
+        "virtua": "catalog:",
         "web-tree-sitter": "^0.24.7",
         "yaml": "2.8.3",
         "zod": "^3.24.2",
diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json
index 13e49b3ac9d..eafea703c35 100644
--- a/packages/kilo-vscode/package.json
+++ b/packages/kilo-vscode/package.json
@@ -880,6 +880,7 @@
     "simple-git": "3.35.2",
     "solid-js": "^1.9.11",
     "uri-js": "^4.4.1",
+    "virtua": "catalog:",
     "web-tree-sitter": "^0.24.7",
     "yaml": "2.8.3",
     "zod": "^3.24.2"
diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts
index d7c66637e03..79f2f5dfff7 100644
--- a/packages/kilo-vscode/src/KiloProvider.ts
+++ b/packages/kilo-vscode/src/KiloProvider.ts
@@ -49,6 +49,8 @@ import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-fil
 import { handleFileSearch } from "./kilo-provider/file-search"
 import { getTerminalContents } from "./services/terminal/context"
 import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
+import { clearCommandsCache, loadCommands } from "./kilo-provider/commands"
+import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page"
 import { childID } from "./kilo-provider/task-session"
 import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network"
 import { abortSession, parseQueued } from "./kilo-provider/abort"
@@ -110,6 +112,8 @@ type KiloProviderOptions = {
   slimEditMetadata?: boolean
 }
 
+type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile"
+
 // Helper to map agent data to the subset of fields sent to the webview
 const mapAgent = (a: Agent) => ({
   name: a.name,
@@ -172,6 +176,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
   private projectID: string | undefined
   /** Abort controller for the current loadMessages request; aborted when a new session is selected. */
   private loadMessagesAbort: AbortController | null = null
+  /** Per-session last focus-mode reconcile timestamp — throttles rapid tab switching. */
+  private lastReconciledAt = new Map()
   /** Set when refreshSessions() is called before the client is ready.
    *  Cleared and retried once the connection transitions to "connected". */
   private pendingSessionRefresh = false
@@ -453,6 +459,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
     this.trackedSessionIds.add(sessionId)
   }
 
+  public loadMessages(sessionID: string): Promise {
+    // Sub-agent viewer: full transcript (no "load earlier" UI, no pagination).
+    return this.handleLoadMessages(sessionID, { limit: 0 })
+  }
+
   /**
    * Register a directory override for a session (e.g., worktree path).
    * When set, all operations for this session use this directory instead of the workspace root.
@@ -638,7 +649,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
         case "loadMessages":
           // Don't await: allow parallel loads so rapid session switching
           // isn't blocked by slow responses for earlier sessions.
-          void this.handleLoadMessages(message.sessionID)
+          void this.handleLoadMessages(message.sessionID, {
+            mode: message.mode,
+            before: message.before,
+            limit: message.limit,
+          })
           break
         case "syncSession":
           this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) =>
@@ -1273,109 +1288,103 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
     }
   }
 
-  /**
-   * Handle loading messages for a session.
-   */
-  private async handleLoadMessages(sessionID: string): Promise {
-    // Track the session so we receive its SSE events
-    this.trackedSessionIds.add(sessionID)
-    this.focusSession(sessionID)
-    this.contextSessionID = sessionID
-
-    if (!this.client) {
-      this.postMessage({
-        type: "error",
-        message: "Not connected to CLI backend",
-        sessionID,
+  /** Non-blocking: refresh session metadata + status for the webview after switching. */
+  private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void {
+    if (!this.client) return
+    this.client.session
+      .get({ sessionID, directory: dir })
+      .then((r) => {
+        if (r.data && !signal?.aborted) {
+          this.currentSession = r.data
+          this.contextSessionID = r.data.id
+        }
       })
+      .catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e))
+    this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) })
+    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
+          this.postMessage({
+            type: "sessionStatus",
+            sessionID: sid,
+            status: info.type,
+            ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
+          })
+        }
+      })
+      .catch((e: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", e))
+  }
+
+  private async handleLoadMessages(
+    sessionID: string,
+    options: { mode?: MessageLoadMode; before?: string; limit?: number } = {},
+  ): Promise {
+    const mode = options.mode ?? "replace"
+    if (mode !== "prepend") {
+      this.trackedSessionIds.add(sessionID)
+      this.focusSession(sessionID)
+      this.contextSessionID = sessionID
+    }
+    if (!this.client) {
+      this.postMessage({ type: "error", message: "Not connected to CLI backend", sessionID })
       return
     }
-
-    // Abort any previous in-flight loadMessages request so the backend
-    // isn't overwhelmed when the user switches sessions rapidly.
-    this.loadMessagesAbort?.abort()
-    const abort = new AbortController()
-    this.loadMessagesAbort = abort
-
+    const dir = this.getWorkspaceDirectory(sessionID)
+    if (mode === "focus") {
+      this.refreshSessionDetails(sessionID, dir)
+      // Reconcile tail so SSE drops self-heal. Throttled to skip rapid tab-switching bursts.
+      if (Date.now() - (this.lastReconciledAt.get(sessionID) ?? 0) < 1000) return
+      await this.handleLoadMessages(sessionID, { mode: "reconcile", limit: options.limit ?? MESSAGE_PAGE_LIMIT })
+      return
+    }
+    // Replace competes for the spinner and cancels earlier loads; prepend/reconcile run in parallel.
+    const abort = mode === "replace" ? new AbortController() : undefined
+    if (abort) {
+      this.loadMessagesAbort?.abort()
+      this.loadMessagesAbort = abort
+      this.refreshSessionDetails(sessionID, dir, abort.signal)
+    }
     try {
-      const workspaceDir = this.getWorkspaceDirectory(sessionID)
-      const { data: messagesData } = await retry(() =>
-        this.client!.session.messages(
-          { sessionID, directory: workspaceDir },
-          { throwOnError: true, signal: abort.signal },
-        ),
-      )
-
-      // If this request was aborted while awaiting, skip posting stale results
-      if (abort.signal.aborted) return
-
-      // Update currentSession so fallback logic in handleSendMessage/handleAbort
-      // references the correct session after switching.  loadMessages is the
-      // canonical "user switched to this session" signal, so always update —
-      // the old guard `this.currentSession.id === sessionID` prevented updates
-      // when switching between different sessions.
-      // Non-blocking: don't let a failure here prevent messages from loading.
-      // 404s are expected for cross-worktree sessions — use silent to suppress HTTP error logs.
-      this.client.session
-        .get({ sessionID, directory: workspaceDir })
-        .then((result) => {
-          if (result.data && !abort.signal.aborted) {
-            this.currentSession = result.data
-            this.contextSessionID = result.data.id
-          }
-        })
-        .catch((err: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", err))
-
-      this.postMessage({
-        type: "workspaceDirectoryChanged",
-        directory: this.getWorkspaceDirectory(sessionID),
+      const page = await fetchMessagePage(this.client, {
+        sessionID,
+        workspaceDir: dir,
+        limit: options.limit ?? MESSAGE_PAGE_LIMIT,
+        before: options.before,
+        signal: abort?.signal,
       })
-
-      // Fetch current session status so the webview has the correct busy/idle
-      // state after switching tabs (SSE events may have been missed).
-      this.client.session
-        .status({ directory: workspaceDir })
-        .then((result) => {
-          if (!result.data) return
-          for (const [sid, info] of Object.entries(result.data) as [string, SessionStatus][]) {
-            if (!this.trackedSessionIds.has(sid)) continue
-            this.postMessage({
-              type: "sessionStatus",
-              sessionID: sid,
-              status: info.type,
-              ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
-            })
-          }
-        })
-        .catch((err: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", err))
-
-      const messages = messagesData.map((m) => ({
+      if (abort?.signal.aborted) return
+      // Drop results for a session deleted mid-fetch. Prepend/reconcile have
+      // no abort controller, so this guard prevents ghost entries.
+      if (!this.trackedSessionIds.has(sessionID)) return
+      const messages = page.items.map((m) => ({
         ...m.info,
         parts: this.slimParts(m.parts),
         createdAt: new Date(m.info.time.created).toISOString(),
       }))
-
       for (const message of messages) {
         this.connectionService.recordMessageSessionId(message.id, message.sessionID)
       }
-
-      // Snapshot must reflect every SSE event up to its taken-time; any
-      // delta still queued here is either already applied in the snapshot
-      // (re-emitting would duplicate streamed text) or trails the snapshot
-      // and is silently lost via drop().
-      this.streams.drop(sessionID)
-      this.postMessage({ type: "messagesLoaded", sessionID, messages })
+      // Authoritative snapshot: drop queued deltas. Prepend is older history
+      // and must not clobber live deltas.
+      if (mode === "replace" || mode === "reconcile") this.streams.drop(sessionID)
+      if (mode === "reconcile") this.lastReconciledAt.set(sessionID, Date.now())
+      this.postMessage({
+        type: "messagesLoaded",
+        sessionID,
+        messages,
+        mode,
+        cursor: page.cursor,
+        hasMore: Boolean(page.cursor),
+      })
       // Recover any prompts missed while the webview was loading or during an SSE reconnection.
       this.recoverPendingPrompts()
     } catch (error) {
-      // Silently ignore aborted requests — the user switched to a different session
-      if (abort.signal.aborted) return
+      if (abort?.signal.aborted) return
       console.error("[Kilo New] KiloProvider: Failed to load messages:", error)
-      this.postMessage({
-        type: "error",
-        message: getErrorMessage(error) || "Failed to load messages",
-        sessionID,
-      })
+      this.postMessage({ type: "error", message: getErrorMessage(error) || "Failed to load messages", sessionID })
     }
   }
 
@@ -1420,7 +1429,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
       // Snapshot supersedes any queued deltas (see handleLoadMessages for the
       // snapshot-freshness assumption that governs drop() here).
       this.streams.drop(sessionID)
-      this.postMessage({ type: "messagesLoaded", sessionID, messages })
+      this.postMessage({
+        type: "messagesLoaded",
+        sessionID,
+        messages,
+        mode: "replace",
+        hasMore: false,
+      })
+
       // Recover any prompts emitted by the child before we started tracking it.
       this.recoverPendingPrompts()
     } catch (err) {
@@ -1516,6 +1532,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
       this.streams.drop(sessionID)
       this.syncedChildSessions.delete(sessionID)
       this.sessionDirectories.delete(sessionID)
+      this.lastReconciledAt.delete(sessionID)
       this.connectionService.pruneSession(sessionID)
       if (this.currentSession?.id === sessionID) {
         this.currentSession = null
@@ -1735,6 +1752,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
     }
   }
 
+  private clearCommandsCache(): void {
+    this.cachedCommandsMessage = null
+    clearCommandsCache()
+  }
+
   private async fetchAndSendCommands(): Promise {
     if (!this.client) {
       if (this.cachedCommandsMessage) {
@@ -1745,19 +1767,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
 
     try {
       const dir = this.getWorkspaceDirectory()
-      const { data: commands } = await retry(() =>
-        this.client!.command.list({ directory: dir }, { throwOnError: true }),
-      )
+      const message = await loadCommands(this.client, dir)
 
-      const message = {
-        type: "commandsLoaded",
-        commands: commands.map((c) => ({
-          name: c.name,
-          description: c.description,
-          source: c.source,
-          hints: c.hints,
-        })),
-      }
       this.cachedCommandsMessage = message
       this.postMessage(message)
     } catch (error) {
@@ -1790,7 +1801,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
       if (result.error) {
         console.error("[Kilo New] removeSkill returned error:", result.error)
         this.cachedSkillsMessage = null
-        this.cachedCommandsMessage = null
+        this.clearCommandsCache()
         await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()])
         return false
       }
diff --git a/packages/kilo-vscode/src/SubAgentViewerProvider.ts b/packages/kilo-vscode/src/SubAgentViewerProvider.ts
index c50d3acba2d..277a2b606b7 100644
--- a/packages/kilo-vscode/src/SubAgentViewerProvider.ts
+++ b/packages/kilo-vscode/src/SubAgentViewerProvider.ts
@@ -61,18 +61,8 @@ export class SubAgentViewerProvider implements vscode.Disposable {
         // sessionCreated to the webview.
         provider.registerSession(session)
 
-        // Fetch and send existing messages
-        const { data: messagesData } = await client.session.messages({ sessionID }, { throwOnError: true })
-        const messages = messagesData.map((m) => ({
-          ...m.info,
-          parts: m.parts,
-          createdAt: new Date(m.info.time.created).toISOString(),
-        }))
-        provider.postMessage({
-          type: "messagesLoaded",
-          sessionID,
-          messages,
-        })
+        // Fetch the newest page before navigating so the tab opens on the latest turn.
+        await provider.loadMessages(sessionID)
 
         // Navigate to the sub-agent viewer
         provider.postMessage({ type: "viewSubAgentSession", sessionID })
diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts
index 314aa077aee..f8d593f8af4 100644
--- a/packages/kilo-vscode/src/agent-manager/types.ts
+++ b/packages/kilo-vscode/src/agent-manager/types.ts
@@ -532,6 +532,9 @@ interface PreviewImageIn {
 interface LoadMessagesIn {
   type: "loadMessages"
   sessionID: string
+  mode?: "replace" | "prepend" | "focus"
+  before?: string
+  limit?: number
 }
 
 interface FileSourceIn {
diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts
index 450aba09be7..a6ea2c31b03 100644
--- a/packages/kilo-vscode/src/extension.ts
+++ b/packages/kilo-vscode/src/extension.ts
@@ -326,6 +326,7 @@ export function activate(context: vscode.ExtensionContext) {
         const match = uri.path.match(/^\/kilocode\/s\/([a-zA-Z0-9_-]+)$/)
         if (!match) return
         const sessionId = match[1]
+        if (!sessionId) return
         console.log("[Kilo New] URI handler: opening cloud session:", sessionId)
         await vscode.commands.executeCommand(`${KiloProvider.viewType}.focus`)
         provider.openCloudSession(sessionId)
diff --git a/packages/kilo-vscode/src/kilo-provider/commands.ts b/packages/kilo-vscode/src/kilo-provider/commands.ts
new file mode 100644
index 00000000000..d641fff1f38
--- /dev/null
+++ b/packages/kilo-vscode/src/kilo-provider/commands.ts
@@ -0,0 +1,34 @@
+import type { KiloClient } from "@kilocode/sdk/v2/client"
+import { retry } from "../services/cli-backend/retry"
+
+const promises = new Map>()
+
+export function clearCommandsCache(): void {
+  promises.clear()
+}
+
+export async function loadCommands(client: KiloClient, dir: string): Promise {
+  const pending = promises.get(dir)
+  if (pending) return pending
+
+  const promise = retry(() => client.command.list({ directory: dir }, { throwOnError: true })).then(({ data }) => ({
+    type: "commandsLoaded",
+    commands: data.map((cmd) => ({
+      name: cmd.name,
+      description: cmd.description,
+      source: cmd.source,
+      hints: cmd.hints,
+    })),
+  }))
+
+  promises.set(dir, promise)
+  try {
+    return await promise
+  } finally {
+    // Clear the cache entry once the request settles so subsequent calls
+    // fetch fresh data. Identity check guards against clear-then-restart
+    // races: if clearCommandsCache() wiped the map and a new loadCommands()
+    // already stored a fresh promise, don't delete its entry.
+    if (promises.get(dir) === promise) promises.delete(dir)
+  }
+}
diff --git a/packages/kilo-vscode/src/kilo-provider/message-page.ts b/packages/kilo-vscode/src/kilo-provider/message-page.ts
new file mode 100644
index 00000000000..b8fef78f2f3
--- /dev/null
+++ b/packages/kilo-vscode/src/kilo-provider/message-page.ts
@@ -0,0 +1,64 @@
+import type { KiloClient } from "@kilocode/sdk/v2/client"
+import { retry } from "../services/cli-backend/retry"
+
+export const MESSAGE_PAGE_LIMIT = 80
+
+/**
+ * Build the same base64url-encoded cursor format the server emits so a
+ * synthesized cursor round-trips through `session.messages({ before })`.
+ * Server contract: `{ id, time }` JSON → base64url. See MessageV2.cursor.
+ */
+function synthesizeCursor(oldest: { info: { id: string; time: { created: number } } }): string {
+  const payload = JSON.stringify({ id: oldest.info.id, time: oldest.info.time.created })
+  return Buffer.from(payload, "utf8").toString("base64url")
+}
+
+export async function fetchMessagePage(
+  client: KiloClient,
+  input: {
+    sessionID: string
+    workspaceDir: string
+    limit: number
+    before?: string
+    signal?: AbortSignal
+  },
+) {
+  // limit: 0 is the server contract for "return every message" — used by
+  // the sub-agent viewer, which has no "load earlier" UI.
+  const full = input.limit === 0
+  const read = async (before?: string) => {
+    const result = await retry(() =>
+      client.session.messages(
+        { sessionID: input.sessionID, directory: input.workspaceDir, limit: input.limit, before },
+        { throwOnError: true, signal: input.signal },
+      ),
+    )
+    // When a proxy/auth gateway strips X-Next-Cursor but the response fills
+    // the requested limit, synthesize a cursor from the oldest item so the
+    // "load earlier" path keeps working. Risk of one extra empty request is
+    // preferable to silently hiding older history. Never synthesize for
+    // full loads — those return everything by contract.
+    const items = result.data
+    const header = result.response.headers.get("X-Next-Cursor")
+    const cursor = full
+      ? undefined
+      : (header ?? (items.length >= input.limit && items[0] ? synthesizeCursor(items[0]) : undefined))
+    return { items, cursor }
+  }
+
+  const suffix = (items: Awaited>["items"]) => {
+    const index = [...items].reverse().findIndex((item) => item.info.role === "user")
+    if (index === -1) return items
+    return items.slice(items.length - index - 1)
+  }
+
+  const fill = async (page: Awaited>): Promise>> => {
+    if (page.items[0]?.info.role !== "assistant") return page
+    if (!page.cursor || input.signal?.aborted) return page
+    const next = await read(page.cursor)
+    const items = [...suffix(next.items), ...page.items]
+    return fill({ items, cursor: next.cursor })
+  }
+
+  return fill(await read(input.before))
+}
diff --git a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts
index 0e55b48d825..1c5c3eafe49 100644
--- a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts
+++ b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts
@@ -139,16 +139,22 @@ function slimWrite(state: Record): Record {
   return next
 }
 
-/** bash: truncate metadata.output (up to 30KB) and state.output (up to 50KB). */
-function slimBash(state: Record): Record {
+/** read/list/search: keep the rendered tool details lightweight on historical loads. */
+function slimOutput(state: Record): Record {
   const next = { ...state }
+  if (typeof state.output === "string" && state.output.length > OUTPUT_CAP) {
+    next.output = cap(state.output)
+  }
+  return next
+}
+
+/** bash: truncate metadata.output and state.output. */
+function slimBash(state: Record): Record {
+  const next = slimOutput(state)
   const meta = state.metadata
   if (isObj(meta) && typeof meta.output === "string" && meta.output.length > OUTPUT_CAP) {
     next.metadata = { ...meta, output: cap(meta.output) }
   }
-  if (typeof state.output === "string" && (state.output as string).length > OUTPUT_CAP) {
-    next.output = cap(state.output)
-  }
   return next
 }
 
@@ -157,6 +163,10 @@ function slimBash(state: Record): Record {
 // ---------------------------------------------------------------------------
 
 const slimmers: Record) => Record> = {
+  read: slimOutput,
+  list: slimOutput,
+  glob: slimOutput,
+  grep: slimOutput,
   edit: slimEdit,
   apply_patch: slimPatch,
   multiedit: slimMultiedit,
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
new file mode 100644
index 00000000000..04a10c4f5bb
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts
@@ -0,0 +1,244 @@
+import { describe, it, expect } from "bun:test"
+
+// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
+const { KiloProvider } = await import("../../src/KiloProvider")
+
+type State = "connecting" | "connected" | "disconnected" | "error"
+
+interface Deferred {
+  promise: Promise
+  resolve: (value: T) => void
+  reject: (reason?: unknown) => void
+}
+
+function defer(): Deferred {
+  let resolve!: (value: T) => void
+  let reject!: (reason?: unknown) => void
+  const promise = new Promise((res, rej) => {
+    resolve = res
+    reject = rej
+  })
+  return { promise, resolve, reject }
+}
+
+function mkMessage(id: string, role: "user" | "assistant", time = 0) {
+  return {
+    info: {
+      id,
+      sessionID: "s1",
+      role,
+      time: { created: time },
+    },
+    parts: [],
+  }
+}
+
+function mkResult(items: unknown[]) {
+  return { data: items, response: { headers: new Headers() } }
+}
+
+function createClient(options?: {
+  messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }>
+  messagesData?: unknown[]
+  deleteDeferred?: Deferred
+}) {
+  const calls: { before?: string; limit?: number }[] = []
+  return {
+    calls,
+    session: {
+      list: async () => ({ data: [] }),
+      get: async () => ({ data: null }),
+      status: async () => ({ data: {} }),
+      messages: async (params: { before?: string; limit?: number }) => {
+        calls.push({ before: params.before, limit: params.limit })
+        if (options?.messagesDeferred) return options.messagesDeferred.promise
+        return mkResult(options?.messagesData ?? [])
+      },
+      delete: async () => {
+        if (options?.deleteDeferred) return options.deleteDeferred.promise
+        return { data: {} }
+      },
+    },
+    provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) },
+    app: { agents: async () => ({ data: [] }) },
+    config: { get: async () => ({ data: {} }) },
+    kilo: {
+      notifications: async () => ({ data: [] }),
+      profile: async () => ({ data: {} }),
+    },
+    command: { list: async () => ({ data: [] }) },
+  }
+}
+
+function createConnection(client: ReturnType) {
+  return {
+    connect: async () => {},
+    getClient: () => client,
+    onEventFiltered: () => () => undefined,
+    onStateChange: (_l: (s: State) => void) => () => undefined,
+    onNotificationDismissed: () => () => undefined,
+    onLanguageChanged: () => () => undefined,
+    onProfileChanged: () => () => undefined,
+    onMigrationComplete: () => () => undefined,
+    onFavoritesChanged: () => () => undefined,
+    onClearPendingPrompts: () => () => undefined,
+    registerDirectoryProvider: () => () => undefined,
+    getServerInfo: () => ({ port: 12345 }),
+    getConnectionState: () => "connected" as const,
+    resolveEventSessionId: () => undefined,
+    recordMessageSessionId: () => undefined,
+    notifyNotificationDismissed: () => undefined,
+    pruneSession: () => undefined,
+    registerFocused: () => undefined,
+    unregisterFocused: () => undefined,
+  }
+}
+
+type ProviderInternals = {
+  connectionState: State
+  webview: { postMessage: (message: unknown) => Promise } | null
+  trackedSessionIds: Set
+  handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise
+  handleDeleteSession: (sid: string) => Promise
+}
+
+function makeProvider(client: ReturnType) {
+  const connection = createConnection(client)
+  const provider = new KiloProvider({} as never, connection as never)
+  const internal = provider as unknown as ProviderInternals
+  internal.connectionState = "connected"
+  const sent: unknown[] = []
+  internal.webview = {
+    postMessage: async (message: unknown) => {
+      sent.push(message)
+    },
+  }
+  return { provider, internal, sent }
+}
+
+describe("KiloProvider.handleLoadMessages / focus mode freshness", () => {
+  it("refetches the tail page on focus-mode reselection and posts a reconcile snapshot", async () => {
+    // Regression: switching to an already-loaded session sent mode: "focus"
+    // which only refreshed session metadata and status — not messages. If
+    // SSE dropped events during the gap (reconnect, missed child-task
+    // messages, backend crash-restart) the webview showed stale content with
+    // no way to recover short of reloading the extension. Focus mode must
+    // still reconcile the tail against the server snapshot so silent drift
+    // self-heals on the next session switch.
+    const messages = [
+      mkMessage("m1", "user", 1),
+      mkMessage("m2", "assistant", 2),
+      mkMessage("m3", "user", 3), // delivered after SSE reconnect, missed by webview
+    ]
+    const client = createClient({ messagesData: messages })
+    const { internal, sent } = makeProvider(client)
+    internal.trackedSessionIds.add("s1")
+
+    await internal.handleLoadMessages("s1", { mode: "focus" })
+
+    // Server must be hit to reconcile the current state.
+    expect(client.calls.length).toBeGreaterThanOrEqual(1)
+
+    // Must post a messagesLoaded snapshot tagged reconcile — not replace —
+    // so the webview merges without tearing down existing reactive proxies.
+    const loaded = sent.find(
+      (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
+    ) as { mode?: string; messages: { id: string }[] } | undefined
+    expect(loaded).toBeDefined()
+    expect(loaded!.mode).toBe("reconcile")
+    expect(loaded!.messages.map((m) => m.id)).toContain("m3")
+  })
+
+  it("throttles repeat focus-mode reconciles within 1s", async () => {
+    // Regression: rapid session tab switching (A→B→A) used to stack up one
+    // reconcile fetch per click, each doing a full-page fetch + 80-message
+    // reactive-store reconcile. A 1s throttle kills the redundant work while
+    // still catching SSE drops on normal use patterns.
+    const client = createClient({ messagesData: [mkMessage("m1", "user", 1)] })
+    const { internal } = makeProvider(client)
+    internal.trackedSessionIds.add("s1")
+
+    await internal.handleLoadMessages("s1", { mode: "focus" })
+    const callsAfterFirst = client.calls.length
+
+    // Second focus within the throttle window — no fetch should happen.
+    await internal.handleLoadMessages("s1", { mode: "focus" })
+    expect(client.calls.length).toBe(callsAfterFirst)
+  })
+
+  it("does not post messagesLoaded on focus when the session is no longer tracked", async () => {
+    // Defensive: if the user deletes the session while the background focus
+    // refetch is in flight, drop the response (same invariant as prepend).
+    const messages = defer<{ data: unknown[]; response: { headers: Headers } }>()
+    const client = createClient({ messagesDeferred: messages })
+    const { internal, sent } = makeProvider(client)
+    internal.trackedSessionIds.add("s1")
+
+    const load = internal.handleLoadMessages("s1", { mode: "focus" })
+    await internal.handleDeleteSession("s1")
+    messages.resolve(mkResult([mkMessage("m1", "user", 10)]))
+    await load
+
+    const loaded = sent.filter(
+      (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
+    )
+    expect(loaded).toEqual([])
+  })
+})
+
+describe("KiloProvider.loadMessages / sub-agent viewer full history", () => {
+  it("loads all messages without the MESSAGE_PAGE_LIMIT cap (sub-agent viewer needs full turn history)", async () => {
+    // Regression: SubAgentViewerProvider used to call client.session.messages
+    // with no limit, loading every turn. After switching to provider.loadMessages
+    // it inherited the 80-message page cap and sub-agents with more than 80
+    // turns would open truncated with no visible indicator. loadMessages() is
+    // the sub-agent viewer's single entry point — it must request the full
+    // transcript.
+    const big = Array.from({ length: 200 }, (_, i) => mkMessage(`m${i}`, i % 2 === 0 ? "user" : "assistant", i))
+    const client = createClient({ messagesData: big })
+    const { provider, sent } = makeProvider(client)
+
+    await provider.loadMessages("s1")
+
+    const loaded = sent.find(
+      (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
+    ) as { messages: unknown[] } | undefined
+    expect(loaded).toBeDefined()
+    expect(loaded!.messages).toHaveLength(200)
+
+    // Server contract: limit: 0 (or undefined) returns everything.
+    expect(client.calls).toHaveLength(1)
+    const limit = client.calls[0]?.limit
+    expect(limit === undefined || limit === 0).toBe(true)
+  })
+})
+
+describe("KiloProvider.handleLoadMessages / prepend into deleted session", () => {
+  it("does not post messagesLoaded for a session deleted mid-prepend", async () => {
+    // Regression: handleLoadMessages fires fire-and-forget from the webview
+    // message dispatcher. If the user deletes the session while a prepend
+    // fetch is in flight, the response still arrives and posts messagesLoaded
+    // for a now-dead session ID, resurrecting a ghost entry in the webview
+    // store until something else clears it.
+    const messages = defer<{ data: unknown[]; response: { headers: Headers } }>()
+    const client = createClient({ messagesDeferred: messages })
+    const { internal, sent } = makeProvider(client)
+
+    // Simulate the session being tracked (as it would after the initial load).
+    internal.trackedSessionIds.add("s1")
+
+    const load = internal.handleLoadMessages("s1", { mode: "prepend", before: "cursor-1", limit: 80 })
+
+    // User deletes the session while the fetch is still pending.
+    await internal.handleDeleteSession("s1")
+
+    // Fetch finally resolves after deletion.
+    messages.resolve(mkResult([mkMessage("m1", "user", 10)]))
+    await load
+
+    const loaded = sent.filter(
+      (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded",
+    )
+    expect(loaded).toEqual([])
+  })
+})
diff --git a/packages/kilo-vscode/tests/unit/message-page.test.ts b/packages/kilo-vscode/tests/unit/message-page.test.ts
new file mode 100644
index 00000000000..4fed55e19d0
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/message-page.test.ts
@@ -0,0 +1,118 @@
+import { describe, it, expect } from "bun:test"
+import { fetchMessagePage } from "../../src/kilo-provider/message-page"
+
+type Message = { info: { id: string; role: "user" | "assistant"; time: { created: number } }; parts: unknown[] }
+
+function message(id: string, role: "user" | "assistant", time: number): Message {
+  return { info: { id, role, time: { created: time } }, parts: [] }
+}
+
+function mockClient(pages: { items: Message[]; cursor?: string }[]) {
+  const calls: { before?: string; limit?: number }[] = []
+  let idx = 0
+  const client = {
+    session: {
+      messages: async (
+        params: { sessionID: string; directory: string; limit: number; before?: string },
+        _opts: { throwOnError: boolean; signal?: AbortSignal },
+      ) => {
+        calls.push({ before: params.before, limit: params.limit })
+        const page = pages[idx++]
+        if (!page) throw new Error("no more mock pages")
+        const headers = new Headers()
+        if (page.cursor) headers.set("X-Next-Cursor", page.cursor)
+        return {
+          data: page.items,
+          response: { headers } as Response,
+        }
+      },
+    },
+  }
+  return { client, calls }
+}
+
+describe("fetchMessagePage / cursor fallback", () => {
+  it("returns server cursor when X-Next-Cursor header is present", async () => {
+    const { client } = mockClient([
+      {
+        items: [message("m1", "user", 1), message("m2", "assistant", 2), message("m3", "user", 3)],
+        cursor: "server-cursor-abc",
+      },
+    ])
+    const page = await fetchMessagePage(client as never, {
+      sessionID: "s1",
+      workspaceDir: "/repo",
+      limit: 3,
+    })
+    expect(page.cursor).toBe("server-cursor-abc")
+  })
+
+  it("synthesizes a cursor when server omits X-Next-Cursor but page is full (header stripped by proxy / missing permission)", async () => {
+    // Regression: if a proxy or auth layer strips X-Next-Cursor, the webview
+    // loses access to older messages even when they exist. When the response
+    // fills the requested limit, derive a cursor from the oldest item so the
+    // "load earlier" path keeps working.
+    const { client } = mockClient([
+      {
+        items: [
+          message("m1", "user", 10),
+          message("m2", "assistant", 20),
+          message("m3", "user", 30),
+          message("m4", "assistant", 40),
+        ],
+        // Intentionally no cursor — simulating a stripped header.
+      },
+    ])
+    const page = await fetchMessagePage(client as never, {
+      sessionID: "s1",
+      workspaceDir: "/repo",
+      limit: 4,
+    })
+    expect(page.cursor).toBeDefined()
+    // Cursor must be a base64url-encoded { id, time } of the oldest item so
+    // the server's before parser accepts it on the next request.
+    const decoded = JSON.parse(Buffer.from(page.cursor!, "base64url").toString("utf8"))
+    expect(decoded).toEqual({ id: "m1", time: 10 })
+  })
+
+  it("leaves cursor undefined when server omits header AND page is not full (truly no more)", async () => {
+    const { client } = mockClient([
+      {
+        items: [message("m1", "user", 10), message("m2", "assistant", 20)],
+      },
+    ])
+    const page = await fetchMessagePage(client as never, {
+      sessionID: "s1",
+      workspaceDir: "/repo",
+      limit: 80,
+    })
+    expect(page.cursor).toBeUndefined()
+  })
+
+  it("synthesized cursor round-trips through the server's before parameter", async () => {
+    // First page: server strips header, items fill limit → cursor synthesized.
+    // Next page request uses that cursor and returns more items.
+    const { client, calls } = mockClient([
+      {
+        items: [message("m3", "user", 30), message("m4", "assistant", 40)],
+      },
+      {
+        items: [message("m1", "user", 10), message("m2", "assistant", 20)],
+      },
+    ])
+    const first = await fetchMessagePage(client as never, {
+      sessionID: "s1",
+      workspaceDir: "/repo",
+      limit: 2,
+    })
+    expect(first.cursor).toBeDefined()
+
+    await fetchMessagePage(client as never, {
+      sessionID: "s1",
+      workspaceDir: "/repo",
+      limit: 2,
+      before: first.cursor,
+    })
+    expect(calls[1]?.before).toBe(first.cursor)
+  })
+})
diff --git a/packages/kilo-vscode/tests/unit/part-stash.test.ts b/packages/kilo-vscode/tests/unit/part-stash.test.ts
new file mode 100644
index 00000000000..58d25f6aff8
--- /dev/null
+++ b/packages/kilo-vscode/tests/unit/part-stash.test.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect } from "bun:test"
+import { PartStash } from "../../webview-ui/src/context/part-stash"
+import type { Part } from "../../webview-ui/src/types/messages"
+
+function text(id: string, messageID: string, value: string): Part {
+  return { type: "text", id, messageID, text: value } as Part
+}
+
+describe("PartStash", () => {
+  it("put / peek round-trips parts", () => {
+    const stash = new PartStash()
+    stash.put("m1", [text("p1", "m1", "hi")])
+    const peeked = stash.peek("m1")
+    expect(peeked?.[0] && "text" in peeked[0] ? peeked[0].text : undefined).toBe("hi")
+  })
+
+  it("remove() clears stashed parts — regression for handleMessageRemoved leak", () => {
+    // Before the fix, handleMessageRemoved wiped reactive parts but left the
+    // stash entry alive. If an off-screen message was removed before its turn
+    // mounted, its parts would sit in the stash forever. Worse: a later call
+    // to peek() or getParts() could surface the parts of a deleted message.
+    const stash = new PartStash()
+    stash.put("m1", [text("p1", "m1", "stale")])
+    stash.remove("m1")
+    expect(stash.peek("m1")).toBeUndefined()
+    expect(stash.size()).toBe(0)
+  })
+
+  it("take() consumes stashed parts atomically", () => {
+    const stash = new PartStash()
+    stash.put("m1", [text("p1", "m1", "a")])
+    stash.put("m2", [text("p2", "m2", "b")])
+    const taken = stash.take(["m1", "m2"])
+    expect(Object.keys(taken).sort()).toEqual(["m1", "m2"])
+    expect(stash.size()).toBe(0)
+  })
+
+  it("take() skips IDs already hydrated into the reactive store", () => {
+    const stash = new PartStash()
+    stash.put("m1", [text("p1", "m1", "stash")])
+    const taken = stash.take(["m1"], (id) => id === "m1")
+    expect(taken).toEqual({})
+    // The stash entry should be preserved — hydrateParts will noop and
+    // subsequent SSE updates that target the message can still merge into it
+    // if needed.
+    expect(stash.peek("m1")).toBeDefined()
+  })
+
+  it("take() returns empty when no IDs match", () => {
+    const stash = new PartStash()
+    stash.put("m1", [text("p1", "m1", "a")])
+    expect(stash.take(["m2", "m3"])).toEqual({})
+  })
+})
diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png
index 238361cf9e6..790e8c736a0 100644
--- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png
+++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png
@@ -1,3 +1,3 @@
 version https://git-lfs.github.com/spec/v1
-oid sha256:8348db9191e1e616c93bf54ae33702a57e25c092a8f80a24d1da04811e523500
-size 26519
+oid sha256:4294f36eea4005ca5f3f6d4a3f7ed84d5113f7454c2149280f5182ccbc686124
+size 26407
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
index 53f8d494d83..ffdd0c1031b 100644
--- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
+++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx
@@ -762,20 +762,28 @@ const AgentManagerContent: Component = () => {
     return result
   })
 
-  // Sessions for the currently selected worktree (tab bar), respecting custom order if set
+  // Oldest-first sort before applyTabOrder — worktree label and tab bar must agree on "first session".
+  const sessionsForWorktree = (worktreeId: string): SessionInfo[] => {
+    const ids = new Set(
+      managedSessions()
+        .filter((ms) => ms.worktreeId === worktreeId)
+        .map((ms) => ms.id),
+    )
+    return applyTabOrder(
+      session
+        .sessions()
+        .filter((s) => ids.has(s.id))
+        .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()),
+      worktreeTabOrder()[worktreeId],
+    )
+  }
+
   const activeWorktreeSessions = createMemo((): SessionInfo[] => {
     const sel = selection()
     if (!sel || sel === LOCAL) return []
-    const managed = managedSessions().filter((ms) => ms.worktreeId === sel)
-    const ids = new Set(managed.map((ms) => ms.id))
-    const sessions = session
-      .sessions()
-      .filter((s) => ids.has(s.id))
-      .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
-    return applyTabOrder(sessions, worktreeTabOrder()[sel])
+    return sessionsForWorktree(sel)
   })
 
-  // Active tab sessions: local sessions when on "local", worktree sessions otherwise
   const activeTabs = createMemo((): SessionInfo[] => {
     const sel = selection()
     if (sel === LOCAL) return localSessions()
@@ -783,11 +791,10 @@ const AgentManagerContent: Component = () => {
     return []
   })
 
-  // Whether the selected context has zero sessions
   const contextEmpty = createMemo(() => {
     const sel = selection()
     if (sel === LOCAL) return localSessionIDs().length === 0
-    if (sel) return activeWorktreeSessions().length === 0
+    if (sel) return activeWorktreeSessions().length === 0 && managedSessions().every((ms) => ms.worktreeId !== sel)
     return false
   })
 
@@ -802,8 +809,6 @@ const AgentManagerContent: Component = () => {
     }
   })
 
-  // Scroll the sidebar to the focused item whenever selection changes (covers keyboard
-  // navigation, new worktree creation, and any other programmatic selection change).
   createEffect(() => {
     const id = selection() ?? session.currentSessionID()
     if (!id) return
@@ -813,22 +818,16 @@ const AgentManagerContent: Component = () => {
     })
   })
 
-  // Read-only mode: viewing an unassigned session (not in a worktree or local)
   const readOnly = createMemo(() => selection() === null && !!session.currentSessionID())
 
-  // Tab scroll: hidden scrollbar with fade overflow indicators
   const visibleTabId = createMemo(() =>
     reviewActive() ? REVIEW_TAB_ID : (session.currentSessionID() ?? activePendingId()),
   )
   const tabScroll = useTabScroll(activeTabs, visibleTabId)
 
-  // Display name for worktree — prefers persisted label, then first session title, then branch
   const worktreeLabel = (wt: WorktreeState): string => {
     if (wt.label) return wt.label
-    const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id)
-    const ids = new Set(managed.map((ms) => ms.id))
-    const sessions = session.sessions().filter((s) => ids.has(s.id))
-    return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
+    return firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch)
   }
 
   const worktreeSubtitle = (wt: WorktreeState): string | undefined => {
@@ -838,7 +837,6 @@ const AgentManagerContent: Component = () => {
 
   const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId)
 
-  /** True when any session in the given ID list is actively working (busy/retry and not blocked by permissions/questions). */
   const isAnySessionBusy = (ids: string[]): boolean => {
     if (ids.length === 0) return false
     const statuses = session.allStatusMap()
@@ -1008,12 +1006,15 @@ const AgentManagerContent: Component = () => {
   const selectWorktree = (worktreeId: string) => {
     saveTabMemory()
     setSelection(worktreeId)
+    // Try rich session list first, fall back to managed session IDs when
+    // session.sessions() hasn't been populated yet for this worktree.
+    const rich = sessionsForWorktree(worktreeId)
     const managed = managedSessions().filter((ms) => ms.worktreeId === worktreeId)
-    const ids = new Set(managed.map((ms) => ms.id))
-    const sessions = session.sessions().filter((s) => ids.has(s.id))
     const remembered = tabMemory()[worktreeId]
-    const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
-    const fallback = target ?? sessions[0]
+    const target = remembered
+      ? (rich.find((s) => s.id === remembered) ?? managed.find((ms) => ms.id === remembered))
+      : undefined
+    const fallback = target ?? rich[0] ?? managed[0]
     if (fallback) session.selectSession(fallback.id)
     else session.setCurrentSessionID(undefined)
     setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true)
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
index 752bebe3964..9f5801e121f 100644
--- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
+++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx
@@ -1,13 +1,13 @@
 /**
  * MessageList component
- * Scrollable turn-based message list.
+ * Scrollable turn-based message list with virtualization.
  * Each user message is rendered as a VscodeSessionTurn — a custom component that
  * renders all assistant parts as a flat, verbose list with no context grouping,
  * and fully expands sub-agent (task tool) parts inline.
  * Shows recent sessions in the empty state for quick resumption.
  */
 
-import { Component, For, Show, createEffect, createMemo, onCleanup, JSX } from "solid-js"
+import { Component, For, Show, createEffect, createMemo, createSignal, on, onCleanup, JSX } from "solid-js"
 import { Icon } from "@kilocode/kilo-ui/icon"
 import { Spinner } from "@kilocode/kilo-ui/spinner"
 import { useDialog } from "@kilocode/kilo-ui/context/dialog"
@@ -17,12 +17,13 @@ import { useServer } from "../../context/server"
 import { useLanguage } from "../../context/language"
 import { formatRelativeDate } from "../../utils/date"
 import { FeedbackDialog } from "./FeedbackDialog"
-import { VscodeSessionTurn } from "./VscodeSessionTurn"
+import { VscodeSessionTurn, type VscodeTurn } from "./VscodeSessionTurn"
 import { RevertBanner } from "./RevertBanner"
 import { AccountSwitcher } from "../shared/AccountSwitcher"
 import { KiloNotifications } from "./KiloNotifications"
 import { WorkingIndicator } from "../shared/WorkingIndicator"
 import { QuestionDock } from "./QuestionDock"
+import { Virtualizer } from "virtua/solid"
 import { SuggestBar } from "./SuggestBar"
 import { activeUserMessageID as getActiveUserMessageID } from "../../context/session-queue"
 import type { QuestionRequest, SuggestionRequest } from "../../types/messages"
@@ -74,14 +75,25 @@ export const MessageList: Component = (props) => {
     }
   })
 
-  const allUserMessages = () => session.userMessages()
+  const [scrollEl, setScrollEl] = createSignal()
+  const positions = new Map()
+
   const boundary = () => session.revert()?.messageID
-  const userMessages = createMemo(() => {
+  const turns = createMemo(() => {
+    const result: VscodeTurn[] = []
     const b = boundary()
-    if (!b) return allUserMessages()
-    return allUserMessages().filter((m) => m.id < b)
+    for (const msg of session.messages()) {
+      if (msg.role === "user") {
+        if (b && msg.id >= b) break
+        result.push({ id: msg.id, user: msg, assistant: [] })
+        continue
+      }
+      const turn = result[result.length - 1]
+      if (turn && msg.role === "assistant") turn.assistant.push(msg)
+    }
+    return result
   })
-  const isEmpty = () => userMessages().length === 0 && !session.loading() && !boundary()
+  const isEmpty = () => turns().length === 0 && !session.loading() && !boundary()
 
   const recent = createMemo(() =>
     [...session.sessions()]
@@ -94,9 +106,67 @@ export const MessageList: Component = (props) => {
   const activeUserIndex = createMemo(() => {
     const active = activeUserID()
     if (!active) return -1
-    return userMessages().findIndex((msg) => msg.id === active)
+    return turns().findIndex((turn) => turn.user.id === active)
   })
 
+  const save = (id: string | undefined) => {
+    const el = scrollEl()
+    if (!id || !el) return
+    positions.set(id, { top: el.scrollTop, userScrolled: autoScroll.userScrolled() })
+  }
+
+  const maybeLoadOlder = () => {
+    const el = scrollEl()
+    if (!el || el.scrollTop > 600) return
+    session.loadOlderMessages()
+  }
+
+  const handleScroll = () => {
+    autoScroll.handleScroll()
+    maybeLoadOlder()
+  }
+
+  const setScrollRef = (el: HTMLElement | undefined) => {
+    setScrollEl(el)
+    autoScroll.scrollRef(el)
+  }
+
+  const [pendingRestore, setPendingRestore] = createSignal()
+
+  createEffect(
+    on(session.currentSessionID, (id, prev) => {
+      save(prev)
+      setPendingRestore(id)
+    }),
+  )
+
+  createEffect(() => {
+    const id = pendingRestore()
+    if (!id || session.loading()) return
+    turns().length
+    // Double-rAF: the first frame lets the browser paint the new DOM from
+    // the messagesLoaded batch. The second frame restores scroll position
+    // without forcing a synchronous layout reflow mid-paint.
+    requestAnimationFrame(() => {
+      requestAnimationFrame(() => {
+        if (pendingRestore() !== id) return
+        const el = scrollEl()
+        if (!el) return
+        const pos = positions.get(id)
+        if (pos?.userScrolled) {
+          el.scrollTop = pos.top
+          autoScroll.pause()
+        } else {
+          autoScroll.forceScrollToBottom()
+        }
+        setPendingRestore(undefined)
+        maybeLoadOlder()
+      })
+    })
+  })
+
+  onCleanup(() => save(session.currentSessionID()))
+
   return (
     
@@ -105,13 +175,7 @@ export const MessageList: Component = (props) => {
-
+
@@ -153,24 +217,37 @@ export const MessageList: Component = (props) => {
- - - {(msg, index) => { - const queued = createMemo(() => { - const active = activeUserIndex() - if (active === -1) return false - return index() > active - }) + + +
+ + {language.t("session.messages.loadingEarlier")} +
+
+ + + + + + {(turn, index) => { + const queued = createMemo(() => { + const active = activeUserIndex() + if (active === -1) return false + return index() > active + }) - return ( - - ) - }} -
+ return + }} + +
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx index 49fdda93a4a..4daaaa6f318 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx @@ -32,6 +32,7 @@ import { ErrorDisplay } from "./ErrorDisplay" import { useServer } from "../../context/server" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" +import type { Message as WebMessage } from "../../types/messages" function getDirectory(path: string): string { const sep = path.includes("/") ? "/" : "\\" @@ -45,9 +46,14 @@ function getFilename(path: string): string { return idx === -1 ? path : path.slice(idx + 1) } +export interface VscodeTurn { + id: string + user: WebMessage + assistant: WebMessage[] +} + interface VscodeSessionTurnProps { - sessionID: string - messageID: string + turn: VscodeTurn queued?: boolean } @@ -59,45 +65,22 @@ export const VscodeSessionTurn: Component = (props) => { const session = useSession() const language = useLanguage() - const emptyMessages: SDKMessage[] = [] const emptyParts: SDKPart[] = [] const emptyDiffs: SnapshotFileDiff[] = [] - const allMessages = createMemo(() => { - const msgs = data.store.message?.[props.sessionID] - return (msgs ?? emptyMessages) as SDKMessage[] + createEffect(() => { + const turn = props.turn + session.hydrateParts([turn.user.id, ...turn.assistant.map((m) => m.id)]) }) - const message = createMemo(() => { - return allMessages().find((m) => m.id === props.messageID && m.role === "user") as - | (SDKMessage & { role: "user" }) - | undefined - }) + const message = createMemo(() => props.turn.user as SDKMessage & { role: "user" }) const parts = createMemo(() => { const msg = message() - if (!msg) return emptyParts return (data.store.part?.[msg.id] ?? emptyParts) as SDKPart[] }) - const messageIndex = createMemo(() => { - const msgs = allMessages() - return msgs.findIndex((m) => m.id === props.messageID) - }) - - const assistantMessages = createMemo(() => { - const index = messageIndex() - if (index < 0) return [] as SDKAssistantMessage[] - const msgs = allMessages() - const result: SDKAssistantMessage[] = [] - for (let i = index + 1; i < msgs.length; i++) { - const m = msgs[i] - if (!m) continue - if (m.role === "user") break - if (m.role === "assistant") result.push(m as SDKAssistantMessage) - } - return result - }) + const assistantMessages = createMemo(() => props.turn.assistant as SDKAssistantMessage[]) const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) @@ -174,7 +157,7 @@ export const VscodeSessionTurn: Component = (props) => { assistantMessages().length > 0 && !session.revert() ? () => { if (session.status() !== "idle") return - session.revertSession(props.messageID) + session.revertSession(msg().id) } : undefined } diff --git a/packages/kilo-vscode/webview-ui/src/context/part-stash.ts b/packages/kilo-vscode/webview-ui/src/context/part-stash.ts new file mode 100644 index 00000000000..9d622ce9fc4 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/part-stash.ts @@ -0,0 +1,63 @@ +/** + * PartStash holds message parts outside the reactive Solid store until a + * turn is actually rendered by the virtualizer. Writing parts for off-screen + * messages into the reactive store triggers expensive DOM work for invisible + * content — parking them here keeps initial-load churn cheap. + * + * The stash lives alongside (not inside) the reactive store. Every lifecycle + * event that invalidates a message must reach both the store and the stash. + * Centralising stash access behind this helper keeps that invariant easy to + * audit (and easy to unit-test, since the store is Solid-specific). + */ +import type { Part } from "../types/messages" + +export class PartStash { + private map = new Map() + + /** Stash parts for a message that hasn't been rendered yet. */ + put(messageID: string, parts: Part[]): void { + this.map.set(messageID, parts) + } + + /** Read without consuming. Returns `undefined` if absent. */ + peek(messageID: string): Part[] | undefined { + return this.map.get(messageID) + } + + /** + * Invalidate any stashed parts for a message. Callers MUST invoke this in + * every path that removes a message from state (messageRemoved, + * sendMessageFailed, sessionDeleted) or promotes it into the reactive + * store (messageCreated, partUpdated, hydrateParts). Missing a call here + * leaks memory and, worse, can resurface stale parts via `peek()` after + * the message is gone. + */ + remove(messageID: string): void { + this.map.delete(messageID) + } + + /** + * Collect parts for the given IDs, consuming the stash. Used by the + * virtualizer when a turn is about to render: the returned parts should + * be written to the reactive store atomically by the caller. + * + * IDs already present in the reactive store are skipped — pass an optional + * `isHydrated` predicate for that check. + */ + take(ids: string[], isHydrated?: (id: string) => boolean): Record { + const out: Record = {} + for (const id of ids) { + if (isHydrated?.(id)) continue + const parts = this.map.get(id) + if (!parts) continue + out[id] = parts + this.map.delete(id) + } + return out + } + + /** Diagnostics and tests only. */ + size(): number { + return this.map.size + } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 2890c8a18f5..d4dcdfa4842 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -32,6 +32,7 @@ import type { FileAttachment, SendMessageFailedMessage, McpStatusEntry, + MessageLoadMode, } from "../types/messages" import { removeSessionPermissions, upsertPermission } from "./permission-queue" import { @@ -46,9 +47,29 @@ import { Identifier } from "../utils/id" import { resolveModelSelection } from "./model-selection" import { resolveSessionAgent } from "./session-agent" import { queuedUserMessageIDs } from "./session-queue" +import { PartStash } from "./part-stash" import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model" const RECENT_LIMIT = 5 +const MESSAGE_PAGE_LIMIT = 80 + +type MessageMutation = Exclude | "append" | "update" + +interface MessagePageState { + initialLoaded: boolean + loadingInitial: boolean + loadingOlder: boolean + before?: string + hasMore: boolean + lastMutation?: MessageMutation +} + +const emptyPageState: MessagePageState = { + initialLoaded: false, + loadingInitial: false, + loadingOlder: false, + hasMore: false, +} // Store structure for messages and parts interface SessionStore { @@ -79,6 +100,9 @@ interface SessionContextValue { statusText: Accessor busySince: Accessor loading: Accessor + loadingOlderMessages: Accessor + hasOlderMessages: Accessor + messageMutation: Accessor // Messages for current session messages: Accessor @@ -105,6 +129,10 @@ interface SessionContextValue { // Parts for a specific message getParts: (messageID: string) => Part[] + // Move stashed parts into the reactive store for the given message IDs. + // Called by VscodeSessionTurn when the virtualizer renders a turn. + hydrateParts: (messageIDs: string[]) => void + // Todos for current session todos: Accessor @@ -202,6 +230,7 @@ interface SessionContextValue { createSession: () => void clearCurrentSession: () => void loadSessions: () => void + loadOlderMessages: () => void selectSession: (id: string) => void deleteSession: (id: string) => void renameSession: (id: string, title: string) => void @@ -246,6 +275,13 @@ export const SessionProvider: ParentComponent = (props) => { const [loading, setLoading] = createSignal(false) const [loaded, setLoaded] = createSignal>(new Set()) + const [pages, setPages] = createStore>({}) + + // Parts stash: holds parts from messagesLoaded outside the reactive store + // until a VscodeSessionTurn is rendered by the virtualizer and calls + // hydrateParts(). This avoids writing parts for off-screen messages into + // the store, which would trigger expensive DOM work for invisible content. + const stash = new PartStash() // Pending permissions const [permissions, setPermissions] = createSignal([]) @@ -641,6 +677,11 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "requestFavorites" }) onCleanup(unsubFavorites) + function handleError(message: Extract) { + if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false) + if (message.sessionID) patchPage(message.sessionID, { loadingInitial: false, loadingOlder: false }) + } + function toggleFavorite(providerID: string, modelID: string) { const key = `${providerID}/${modelID}` const idx = store.favoriteModels.findIndex((f) => `${f.providerID}/${f.modelID}` === key) @@ -679,7 +720,11 @@ export const SessionProvider: ParentComponent = (props) => { break case "messagesLoaded": - handleMessagesLoaded(message.sessionID, message.messages) + handleMessagesLoaded(message.sessionID, message.messages, { + mode: message.mode, + cursor: message.cursor, + hasMore: message.hasMore, + }) break case "messageCreated": @@ -751,9 +796,7 @@ export const SessionProvider: ParentComponent = (props) => { } case "error": - // Only clear loading if the error is for the current session - // (or has no sessionID for backwards compatibility) - if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false) + handleError(message) break case "sendMessageFailed": @@ -822,7 +865,72 @@ export const SessionProvider: ParentComponent = (props) => { }) } - function handleMessagesLoaded(sessionID: string, messages: Message[]) { + function patchPage(sessionID: string, patch: Partial) { + setPages(sessionID, { ...(pages[sessionID] ?? emptyPageState), ...patch }) + } + + function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { + if (mode === "reconcile") { + // Tail reconcile: incoming is the authoritative newest-N snapshot. + // Local state may already hold some of those IDs and may also hold + // newer optimistic entries created after the fetch was taken. Merge + // by id (server wins on collision) then sort by createdAt so new + // server messages land in the right position and optimistic tail + // entries stay at the end. + const byId = new Map() + for (const msg of current) byId.set(msg.id, msg) + for (const msg of incoming) byId.set(msg.id, msg) + return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + } + const seen = new Set() + const source = mode === "prepend" ? [...incoming, ...current] : incoming + return source.filter((msg) => { + if (seen.has(msg.id)) return false + seen.add(msg.id) + return true + }) + } + + function withPending(sessionID: string, messages: Message[]) { + const pending = pendingOptimistic.get(sessionID) + if (!pending || pending.size === 0) return messages + const ids = new Set(messages.map((msg) => msg.id)) + const current = store.messages[sessionID] ?? [] + const orphans = current.filter((msg) => pending.has(msg.id) && !ids.has(msg.id)) + return [...messages, ...orphans] + } + + // Cheap shape check: same ids in same order AND same part counts per message. + // Short-circuits reconcile when the server snapshot matches local state + // (the common case — SSE didn't actually miss anything), avoiding the + // 80 setStore("parts", ...) calls per session switch. + function sameReconcileShape(current: Message[], incoming: Message[]): boolean { + if (current.length !== incoming.length) return false + for (let i = 0; i < incoming.length; i++) { + const c = current[i]! + const n = incoming[i]! + if (c.id !== n.id) return false + if ((c.parts?.length ?? 0) !== (n.parts?.length ?? 0)) return false + } + return true + } + + function handleMessagesLoaded( + sessionID: string, + messages: Message[], + input: { mode?: Exclude; cursor?: string; hasMore?: boolean } = {}, + ) { + const mode = input.mode ?? "replace" + const reset = mode === "prepend" + + // Reconcile fast-path: if the tail matches local state shape-wise, every + // message+part-count already agrees with the server. Skip the reactive + // store churn entirely — virtualizer and rendering stay untouched. + if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages)) { + patchPage(sessionID, { initialLoaded: true, lastMutation: "update" }) + return + } + batch(() => { setLoaded((prev) => { if (prev.has(sessionID)) return prev @@ -832,31 +940,59 @@ export const SessionProvider: ParentComponent = (props) => { }) if (sessionID === currentSessionID()) setLoading(false) - // Preserve optimistic messages that haven't been confirmed yet. - // The server may not have created the message record by the time - // this session's messages are loaded (e.g. on session switch). - const pending = pendingOptimistic.get(sessionID) - if (pending && pending.size > 0) { - const loadedIds = new Set(messages.map((m) => m.id)) - const current = store.messages[sessionID] ?? [] - const orphans = current.filter((m) => pending.has(m.id) && !loadedIds.has(m.id)) - setStore("messages", sessionID, reconcile([...messages, ...orphans], { key: "id" })) + const current = store.messages[sessionID] ?? [] + const merged = + mode === "prepend" || mode === "reconcile" + ? mergeMessages(current, messages, mode) + : withPending(sessionID, messages) + // "replace" mode (session switch): assign directly — reconcile's O(n) + // diff is unnecessary when the entire list is new, and its reactive + // proxy creation for each message object dominated the trace (~900ms). + // "prepend" / "reconcile": reconcile to preserve existing proxies. + if (mode === "replace") { + setStore("messages", sessionID, merged) } else { - setStore("messages", sessionID, reconcile(messages, { key: "id" })) + setStore("messages", sessionID, reconcile(merged, { key: "id" })) } - // Also extract parts from messages for (const msg of messages) { - if (msg.parts && msg.parts.length > 0) { + if (!msg.parts || msg.parts.length === 0) continue + if (mode === "reconcile" && store.parts[msg.id]) { + // Reconcile on a message already hydrated into the reactive store: + // write parts directly so visible turns pick up the server- + // authoritative state immediately instead of waiting for the + // virtualizer to re-render. setStore("parts", msg.id, reconcile(msg.parts, { key: "id" })) + stash.remove(msg.id) + } else { + // Stash parts outside the reactive store — they'll be hydrated + // on demand when the virtualizer renders the corresponding turn. + stash.put(msg.id, msg.parts) } } - const agent = resolveSessionAgent(messages, agentNames()) + // "reconcile" is a background tail refresh, not a page navigation — + // preserve the existing pagination cursor/hasMore so "load earlier" + // keeps working. + if (mode === "reconcile") { + patchPage(sessionID, { initialLoaded: true, lastMutation: "update" }) + } else { + setPages(sessionID, { + initialLoaded: true, + loadingInitial: false, + loadingOlder: false, + before: input.cursor, + hasMore: input.hasMore ?? Boolean(input.cursor), + lastMutation: mode, + }) + } + + const agent = resolveSessionAgent(merged, agentNames()) if (agent) { setStore("agentSelections", sessionID, agent) } }) + if (reset) requestAnimationFrame(() => patchPage(sessionID, { lastMutation: undefined })) } function handleMessageCreated(message: Message) { @@ -877,6 +1013,7 @@ export const SessionProvider: ParentComponent = (props) => { ) } + const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id) setStore("messages", message.sessionID, (msgs = []) => { // Check if message already exists (optimistic or update case). // Since we now use the same messageID for optimistic and server messages, @@ -889,6 +1026,7 @@ export const SessionProvider: ParentComponent = (props) => { } return [...msgs, message] }) + patchPage(message.sessionID, { initialLoaded: true, lastMutation: exists ? "update" : "append" }) // Sync mode picker from any message role (user or assistant). // agentNames() already excludes subagent/hidden agents, so subtask @@ -899,6 +1037,7 @@ export const SessionProvider: ParentComponent = (props) => { } if (message.parts && message.parts.length > 0) { + stash.remove(message.id) setStore("parts", message.id, message.parts) } } @@ -917,6 +1056,16 @@ export const SessionProvider: ParentComponent = (props) => { return } + if (sessionID) patchPage(sessionID, { lastMutation: "update" }) + + // If the stash has parts for this message, hydrate them first so the + // SSE update merges into the full part list rather than an empty array. + const stashed = stash.peek(effectiveMessageID) + if (stashed) { + stash.remove(effectiveMessageID) + setStore("parts", effectiveMessageID, stashed) + } + setStore( "parts", produce((parts) => { @@ -1094,6 +1243,7 @@ export const SessionProvider: ParentComponent = (props) => { function handleSendMessageFailed(message: SendMessageFailedMessage) { if (message.sessionID && message.messageID) { pendingOptimistic.get(message.sessionID)?.delete(message.messageID) + stash.remove(message.messageID) batch(() => { setStore("messages", message.sessionID!, (msgs = []) => msgs.filter((m) => m.id !== message.messageID)) setStore( @@ -1239,9 +1389,10 @@ export const SessionProvider: ParentComponent = (props) => { function handleSessionDeleted(sessionID: string) { pendingOptimistic.delete(sessionID) batch(() => { - // Collect message IDs so we can clean up their parts + // Collect message IDs so we can clean up their parts (store + stash) const msgs = store.messages[sessionID] ?? [] const msgIds = msgs.map((m) => m.id) + for (const id of msgIds) stash.remove(id) setStore( "sessions", @@ -1269,6 +1420,11 @@ export const SessionProvider: ParentComponent = (props) => { delete todos[sessionID] }), ) + setPages( + produce((map) => { + delete map[sessionID] + }), + ) setStore( "agentSelections", produce((selections) => { @@ -1334,6 +1490,10 @@ export const SessionProvider: ParentComponent = (props) => { delete parts[messageID] }), ) + // Also clear any stashed parts for this message. Without this, a + // removed-before-hydrated message leaks parts in the stash and can + // resurface them via getParts() after the message is gone. + stash.remove(messageID) } function handleCloudSessionDataLoaded(cloudSessionId: string, title: string, messages: Message[]) { @@ -1352,6 +1512,7 @@ export const SessionProvider: ParentComponent = (props) => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) + patchPage(key, { initialLoaded: true, hasMore: false, lastMutation: "replace" }) setStore("messages", key, messages) for (const msg of messages) { if (msg.parts && msg.parts.length > 0) { @@ -1425,7 +1586,8 @@ export const SessionProvider: ParentComponent = (props) => { }) // Load real messages in the background (picks up server-assigned IDs // and the new user message once the send completes via SSE) - vscode.postMessage({ type: "loadMessages", sessionID: session.id }) + patchPage(session.id, { loadingInitial: true, before: undefined, hasMore: false }) + vscode.postMessage({ type: "loadMessages", sessionID: session.id, mode: "replace", limit: MESSAGE_PAGE_LIMIT }) } // Actions @@ -1483,6 +1645,7 @@ export const SessionProvider: ParentComponent = (props) => { setStore("messages", sid, (msgs = []) => [...msgs, temp]) setStore("parts", messageID, parts) + patchPage(sid, { initialLoaded: true, lastMutation: "append" }) queueMicrotask(() => window.dispatchEvent(new CustomEvent("resumeAutoScroll"))) } @@ -1754,6 +1917,21 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "loadSessions" }) } + function loadOlderMessages() { + const id = currentSessionID() + if (!id || !server.isConnected()) return + const page = pages[id] ?? emptyPageState + if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return + patchPage(id, { loadingOlder: true }) + vscode.postMessage({ + type: "loadMessages", + sessionID: id, + mode: "prepend", + before: page.before, + limit: MESSAGE_PAGE_LIMIT, + }) + } + function selectSession(id: string) { if (!server.isConnected()) { console.warn("[Kilo New] Cannot select session: not connected") @@ -1763,10 +1941,16 @@ export const SessionProvider: ParentComponent = (props) => { console.warn("[Kilo New] Cannot select cloud preview session via selectSession") return } + const ready = loaded().has(id) setCurrentSessionID(id) setDraftSessionID(id) - setLoading(!loaded().has(id)) - vscode.postMessage({ type: "loadMessages", sessionID: id }) + setLoading(!ready) + if (ready) { + vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "focus" }) + return + } + patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false }) + vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "replace", limit: MESSAGE_PAGE_LIMIT }) } function selectCloudSession(cloudSessionId: string) { @@ -1817,13 +2001,33 @@ export const SessionProvider: ParentComponent = (props) => { return id ? store.sessions[id] : undefined } + const pageState = () => { + const id = currentSessionID() + return id ? (pages[id] ?? emptyPageState) : emptyPageState + } + + const loadingOlderMessages = () => pageState().loadingOlder + const hasOlderMessages = () => pageState().hasMore + const messageMutation = () => pageState().lastMutation + const messages = () => { const id = currentSessionID() return id ? store.messages[id] || [] : [] } const getParts = (messageID: string) => { - return store.parts[messageID] || [] + return store.parts[messageID] || stash.peek(messageID) || [] + } + + function hydrateParts(ids: string[]) { + const pending = stash.take(ids, (id) => Boolean(store.parts[id])) + if (Object.keys(pending).length === 0) return + setStore( + "parts", + produce((p) => { + for (const [id, parts] of Object.entries(pending)) p[id] = parts + }), + ) } const allMessages = () => store.messages @@ -1962,9 +2166,13 @@ export const SessionProvider: ParentComponent = (props) => { statusText, busySince, loading, + loadingOlderMessages, + hasOlderMessages, + messageMutation, messages, userMessages, getParts, + hydrateParts, todos, permissions, respondingPermissions, @@ -2042,6 +2250,7 @@ export const SessionProvider: ParentComponent = (props) => { createSession, clearCurrentSession, loadSessions, + loadOlderMessages, selectSession, deleteSession, renameSession, diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index 59881ed9f58..8221cc1bb52 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -1,4 +1,4 @@ -import { createSignal, onCleanup, onMount } from "solid-js" +import { createSignal, onCleanup } from "solid-js" import type { Accessor } from "solid-js" import type { SlashCommandInfo, WebviewMessage, ExtensionMessage } from "../types/messages" @@ -39,6 +39,7 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): S const [server, setServer] = createSignal([]) const [query, setQuery] = createSignal(null) const [index, setIndex] = createSignal(0) + const [requested, setRequested] = createSignal(false) const all: SlashCommandEntry[] = [ { @@ -118,6 +119,12 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): S const show = () => query() !== null + const request = () => { + if (requested()) return + setRequested(true) + vscode.postMessage({ type: "requestCommands" }) + } + const results = () => { const q = query() if (q === null) return [] @@ -137,10 +144,6 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): S setServer(message.commands) }) - onMount(() => { - vscode.postMessage({ type: "requestCommands" }) - }) - onCleanup(() => { unsubscribe() }) @@ -153,6 +156,7 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): S const before = val.substring(0, cursor) const match = before.match(SLASH_PATTERN) if (match) { + request() setQuery(match[1]) setIndex(0) } else { diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index ab9e70e4bb8..aec050b64d4 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -150,6 +150,9 @@ export function mockSessionValue(overrides?: { statusText: () => (status === "idle" ? undefined : "Thinking…"), busySince: () => (status === "busy" ? Date.now() - 2000 : undefined), loading: () => false, + loadingOlderMessages: () => false, + hasOlderMessages: () => false, + messageMutation: () => undefined, messages: () => [], userMessages: () => [], allMessages: () => ({}), @@ -157,6 +160,7 @@ export function mockSessionValue(overrides?: { allStatusMap: () => ({}), familyData: () => ({ messages: {}, parts: {}, status: {} }), getParts: () => [], + hydrateParts: noop, todos: () => [], permissions: () => permissions, respondingPermissions: () => new Set(), @@ -209,6 +213,7 @@ export function mockSessionValue(overrides?: { createSession: noop, clearCurrentSession: noop, loadSessions: noop, + loadOlderMessages: noop, selectSession: noop, deleteSession: noop, renameSession: noop, diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index 334b9cbea98..69a755de639 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -1040,7 +1040,13 @@ export const DiffSummaryCollapsed: Story = {
- +
diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css index 04d89fbbcba..ffe3fdc35c0 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -63,6 +63,33 @@ font-size: 13px; } +.message-list-page-loader { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 0 12px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.message-list-load-older { + display: block; + margin: 0 auto 12px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 6px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + cursor: pointer; + padding: 5px 10px; + font: inherit; + font-size: 12px; +} + +.message-list-load-older:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + .message-list-content { display: flex; min-height: 100%; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 6ddbcda6842..3b82ebc252e 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -589,10 +589,15 @@ export interface MessageRemovedMessage { messageID: string } +export type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" + export interface MessagesLoadedMessage { type: "messagesLoaded" sessionID: string messages: Message[] + mode?: Exclude + cursor?: string + hasMore?: boolean } export interface MessageCreatedMessage { @@ -1685,6 +1690,9 @@ export interface ClearSessionRequest { export interface LoadMessagesRequest { type: "loadMessages" sessionID: string + mode?: MessageLoadMode + before?: string + limit?: number } export interface LoadSessionsRequest { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index af1a14a90a0..268eaa54a1b 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -475,40 +475,6 @@ export type EventTodoUpdated = { } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - } - | { - type: "offline" - requestID: string - message: string - } - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - export type SuggestionAction = { /** * Button or option label (1-5 words) @@ -568,6 +534,40 @@ export type EventSuggestionDismissed = { } } +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + } + | { + type: "offline" + requestID: string + message: string + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + type: "session.idle" + properties: { + sessionID: string + } +} + export type EventSessionCompacted = { type: "session.compacted" properties: { @@ -1145,11 +1145,11 @@ export type Event = | EventQuestionReplied | EventQuestionRejected | EventTodoUpdated - | EventSessionStatus - | EventSessionIdle | EventSuggestionShown | EventSuggestionAccepted | EventSuggestionDismissed + | EventSessionStatus + | EventSessionIdle | EventSessionCompacted | EventKiloSessionsRemoteStatusChanged | EventWorkspaceReady diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index fa7fdac44ef..0d35b4d6226 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -6,6 +6,7 @@ import { checksum } from "@opencode-ai/util/encode" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" import { stream } from "./markdown-stream" +import { tryFastRender } from "../kilocode/markdown-fast-path" // kilocode_change type Entry = { hash: string @@ -308,6 +309,16 @@ export function Markdown( copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), } + + // kilocode_change start + const fast = tryFastRender(container, content, local.streaming, decorate, setupCodeCopy, () => labels, copyCleanup) + if (fast.handled) { + copyCleanup = fast.copyCleanup + kickHighlight(container, labels) + return + } + // kilocode_change end + const temp = document.createElement("div") temp.innerHTML = content decorate(temp, labels) @@ -356,14 +367,37 @@ export function Markdown( }) // kilocode_change end - if (!copyCleanup) - copyCleanup = setupCodeCopy(container, () => ({ - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - })) + kickHighlight(container, labels) }) + // kilocode_change start: progressive Shiki highlighting (issue #6221, PR #7102). + // Parser emits plain
 blocks; we upgrade them to
+  // Shiki-highlighted 
 here via setTimeout(0) so initial
+  // paint is instant and session switches with many code blocks don't freeze.
+  // The generation counter + abort signal cancel a previous in-flight pass
+  // when streaming tokens (or session switches) spawn a new render.
+  function kickHighlight(container: HTMLDivElement, labels: { copy: string; copied: string }) {
+    highlightState.signal.aborted = true
+    const gen = ++highlightState.gen
+    const signal = { aborted: false }
+    highlightState.signal = signal
+    void deferredHighlight(
+      container,
+      () => {
+        if (gen !== highlightState.gen) return
+        if (copyCleanup) copyCleanup()
+        copyCleanup = setupCodeCopy(container, () => labels)
+      },
+      signal,
+    )
+  }
+  // kilocode_change end
+
   onCleanup(() => {
+    // kilocode_change: cancel any in-flight deferredHighlight pass so its
+    // completion callback doesn't touch the unmounted DOM.
+    highlightState.signal.aborted = true
+    highlightState.gen++
     if (copyCleanup) copyCleanup()
   })
 
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 527fcc92931..fd13f832eaa 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1596,6 +1596,7 @@ ToolRegistry.register({
       
         
@@ -1616,6 +1617,7 @@ ToolRegistry.register({
       
             
diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 10cb10f5f06..231f7c8ef10 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -1,6 +1,11 @@ import { marked } from "marked" import markedKatex from "marked-katex-extension" -import markedShiki from "marked-shiki" +// kilocode_change: marked-shiki highlighted code blocks synchronously during +// parse, freezing the main thread on session switches with many code blocks +// (issue #6221 / PR #7102). We render plain
 here
+// and hand off to deferredHighlight() in markdown.tsx for progressive Shiki.
+// This import was re-added by an upstream merge; removing it restores the
+// two-pass rendering design.
 import katex from "katex"
 import { bundledLanguages, type BundledLanguage } from "shiki"
 import { parseFilePath } from "../file-path" // kilocode_change
@@ -670,26 +675,10 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
         throwOnError: false,
         nonStandard: true,
       }),
-      markedShiki({
-        async highlight(code, lang) {
-          const highlighter = await getSharedHighlighter({
-            themes: ["Kilo"],
-            langs: [],
-            preferredHighlighter: "shiki-wasm",
-          })
-          if (!(lang in bundledLanguages)) {
-            lang = "text"
-          }
-          if (!highlighter.getLoadedLanguages().includes(lang)) {
-            await highlighter.loadLanguage(lang as BundledLanguage)
-          }
-          return highlighter.codeToHtml(code, {
-            lang: lang || "text",
-            theme: "Kilo",
-            tabindex: false,
-          })
-        },
-      }),
+      // kilocode_change: markedShiki removed — the custom `code` renderer
+      // above returns plain 
 and markdown.tsx
+      // calls deferredHighlight() after paint. Running Shiki inside parse
+      // blocks the main thread on session switches (issue #6221).
     )
     // kilocode_change end
 
diff --git a/packages/ui/src/kilocode/markdown-fast-path.ts b/packages/ui/src/kilocode/markdown-fast-path.ts
new file mode 100644
index 00000000000..4e11f3314e8
--- /dev/null
+++ b/packages/ui/src/kilocode/markdown-fast-path.ts
@@ -0,0 +1,29 @@
+// Fast-path initial render for completed (non-streaming) markdown blocks.
+// Skips morphdom's expensive tree-matching by writing innerHTML directly
+// when the container is empty. On large session switches this avoids the
+// dominant "Parse HTML + morphdom diff" cost for historical messages.
+
+type CopyLabels = { copy: string; copied: string }
+
+/**
+ * If the content is a first paint of completed markdown (not streaming,
+ * container empty), render directly via innerHTML and return true.
+ * The caller should skip morphdom when this returns true.
+ */
+export function tryFastRender(
+  container: HTMLDivElement,
+  content: string,
+  streaming: boolean | undefined,
+  decorate: (root: HTMLDivElement, labels: CopyLabels) => void,
+  setupCopy: (root: HTMLDivElement, getLabels: () => CopyLabels) => (() => void) | undefined,
+  getLabels: () => CopyLabels,
+  copyCleanup: (() => void) | undefined,
+): { handled: boolean; copyCleanup: (() => void) | undefined } {
+  if (streaming || container.childNodes.length > 0) {
+    return { handled: false, copyCleanup }
+  }
+  container.innerHTML = content
+  decorate(container, getLabels())
+  const cleanup = copyCleanup ?? setupCopy(container, getLabels)
+  return { handled: true, copyCleanup: cleanup }
+}

From 213101da4ed21b9a9425d696478588367a28565e Mon Sep 17 00:00:00 2001
From: Alex Alecu 
Date: Fri, 17 Apr 2026 17:59:12 +0300
Subject: [PATCH 28/35] fix(vscode): raise per-file diff cap to 20 MB

Bumps MAX_DETAIL_BYTES from 2 MB to 20 MB so most real-world files open
in the diff detail view without falling back to the summarized entry.
---
 .changeset/fix-agent-manager-memory-leak.md        |  2 +-
 .../kilo-vscode/src/agent-manager/local-diff.ts    | 14 +++++++-------
 2 files changed, 8 insertions(+), 8 deletions(-)

diff --git a/.changeset/fix-agent-manager-memory-leak.md b/.changeset/fix-agent-manager-memory-leak.md
index 72f1b0210b7..2d457e56f61 100644
--- a/.changeset/fix-agent-manager-memory-leak.md
+++ b/.changeset/fix-agent-manager-memory-leak.md
@@ -2,4 +2,4 @@
 "kilo-code": patch
 ---
 
-Fix a native memory leak on Windows where `kilo serve` would grow to several GB of RAM within minutes of opening the Agent Manager. Git diff polling now runs directly in the extension host instead of routing through the CLI subprocess, and the diff detail view caps per-file reads at 2 MB to prevent memory spikes when opening very large files.
+Fix a native memory leak on Windows where `kilo serve` would grow to several GB of RAM within minutes of opening the Agent Manager. Git diff polling now runs directly in the extension host instead of routing through the CLI subprocess, and the diff detail view caps per-file reads at 20 MB to prevent memory spikes when opening very large files.
diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts
index 23ef20517c1..977b01de13d 100644
--- a/packages/kilo-vscode/src/agent-manager/local-diff.ts
+++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts
@@ -21,13 +21,13 @@ type Log = (...args: unknown[]) => void
  *  not stall the poll. Matches `GitOps.workingTreeStats()`. */
 const MAX_UNTRACKED_BYTES = 1_000_000
 
-/** Cap per-side reads in the detail view. Opening a 50 MB tracked file used
- *  to spike `kilo serve`; now that the detail path runs in the extension
- *  host, the same file would spike VS Code's RSS. Over this threshold we
- *  return a summarized entry (empty `before`/`after`/`patch`, metadata
- *  preserved) so the webview can render counts without materializing the
- *  content. */
-export const MAX_DETAIL_BYTES = 2_000_000
+/** Cap per-side reads in the detail view. Opening very large tracked files
+ *  used to spike `kilo serve`; now that the detail path runs in the
+ *  extension host, the same file would spike VS Code's RSS. Over this
+ *  threshold we return a summarized entry (empty `before`/`after`/`patch`,
+ *  metadata preserved) so the webview can render counts without
+ *  materializing the content. */
+export const MAX_DETAIL_BYTES = 20_000_000
 
 /**
  * Local, Node.js-side replacement for the server's `WorktreeDiff.summary()` and

From 2367e279afccd738d09be023b85a4548224dd6a1 Mon Sep 17 00:00:00 2001
From: "kilo-maintainer[bot]" 
Date: Fri, 17 Apr 2026 15:03:15 +0000
Subject: [PATCH 29/35] 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 f3da74cd064..f719341c46c 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-B8kyomBd8nlqFG+9idfs+y8P1M+4eVUBP3sDJDh6upw=",
-    "aarch64-linux": "sha256-Xzcrz1R3Gunp0aVgNpEVPLd+SahV8wyuwgUgVNOTgfI=",
-    "aarch64-darwin": "sha256-5Yq09XbErOYRsO+DOqOYAUmz2go7kAQFnLvTXb2CDcc=",
-    "x86_64-darwin": "sha256-c4Cxv5jFTbb1v56c9I18SgGaigtgHeeyEwfn7QjCUdY="
+    "x86_64-linux": "sha256-eEuIR+GbjhIU5+LMlqYSMlP+8K1jhMdqkH5x+IN4gN8=",
+    "aarch64-linux": "sha256-SBL6g8ad7apxtRH865XOVObm4krJS2whvLHERuliSKU=",
+    "aarch64-darwin": "sha256-gf5MCF06yN6JQCtsWlMcFfMU1BpK2DFXgo1hK+ZPeT4=",
+    "x86_64-darwin": "sha256-ySFUIToMSy9vLzX8s/A7BwO+qrtRlxleJziWmikgePw="
   }
 }

From 72fc5d99187328fb5676315f59cd5711595649ab Mon Sep 17 00:00:00 2001
From: Marius 
Date: Fri, 17 Apr 2026 17:57:24 +0200
Subject: [PATCH 30/35] docs(cli): note cloud schema mirror when adding
 kilocode_change config keys (#9117)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* docs(cli): note cloud schema mirror when adding kilocode_change config keys

Points devs to apps/web/src/app/config.json/extras.ts in the cloud repo
so new Kilo-only config keys get recognised by $schema references.

Closes #9115

* docs: add CLI config schema page and AGENTS.md mirror rule

Short page under Contributing → Architecture explaining how the cloud
overlays Kilo extras on top of the upstream opencode JSON Schema, with
the step-by-step for adding a new Kilo-only config key. AGENTS.md gains
a one-liner in the Fork Merge Process section pointing to it.
---
 AGENTS.md                                     |  2 ++
 packages/kilo-docs/lib/nav/contributing.ts    |  4 +++
 .../architecture/config-schema.md             | 33 +++++++++++++++++++
 packages/opencode/src/config/config.ts        |  7 +++-
 4 files changed, 45 insertions(+), 1 deletion(-)
 create mode 100644 packages/kilo-docs/pages/contributing/architecture/config-schema.md

diff --git a/AGENTS.md b/AGENTS.md
index d71e9235a4d..3ff62660676 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -203,6 +203,8 @@ We regularly merge upstream changes from opencode. To minimize merge conflicts a
 
 4. **Avoid restructuring upstream code** - Don't refactor or reorganize code that comes from opencode unless absolutely necessary.
 
+5. **Mirror new config keys to the cloud schema** - When adding a `kilocode_change` key to `Config.Info` in `packages/opencode/src/config/config.ts`, also add the matching JSON Schema entry in `apps/web/src/app/config.json/extras.ts` in the [cloud repo](https://github.com/Kilo-Org/cloud). See [CLI Config Schema](packages/kilo-docs/pages/contributing/architecture/config-schema.md) for the step-by-step.
+
 The goal is to keep our diff from upstream as small as possible, making regular merges straightforward and reducing the risk of conflicts.
 
 ### Kilocode Change Markers
diff --git a/packages/kilo-docs/lib/nav/contributing.ts b/packages/kilo-docs/lib/nav/contributing.ts
index 34fb344f30f..74e75c5ede6 100644
--- a/packages/kilo-docs/lib/nav/contributing.ts
+++ b/packages/kilo-docs/lib/nav/contributing.ts
@@ -38,6 +38,10 @@ export const ContributingNav: NavSection[] = [
             href: "/contributing/architecture/benchmarking",
             children: "Benchmarking",
           },
+          {
+            href: "/contributing/architecture/config-schema",
+            children: "CLI Config Schema",
+          },
           {
             href: "/contributing/architecture/enterprise-mcp-controls",
             children: "Enterprise MCP Controls",
diff --git a/packages/kilo-docs/pages/contributing/architecture/config-schema.md b/packages/kilo-docs/pages/contributing/architecture/config-schema.md
new file mode 100644
index 00000000000..c4cba931c05
--- /dev/null
+++ b/packages/kilo-docs/pages/contributing/architecture/config-schema.md
@@ -0,0 +1,33 @@
+---
+title: "CLI Config Schema"
+description: "How the Kilo CLI config JSON Schema is served at app.kilo.ai/config.json"
+---
+
+# CLI Config Schema
+
+The JSON Schema referenced by `"$schema": "https://app.kilo.ai/config.json"` in `kilo.json` files is served by the cloud repo. It is a runtime overlay of the upstream opencode schema with Kilo-specific additions on top.
+
+## Flow
+
+1. Client fetches `https://app.kilo.ai/config.json`.
+2. Cloud route `apps/web/src/app/config.json/route.ts` fetches `https://opencode.ai/config.json`, runs `merge()` on it, and returns the result.
+3. `merge()` overlays three sections from `apps/web/src/app/config.json/extras.ts`:
+   - `top` — top-level keys like `commit_message`, `remote_control`, nullable `model` / `small_model`
+   - `agents` — Kilo primary agents (`ask`, `debug`, `orchestrator`)
+   - `experimental` — `codebase_search`, `openTelemetry`
+
+## Adding a new Kilo-only config key
+
+The source of truth is the zod schema in `packages/opencode/src/config/config.ts`. The cloud overlay must match it.
+
+1. Add the zod field with a `kilocode_change` marker in `config.ts`.
+2. Generate the JSON Schema shape: `bun --bun packages/opencode/script/schema.ts /tmp/kilo.json`, then `jq '.properties.' /tmp/kilo.json`.
+3. Paste the shape into the correct bucket in `apps/web/src/app/config.json/extras.ts` in the [cloud repo](https://github.com/Kilo-Org/cloud).
+   - Top-level → `top`; under `experimental` → `experimental`; new primary agent → `agents`; anywhere else → add a new bucket and extend `merge()` in `route.ts`.
+4. Add an assertion in `apps/web/src/tests/cli-config-schema.test.ts`.
+
+If step 3 is skipped, users with `$schema: https://app.kilo.ai/config.json` will see "unknown property" warnings for the new key.
+
+## Caching
+
+The cloud route caches the upstream fetch for 1 hour (`next: { revalidate: 3600 }`) and emits `s-maxage=3600, stale-while-revalidate=3600`, so the response is served from the Cloudflare + Vercel edge cache for all but one request per hour per region.
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index e1d8d191018..33a011eb346 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -1033,10 +1033,15 @@ export namespace Config {
         .boolean()
         .optional()
         .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
-      remote_control: z // kilocode_change
+      // kilocode_change start
+      // NOTE: Any new kilocode_change key added to Config.Info must also be mirrored in
+      // apps/web/src/app/config.json/extras.ts in the cloud repo, otherwise
+      // $schema: https://app.kilo.ai/config.json will not recognize it.
+      remote_control: z
         .boolean()
         .optional()
         .describe("Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup."),
+      // kilocode_change end
       autoupdate: z
         .union([z.boolean(), z.literal("notify")])
         .optional()

From c55a9a9af1bfcf7296ebbc9f14a8f045de1dd0a1 Mon Sep 17 00:00:00 2001
From: Josh Lambert 
Date: Fri, 17 Apr 2026 13:34:45 -0400
Subject: [PATCH 31/35] ci: disable smoke-test job in publish workflow

---
 .github/workflows/publish.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 1a419e32fb3..7fa64facd08 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -261,7 +261,7 @@ jobs:
     needs:
       - version
       - build-cli
-    if: github.repository == 'Kilo-Org/kilocode'
+    if: false # kilocode_change - temporarily disabled
     uses: ./.github/workflows/smoke-test.yml
     with:
       cli_version: ${{ needs.version.outputs.version }}
@@ -272,7 +272,7 @@ jobs:
       - version
       - build-cli
       - build-vscode
-      - smoke-test
+      # - smoke-test # kilocode_change - temporarily disabled
       # - build-tauri
     runs-on: ubuntu-24.04
     steps:

From bec9d9b04dab217fed64f7d3b5720cc027c47ced Mon Sep 17 00:00:00 2001
From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com>
Date: Fri, 17 Apr 2026 13:37:26 -0400
Subject: [PATCH 32/35] Apply suggestions from code review

Co-authored-by: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com>
---
 .github/workflows/publish.yml | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 7fa64facd08..0fdee1361bb 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -261,7 +261,7 @@ jobs:
     needs:
       - version
       - build-cli
-    if: false # kilocode_change - temporarily disabled
+    if: false # Disable the smoketest job until it works when called on draft releases
     uses: ./.github/workflows/smoke-test.yml
     with:
       cli_version: ${{ needs.version.outputs.version }}
@@ -272,7 +272,7 @@ jobs:
       - version
       - build-cli
       - build-vscode
-      # - smoke-test # kilocode_change - temporarily disabled
+      # - smoke-test # Disable the smoketest job until it works when called on draft releases
       # - build-tauri
     runs-on: ubuntu-24.04
     steps:

From 024ad48c508958a19e93395ce4c3b67e3fe2f475 Mon Sep 17 00:00:00 2001
From: "kilo-maintainer[bot]" 
Date: Fri, 17 Apr 2026 18:09:17 +0000
Subject: [PATCH 33/35] release: v7.2.14

---
 .changeset/agent-manager-sessions-history.md |  5 ---
 .changeset/diff-viewer-parity.md             |  5 ---
 .changeset/fix-agent-manager-memory-leak.md  |  5 ---
 .changeset/fix-per-agent-model-override.md   |  6 ----
 .changeset/fix-queued-prompt-reorder.md      |  6 ----
 .changeset/folder-mentions.md                |  6 ----
 .changeset/mcp-input-output-i18n.md          |  5 ---
 .changeset/session-switch-perf-fixes.md      |  6 ----
 .changeset/settings-save-error.md            |  5 ---
 bun.lock                                     | 32 ++++++++++----------
 package.json                                 |  2 +-
 packages/app/package.json                    |  2 +-
 packages/desktop-electron/package.json       |  2 +-
 packages/desktop/package.json                |  2 +-
 packages/extensions/zed/extension.toml       | 12 ++++----
 packages/kilo-docs/package.json              |  2 +-
 packages/kilo-gateway/package.json           |  2 +-
 packages/kilo-i18n/package.json              |  2 +-
 packages/kilo-telemetry/package.json         |  2 +-
 packages/kilo-ui/package.json                |  2 +-
 packages/kilo-vscode/CHANGELOG.md            | 28 +++++++++++++++++
 packages/kilo-vscode/package.json            |  2 +-
 packages/opencode/CHANGELOG.md               | 10 ++++++
 packages/opencode/package.json               |  2 +-
 packages/plugin/package.json                 |  2 +-
 packages/script/package.json                 |  2 +-
 packages/sdk/js/package.json                 |  2 +-
 packages/storybook/package.json              |  2 +-
 packages/ui/package.json                     |  2 +-
 packages/util/package.json                   |  2 +-
 script/upstream/package.json                 |  2 +-
 sdks/vscode/package.json                     |  2 +-
 32 files changed, 79 insertions(+), 90 deletions(-)
 delete mode 100644 .changeset/agent-manager-sessions-history.md
 delete mode 100644 .changeset/diff-viewer-parity.md
 delete mode 100644 .changeset/fix-agent-manager-memory-leak.md
 delete mode 100644 .changeset/fix-per-agent-model-override.md
 delete mode 100644 .changeset/fix-queued-prompt-reorder.md
 delete mode 100644 .changeset/folder-mentions.md
 delete mode 100644 .changeset/mcp-input-output-i18n.md
 delete mode 100644 .changeset/session-switch-perf-fixes.md
 delete mode 100644 .changeset/settings-save-error.md

diff --git a/.changeset/agent-manager-sessions-history.md b/.changeset/agent-manager-sessions-history.md
deleted file mode 100644
index 2df85d93802..00000000000
--- a/.changeset/agent-manager-sessions-history.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"kilo-code": minor
----
-
-Support browsing and resuming sessions from Agent Manager with `/sessions`.
diff --git a/.changeset/diff-viewer-parity.md b/.changeset/diff-viewer-parity.md
deleted file mode 100644
index bfc5040b863..00000000000
--- a/.changeset/diff-viewer-parity.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-Fix the sidebar "Show Changes" diff viewer: the file tree now renders correctly (previously the file rows were cramped onto a single line due to missing styles), and per-file revert buttons are available, matching the Agent Manager.
diff --git a/.changeset/fix-agent-manager-memory-leak.md b/.changeset/fix-agent-manager-memory-leak.md
deleted file mode 100644
index 2d457e56f61..00000000000
--- a/.changeset/fix-agent-manager-memory-leak.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-Fix a native memory leak on Windows where `kilo serve` would grow to several GB of RAM within minutes of opening the Agent Manager. Git diff polling now runs directly in the extension host instead of routing through the CLI subprocess, and the diff detail view caps per-file reads at 20 MB to prevent memory spikes when opening very large files.
diff --git a/.changeset/fix-per-agent-model-override.md b/.changeset/fix-per-agent-model-override.md
deleted file mode 100644
index a58ec1106fd..00000000000
--- a/.changeset/fix-per-agent-model-override.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-"@kilocode/cli": patch
-"kilo-code": patch
----
-
-Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`).
diff --git a/.changeset/fix-queued-prompt-reorder.md b/.changeset/fix-queued-prompt-reorder.md
deleted file mode 100644
index 79a373300fe..00000000000
--- a/.changeset/fix-queued-prompt-reorder.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-"@kilocode/cli": patch
-"kilo-code": patch
----
-
-Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt.
diff --git a/.changeset/folder-mentions.md b/.changeset/folder-mentions.md
deleted file mode 100644
index 2d7a9983a2e..00000000000
--- a/.changeset/folder-mentions.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-"kilo-code": minor
-"@kilocode/cli": patch
----
-
-Support mentioning folders in the prompt with @ references, including top-level folder file contents.
diff --git a/.changeset/mcp-input-output-i18n.md b/.changeset/mcp-input-output-i18n.md
deleted file mode 100644
index 607911264c1..00000000000
--- a/.changeset/mcp-input-output-i18n.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-Show MCP tool call inputs alongside outputs in chat, with JSON syntax highlighting for both.
diff --git a/.changeset/session-switch-perf-fixes.md b/.changeset/session-switch-perf-fixes.md
deleted file mode 100644
index 4936c4e9e0b..00000000000
--- a/.changeset/session-switch-perf-fixes.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-"kilo-code": patch
-"@opencode-ai/ui": patch
----
-
-Make switching between sessions in Agent Manager near-instant. Long sessions no longer freeze the UI when selected, and the chat view self-heals if it missed any messages while the session was in the background.
diff --git a/.changeset/settings-save-error.md b/.changeset/settings-save-error.md
deleted file mode 100644
index c6a24f3e073..00000000000
--- a/.changeset/settings-save-error.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-"kilo-code": patch
----
-
-Show an inline error in the Settings save bar when the configuration fails to save (for example, due to an invalid value) so the user can correct the config and retry instead of losing their unsaved changes silently.
diff --git a/bun.lock b/bun.lock
index 2149cddce63..0de80768691 100644
--- a/bun.lock
+++ b/bun.lock
@@ -30,7 +30,7 @@
     },
     "packages/app": {
       "name": "@opencode-ai/app",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@kilocode/kilo-i18n": "workspace:*",
         "@kilocode/kilo-ui": "workspace:*",
@@ -86,7 +86,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -119,7 +119,7 @@
     },
     "packages/desktop-electron": {
       "name": "@opencode-ai/desktop-electron",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -170,7 +170,7 @@
     },
     "packages/kilo-docs": {
       "name": "@kilocode/kilo-docs",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@docsearch/css": "^4",
         "@docsearch/js": "^4",
@@ -199,7 +199,7 @@
     },
     "packages/kilo-gateway": {
       "name": "@kilocode/kilo-gateway",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@ai-sdk/anthropic": "3.0.64",
         "@ai-sdk/openai": "3.0.48",
@@ -234,7 +234,7 @@
     },
     "packages/kilo-i18n": {
       "name": "@kilocode/kilo-i18n",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "devDependencies": {
         "@tsconfig/node22": "catalog:",
         "@types/bun": "catalog:",
@@ -247,7 +247,7 @@
     },
     "packages/kilo-telemetry": {
       "name": "@kilocode/kilo-telemetry",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@kilocode/kilo-gateway": "workspace:*",
         "@opentelemetry/api": "1.9.0",
@@ -267,7 +267,7 @@
     },
     "packages/kilo-ui": {
       "name": "@kilocode/kilo-ui",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@kobalte/core": "0.13.11",
         "@opencode-ai/util": "workspace:*",
@@ -302,7 +302,7 @@
     },
     "packages/kilo-vscode": {
       "name": "kilo-code",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@anthropic-ai/sdk": "^0.39.0",
         "@kilocode/kilo-i18n": "workspace:*",
@@ -356,7 +356,7 @@
     },
     "packages/opencode": {
       "name": "@kilocode/cli",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "bin": {
         "kilo": "./bin/kilo",
         "kilocode": "./bin/kilo",
@@ -499,7 +499,7 @@
     },
     "packages/plugin": {
       "name": "@kilocode/plugin",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@kilocode/sdk": "workspace:*",
         "zod": "catalog:",
@@ -523,7 +523,7 @@
     },
     "packages/script": {
       "name": "@opencode-ai/script",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "semver": "^7.6.3",
       },
@@ -534,7 +534,7 @@
     },
     "packages/sdk/js": {
       "name": "@kilocode/sdk",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "cross-spawn": "catalog:",
       },
@@ -549,7 +549,7 @@
     },
     "packages/storybook": {
       "name": "@opencode-ai/storybook",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "devDependencies": {
         "@opencode-ai/ui": "workspace:*",
         "@solidjs/meta": "catalog:",
@@ -572,7 +572,7 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "@kilocode/sdk": "workspace:*",
         "@kobalte/core": "catalog:",
@@ -622,7 +622,7 @@
     },
     "packages/util": {
       "name": "@opencode-ai/util",
-      "version": "7.2.12",
+      "version": "7.2.14",
       "dependencies": {
         "zod": "catalog:",
       },
diff --git a/package.json b/package.json
index 1833c67cf12..651a1f64d3a 100644
--- a/package.json
+++ b/package.json
@@ -136,6 +136,6 @@
     "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
     "solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
   },
-  "version": "7.2.12",
+  "version": "7.2.14",
   "peerDependencies": {}
 }
diff --git a/packages/app/package.json b/packages/app/package.json
index 29875997b91..166e9b860ed 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/app",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "description": "",
   "type": "module",
   "exports": {
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index 7d688ab2525..101b5ae6a50 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@opencode-ai/desktop-electron",
   "private": true,
-  "version": "7.2.12",
+  "version": "7.2.14",
   "type": "module",
   "license": "MIT",
   "homepage": "https://opencode.ai",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index e955e6e35fc..21d53b38e90 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
 {
   "name": "@opencode-ai/desktop",
   "private": true,
-  "version": "7.2.12",
+  "version": "7.2.14",
   "type": "module",
   "license": "MIT",
   "scripts": {
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index 029f55707e1..ae55d6f1431 100644
--- a/packages/extensions/zed/extension.toml
+++ b/packages/extensions/zed/extension.toml
@@ -1,7 +1,7 @@
 id = "kilo"
 name = "Kilo"
 description = "The open source coding agent."
-version = "7.2.12"
+version = "7.2.14"
 schema_version = 1
 authors = ["Anomaly"]
 repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
 icon = "./icons/opencode.svg"
 
 [agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-darwin-arm64.zip"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-darwin-arm64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-darwin-x64.zip"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-darwin-x64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-linux-arm64.tar.gz"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-linux-x64.tar.gz"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-linux-x64.tar.gz"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-windows-x64.zip"
+archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-windows-x64.zip"
 cmd = "./opencode.exe"
 args = ["acp"]
diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json
index 58b177678d8..9d3bdb2e478 100644
--- a/packages/kilo-docs/package.json
+++ b/packages/kilo-docs/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@kilocode/kilo-docs",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "private": true,
   "scripts": {
     "dev": "next dev --webpack --port 3002",
diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json
index af07ff3de52..e2de274ff60 100644
--- a/packages/kilo-gateway/package.json
+++ b/packages/kilo-gateway/package.json
@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kilocode/kilo-gateway",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "type": "module",
   "license": "MIT",
   "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json
index 15b8d8c5337..46c1fff9b89 100644
--- a/packages/kilo-i18n/package.json
+++ b/packages/kilo-i18n/package.json
@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kilocode/kilo-i18n",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "type": "module",
   "license": "MIT",
   "description": "Kilo-specific i18n translations and overrides",
diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json
index 69096f72578..af676db0cec 100644
--- a/packages/kilo-telemetry/package.json
+++ b/packages/kilo-telemetry/package.json
@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kilocode/kilo-telemetry",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "type": "module",
   "license": "MIT",
   "description": "Telemetry for Kilo CLI - PostHog analytics integration",
diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json
index ae60ce27df3..a0ecdda7658 100644
--- a/packages/kilo-ui/package.json
+++ b/packages/kilo-ui/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@kilocode/kilo-ui",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "type": "module",
   "license": "MIT",
   "exports": {
diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md
index 8dcc5af4826..265535291a6 100644
--- a/packages/kilo-vscode/CHANGELOG.md
+++ b/packages/kilo-vscode/CHANGELOG.md
@@ -1,5 +1,33 @@
 # kilo-code
 
+## 7.2.14
+
+### Minor Changes
+
+- [#8976](https://github.com/Kilo-Org/kilocode/pull/8976) [`4ef6bbf`](https://github.com/Kilo-Org/kilocode/commit/4ef6bbff5093dc68a607f1f268e6ab662781922e) - Support browsing and resuming sessions from Agent Manager with `/sessions`.
+
+- [#9023](https://github.com/Kilo-Org/kilocode/pull/9023) [`5301258`](https://github.com/Kilo-Org/kilocode/commit/530125828e891d3c50fe8d783201b65e3c4db8e4) - Support mentioning folders in the prompt with @ references, including top-level folder file contents.
+
+### Patch Changes
+
+- [#9121](https://github.com/Kilo-Org/kilocode/pull/9121) [`c8fd421`](https://github.com/Kilo-Org/kilocode/commit/c8fd4218236afb7d9f525ca667ddf53734c47d4a) - Fix the sidebar "Show Changes" diff viewer: the file tree now renders correctly (previously the file rows were cramped onto a single line due to missing styles), and per-file revert buttons are available, matching the Agent Manager.
+
+- [#9046](https://github.com/Kilo-Org/kilocode/pull/9046) [`671129d`](https://github.com/Kilo-Org/kilocode/commit/671129d4d70587352f963f9f409d6c24e9e86436) - Fix a native memory leak on Windows where `kilo serve` would grow to several GB of RAM within minutes of opening the Agent Manager. Git diff polling now runs directly in the extension host instead of routing through the CLI subprocess, and the diff detail view caps per-file reads at 20 MB to prevent memory spikes when opening very large files.
+
+- [#9118](https://github.com/Kilo-Org/kilocode/pull/9118) [`343455b`](https://github.com/Kilo-Org/kilocode/commit/343455b87895a0551760b5710b1ffe58fae21efd) - Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`).
+
+- [#9067](https://github.com/Kilo-Org/kilocode/pull/9067) [`959a8b4`](https://github.com/Kilo-Org/kilocode/commit/959a8b498de6efd28756683162296dd40eb9b454) - Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt.
+
+- [#9123](https://github.com/Kilo-Org/kilocode/pull/9123) [`9749cc1`](https://github.com/Kilo-Org/kilocode/commit/9749cc178d999f96669cc815709a7cdf3129aefd) - Show MCP tool call inputs alongside outputs in chat, with JSON syntax highlighting for both.
+
+- [#8911](https://github.com/Kilo-Org/kilocode/pull/8911) [`eac2dba`](https://github.com/Kilo-Org/kilocode/commit/eac2dbafa009adedeb4b44016956f2c6cd96b715) - Make switching between sessions in Agent Manager near-instant. Long sessions no longer freeze the UI when selected, and the chat view self-heals if it missed any messages while the session was in the background.
+
+- [`f270639`](https://github.com/Kilo-Org/kilocode/commit/f27063987765bba2443f559629bc8c05fad996df) - Show an inline error in the Settings save bar when the configuration fails to save (for example, due to an invalid value) so the user can correct the config and retry instead of losing their unsaved changes silently.
+
+- Updated dependencies [[`eac2dba`](https://github.com/Kilo-Org/kilocode/commit/eac2dbafa009adedeb4b44016956f2c6cd96b715)]:
+  - @opencode-ai/ui@7.2.13
+  - @kilocode/kilo-ui@7.2.13
+
 ## 7.2.12
 
 ### Minor Changes
diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json
index eafea703c35..5c4935dd5f2 100644
--- a/packages/kilo-vscode/package.json
+++ b/packages/kilo-vscode/package.json
@@ -2,7 +2,7 @@
   "name": "kilo-code",
   "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete",
   "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "icon": "assets/icons/logo-outline-black.png",
   "galleryBanner": {
     "color": "#FFFFFF",
diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md
index 78bbb240cfa..b653c80f295 100644
--- a/packages/opencode/CHANGELOG.md
+++ b/packages/opencode/CHANGELOG.md
@@ -1,5 +1,15 @@
 # @kilocode/cli
 
+## 7.2.14
+
+### Patch Changes
+
+- [#9118](https://github.com/Kilo-Org/kilocode/pull/9118) [`343455b`](https://github.com/Kilo-Org/kilocode/commit/343455b87895a0551760b5710b1ffe58fae21efd) - Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`).
+
+- [#9067](https://github.com/Kilo-Org/kilocode/pull/9067) [`959a8b4`](https://github.com/Kilo-Org/kilocode/commit/959a8b498de6efd28756683162296dd40eb9b454) - Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt.
+
+- [#9023](https://github.com/Kilo-Org/kilocode/pull/9023) [`5301258`](https://github.com/Kilo-Org/kilocode/commit/530125828e891d3c50fe8d783201b65e3c4db8e4) - Support mentioning folders in the prompt with @ references, including top-level folder file contents.
+
 ## 7.2.12
 
 ### Patch Changes
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index a2b539c4651..51d86119c0c 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "name": "@kilocode/cli",
   "type": "module",
   "license": "MIT",
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 42a9ccadab1..8be858b3426 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kilocode/plugin",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "type": "module",
   "license": "MIT",
   "scripts": {
diff --git a/packages/script/package.json b/packages/script/package.json
index ec3c42e4095..d5e57862db5 100644
--- a/packages/script/package.json
+++ b/packages/script/package.json
@@ -12,6 +12,6 @@
   "exports": {
     ".": "./src/index.ts"
   },
-  "version": "7.2.12",
+  "version": "7.2.14",
   "peerDependencies": {}
 }
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index 694ed95a525..87c08a72209 100644
--- a/packages/sdk/js/package.json
+++ b/packages/sdk/js/package.json
@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@kilocode/sdk",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "type": "module",
   "license": "MIT",
   "scripts": {
diff --git a/packages/storybook/package.json b/packages/storybook/package.json
index 148289f0469..ff0887b9858 100644
--- a/packages/storybook/package.json
+++ b/packages/storybook/package.json
@@ -26,7 +26,7 @@
     "typescript": "catalog:",
     "vite": "catalog:"
   },
-  "version": "7.2.12",
+  "version": "7.2.14",
   "dependencies": {},
   "peerDependencies": {}
 }
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 7ee16a91353..c40c1979bea 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/ui",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "type": "module",
   "license": "MIT",
   "exports": {
diff --git a/packages/util/package.json b/packages/util/package.json
index 7305f0d747b..95817ea28bc 100644
--- a/packages/util/package.json
+++ b/packages/util/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/util",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "private": true,
   "type": "module",
   "license": "MIT",
diff --git a/script/upstream/package.json b/script/upstream/package.json
index feef48f5f98..dd9af5c076d 100644
--- a/script/upstream/package.json
+++ b/script/upstream/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@kilocode/upstream-merge",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "private": true,
   "type": "module",
   "description": "Scripts for automating upstream opencode merges into Kilo",
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index 3c36192e550..8eeea3b10e7 100644
--- a/sdks/vscode/package.json
+++ b/sdks/vscode/package.json
@@ -2,7 +2,7 @@
   "name": "opencode",
   "displayName": "opencode",
   "description": "opencode for VS Code",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "publisher": "sst-dev",
   "repository": {
     "type": "git",

From 2172616229385f5587a0c0bd00bd5fbcd181d747 Mon Sep 17 00:00:00 2001
From: Johnny Amancio 
Date: Fri, 17 Apr 2026 17:50:47 +0200
Subject: [PATCH 34/35] fix: Remove Bash string appearing before commands -
 brought back on earlier opencode merge

---
 packages/opencode/src/tool/bash.ts            |  6 +--
 .../kilocode/bash-permission-metadata.test.ts | 47 +++++++++++++++++++
 2 files changed, 50 insertions(+), 3 deletions(-)
 create mode 100644 packages/opencode/test/kilocode/bash-permission-metadata.test.ts

diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index 0300301280f..853b74df362 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -280,7 +280,7 @@ async function parse(command: string, ps: boolean) {
   return tree.rootNode
 }
 
-async function ask(ctx: Tool.Context, scan: Scan) {
+async function ask(ctx: Tool.Context, scan: Scan, command: string) {
   if (scan.dirs.size > 0) {
     const globs = Array.from(scan.dirs).map((dir) => {
       if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
@@ -299,7 +299,7 @@ async function ask(ctx: Tool.Context, scan: Scan) {
     permission: "bash",
     patterns: Array.from(scan.patterns),
     always: Array.from(scan.always),
-    metadata: {},
+    metadata: { command }, // kilocode_change
   })
 }
 
@@ -479,7 +479,7 @@ export const BashTool = Tool.define("bash", async () => {
       const root = await parse(params.command, ps)
       const scan = await collect(root, cwd, ps, shell)
       if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
-      await ask(ctx, scan)
+      await ask(ctx, scan, params.command)
 
       return run(
         {
diff --git a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts
new file mode 100644
index 00000000000..2fa282ec6c6
--- /dev/null
+++ b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts
@@ -0,0 +1,47 @@
+// regression test for bash permission metadata.command
+import { describe, expect, test } from "bun:test"
+import { BashTool } from "../../src/tool/bash"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+import { Shell } from "../../src/shell/shell"
+import { SessionID, MessageID } from "../../src/session/schema"
+import type { Permission } from "../../src/permission"
+
+Shell.acceptable.reset()
+
+const baseCtx = {
+  sessionID: SessionID.make("ses_test"),
+  messageID: MessageID.make(""),
+  callID: "",
+  agent: "code",
+  abort: AbortSignal.any([]),
+  messages: [],
+  metadata: () => {},
+  ask: async () => {},
+}
+
+const capture = (requests: Array>) => ({
+  ...baseCtx,
+  ask: async (req: Omit) => {
+    requests.push(req)
+  },
+})
+
+describe("bash permission metadata.command", () => {
+  test("permission prompt shows raw command without tool name prefix", async () => {
+    await using tmp = await tmpdir()
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        const requests: Array> = []
+        const command = "echo hello"
+        await bash.execute({ command, description: "Echo hello" }, capture(requests))
+
+        const bashReq = requests.find((r) => r.permission === "bash")
+        expect(bashReq).toBeDefined()
+        expect(bashReq!.metadata.command).toBe(command)
+      },
+    })
+  })
+})

From 8aa490c66e64c5bd6a87cfb11daff5f38d5c8f05 Mon Sep 17 00:00:00 2001
From: Johnny Amancio 
Date: Fri, 17 Apr 2026 18:16:17 +0200
Subject: [PATCH 35/35] chore: Add kilocode_change marker

---
 packages/opencode/src/tool/bash.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index 853b74df362..273e326e1f7 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/packages/opencode/src/tool/bash.ts
@@ -280,7 +280,7 @@ async function parse(command: string, ps: boolean) {
   return tree.rootNode
 }
 
-async function ask(ctx: Tool.Context, scan: Scan, command: string) {
+async function ask(ctx: Tool.Context, scan: Scan, command: string) { // kilocode_change
   if (scan.dirs.size > 0) {
     const globs = Array.from(scan.dirs).map((dir) => {
       if (process.platform === "win32") return Filesystem.normalizePathPattern(path.join(dir, "*"))
@@ -479,7 +479,7 @@ export const BashTool = Tool.define("bash", async () => {
       const root = await parse(params.command, ps)
       const scan = await collect(root, cwd, ps, shell)
       if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
-      await ask(ctx, scan, params.command)
+      await ask(ctx, scan, params.command) // kilocode_change
 
       return run(
         {