From 96ce0cb5f0bbe16d98ca98a9500ebfa4b2bdd271 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Thu, 16 Apr 2026 16:34:15 +0300 Subject: [PATCH 01/15] 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/15] 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/15] 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 7682f1a2823f845715906047c3e3b401e4e8b01c Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 11:14:09 +0300 Subject: [PATCH 04/15] 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 05/15] 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 06/15] 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 7267b7dc9b94f84a062d48f431188e010514a2b8 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 15:04:29 +0300 Subject: [PATCH 07/15] 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 08/15] 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 d02a658db19ef41e05989ff4aba59ea8d8949ac5 Mon Sep 17 00:00:00 2001 From: Alex Alecu Date: Fri, 17 Apr 2026 16:38:27 +0300 Subject: [PATCH 09/15] 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 10/15] 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 11/15] 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 12/15] 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 13/15] 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 14/15] 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 15/15] 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()