feat(vscode): multi-project Agent Manager with per-project state, sessions, and lifecycle

Add an experimental multi-project mode to the Agent Manager sidebar behind
kilo-code.new.experimental.multiProject (default off). A persistent project
registry catalogs additional git repositories across restarts, while the
workspace repository stays the pinned default project.

Extension:
- Immutable per-project contexts own all repository-bound services (state,
  worktrees, setup scripts, stale tracking, pollers) with generation-based
  invalidation and fail-closed trust checks.
- ProjectContexts manage activation, expansion, and fast switching; session
  routes resolve directories exactly per project via a shared route service.
- pushProjectSessions caches each project's session list and re-posts it on
  fresh skips; session.created/updated/deleted SSE events upsert into the
  owning project's cache so externally created sessions appear immediately.
- Selection restore persists the active target per project and falls back to
  the local context silently when the remembered target is gone.
- Worktree lifecycle handlers extracted into provider-lifecycle.ts with an
  explicit deps object instead of ambient project scope.
- initializeState and onRequestState always refresh sessions: with zero
  managed sessions the listing never ran and the sidebar skeletons forever.
- Log instead of dropping silently when a state-gated message is not ready.

Webview:
- ProjectList accordion with per-project sidebar body, search, actions, and
  default-branch dialog; selecting a project header restores its target.
- Local session tabs and terminal contexts are bucketed per project so open
  tabs never leak across projects sharing the LOCAL context.
- The active project's session list overlays the live session store so new
  sessions show without waiting for a backend re-list.
- SectionHeader requires a DragDropProvider ancestor; the multi-project body
  now provides one (its absence crashed the whole webview render).
- SidebarBody and TabBar extracted out of AgentManagerApp (3215 to 2748
  lines); AgentManagerProvider down to 1887 with caps lowered accordingly.

Also includes the config write revision bindings and per-project indexing
consent groundwork that rode along on this branch.
This commit is contained in:
marius-kilocode
2026-07-27 16:44:57 +02:00
parent 2da8949813
commit 46b7e55f8d
141 changed files with 12752 additions and 1674 deletions
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { $ } from "bun"
import { Effect } from "effect"
import fs from "node:fs/promises"
import path from "node:path"
@@ -79,6 +80,7 @@ const staleKilo: Partial<Config.Info> = {
}
const configDir = process.env["KILO_CONFIG_DIR"]
const disabled = process.env["KILO_DISABLE_CODEBASE_INDEXING"]
const platform = process.env["KILO_PLATFORM"]
const error = new Error("test indexing initialization failed")
function inline(directory: string, root: string, hooks: IndexingWorker.Hooks): IndexingWorker.Driver {
@@ -118,6 +120,7 @@ async function called(init: ReturnType<typeof spyOn<CodeIndexManager, "initializ
}
beforeEach(() => {
process.env["KILO_PLATFORM"] = "cli"
IndexingWorker.override(inline)
})
@@ -127,6 +130,8 @@ afterEach(async () => {
else process.env["KILO_CONFIG_DIR"] = configDir
if (disabled === undefined) delete process.env["KILO_DISABLE_CODEBASE_INDEXING"]
else process.env["KILO_DISABLE_CODEBASE_INDEXING"] = disabled
if (platform === undefined) delete process.env["KILO_PLATFORM"]
else process.env["KILO_PLATFORM"] = platform
global.fetch = fetch
await disposeAllInstances()
})
@@ -388,14 +393,17 @@ describe("indexing startup degradation", () => {
},
})
expect(config.status).toBe(200)
await called(init)
const status = await app.request("/indexing/status", {
const status = await app.request("/indexing/consent", {
method: "PUT",
headers: {
"content-type": "application/json",
"x-kilo-directory": tmp.path,
},
body: JSON.stringify({ enabled: true }),
})
expect(status.status).toBe(200)
await called(init)
const body = await status.json()
expect(body).toMatchObject({
@@ -629,6 +637,99 @@ describe("indexing startup degradation", () => {
})
})
test("requires explicit VS Code consent even when repository config enables indexing", async () => {
const created: string[] = []
IndexingWorker.override((directory) => {
created.push(directory)
return inline(directory, "/index", {
status() {},
telemetry() {},
warning() {},
log() {},
failure() {},
})
})
await using tmp = await tmpdir({ git: true, config: cfg })
process.env["KILO_CONFIG_DIR"] = tmp.path
process.env["KILO_PLATFORM"] = "vscode"
try {
await provideTestInstance({
directory: tmp.path,
init: Effect.promise(() => KiloIndexing.setConsent(false)),
fn: async () => {
const status = await KiloIndexing.current()
expect(status.state).toBe("Disabled")
expect(status.message).toContain("enable it for this project")
expect(created).toEqual([])
},
})
} finally {
process.env["KILO_PLATFORM"] = "cli"
}
})
test("shares consent across linked worktrees and revokes every project worker", async () => {
const created: string[] = []
const disposed: string[] = []
IndexingWorker.override((directory) => {
created.push(directory)
return {
async init() {
return {
state: "Standby",
message: "Indexing paused.",
processedFiles: 0,
totalFiles: 0,
percent: 0,
}
},
async search() {
return []
},
async dispose() {
disposed.push(directory)
},
}
})
await using tmp = await tmpdir({ git: true, config: cfg })
const worktree = path.join(path.dirname(tmp.path), `indexing-worktree-${Date.now()}`)
await $`git worktree add --quiet -b indexing-consent-${Date.now()} ${worktree} HEAD`.cwd(tmp.path)
process.env["KILO_CONFIG_DIR"] = tmp.path
process.env["KILO_PLATFORM"] = "vscode"
try {
await withTestInstance({
directory: tmp.path,
fn: async () => {
await KiloIndexing.setConsent(true)
await wait(() => KiloIndexing.current(), "Standby")
},
})
await withTestInstance({
directory: worktree,
fn: async () => expect((await wait(() => KiloIndexing.current(), "Standby")).state).toBe("Standby"),
})
expect(new Set(created)).toEqual(new Set([tmp.path, worktree]))
await withTestInstance({
directory: tmp.path,
fn: () => KiloIndexing.setConsent(false),
})
expect(new Set(disposed)).toEqual(new Set([tmp.path, worktree]))
await withTestInstance({
directory: worktree,
fn: async () => expect((await KiloIndexing.current()).state).toBe("Disabled"),
})
} finally {
process.env["KILO_PLATFORM"] = "cli"
await $`git worktree remove --force ${worktree}`.cwd(tmp.path).quiet()
}
}, 15_000)
test("enriches Kilo provider config from env auth", async () => {
global.fetch = (() =>
Promise.resolve(
@@ -1,10 +1,12 @@
import { afterEach, describe, expect, test } from "bun:test"
import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"
import path from "path"
import { chmod, rm, stat, symlink } from "fs/promises"
import * as Log from "@opencode-ai/core/util/log"
import { Global } from "@opencode-ai/core/global"
import { Server } from "../../../src/server/server"
import { Config } from "../../../src/config/config"
import { KilocodeConfigOverlay } from "../../../src/kilocode/config/overlay"
import { KilocodeConfigWriter } from "../../../src/kilocode/config/writer"
import { Permission } from "../../../src/permission"
import { PtyPaths } from "../../../src/server/routes/instance/httpapi/groups/pty"
import { Filesystem } from "../../../src/util/filesystem"
@@ -12,14 +14,16 @@ import { resetDatabase } from "../../fixture/db"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
void Log.init({ print: false })
setDefaultTimeout(30_000)
const original = Global.Path.config
const terminal = process.platform === "win32" ? test.skip : test.serial
type Target = { path: string; revision: string; exists: boolean; writable: boolean; raw: Record<string, unknown> }
type Overlay = {
fields: Record<string, { source: string; inherited: boolean; overridden: boolean; value?: unknown }>
collections: Record<string, Array<{ key: string; source: string; inherited: boolean; local?: unknown }>>
targets: { project?: string; global?: string; active?: string }
targets: { project: Target; global: Target; active: Target }
}
type Agent = {
name: string
@@ -30,34 +34,41 @@ afterEach(async () => {
;(Global.Path as { config: string }).config = original
await disposeAllInstances()
await resetDatabase()
})
}, 15_000)
function req(dir: string, input: string, init?: RequestInit) {
return Server.Default().app.request(input, {
...init,
headers: {
"x-kilo-directory": dir,
...init?.headers,
},
})
return request(Server.Default().app, dir, input, init)
}
function app(_value: boolean) {
return Server.Default().app
}
function request(target: ReturnType<typeof app>, dir: string | undefined, input: string, init?: RequestInit) {
async function request(target: ReturnType<typeof app>, dir: string | undefined, input: string, init?: RequestInit) {
const headers = {
...(dir ? { "x-kilo-directory": dir } : {}),
...init?.headers,
}
const body = init?.method === "PATCH" && input === "/config/overlay" ? JSON.parse(String(init.body)) : undefined
const next =
body && !body.expected
? await (async () => {
const scope = body.scope === "global" ? "global" : "project"
const response = await target.request(`/config/overlay?scope=${scope}`, { headers })
const overlay = (await response.json()) as Overlay
const expected = overlay.targets[scope]
return { ...body, expected: { path: expected.path, revision: expected.revision } }
})()
: body
return target.request(input, {
...init,
headers: {
...(dir ? { "x-kilo-directory": dir } : {}),
...init?.headers,
},
headers,
body: next ? JSON.stringify(next) : init?.body,
})
}
async function json<T>(response: Response) {
expect(response.status).toBe(200)
if (response.status !== 200) throw new Error(`HTTP ${response.status}: ${await response.text()}`)
return (await response.json()) as T
}
@@ -77,6 +88,186 @@ async function setGlobal(dir: string, value: Config.Info) {
}
describe("config overlay routes", () => {
test("writes a missing project target atomically", async () => {
await using project = await tmpdir()
const target = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
const result = await KilocodeConfigWriter.write({
scope: "project",
directory: project.path,
expected: target,
set: { model: "test/model" },
})
expect(result.ok).toBe(true)
expect(await Bun.file(target.path).text()).toContain('"model": "test/model"')
})
test("returns exact raw target data and a stable missing-file revision", async () => {
await using project = await tmpdir()
const first = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
const second = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
expect(first.exists).toBe(false)
expect(first.raw).toEqual({})
expect(first.revision).toBe(second.revision)
await Filesystem.write(first.path, '{\n // preserved\n "model": "test/model"\n}\n')
const saved = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
expect(saved.raw).toEqual({ model: "test/model" })
expect(saved.revision).not.toBe(first.revision)
})
test("rejects a comment-only external edit with a typed revision conflict", async () => {
await using project = await tmpdir()
const before = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
await Filesystem.write(before.targets.project.path, "{\n // external edit\n}\n")
const response = await Server.Default().app.request("/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json", "x-kilo-directory": project.path },
body: JSON.stringify({
scope: "project",
expected: {
path: before.targets.project.path,
revision: before.targets.project.revision,
},
set: { model: "test/model" },
}),
})
expect(response.status).toBe(409)
expect(await response.json()).toMatchObject({ code: "revision-conflict" })
})
test("rejects a newly created higher-priority target", async () => {
await using project = await tmpdir()
const before = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
await Filesystem.write(path.join(project.path, "kilo.json"), "{}")
const response = await Server.Default().app.request("/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json", "x-kilo-directory": project.path },
body: JSON.stringify({
scope: "project",
expected: {
path: before.targets.project.path,
revision: before.targets.project.revision,
},
set: { model: "test/model" },
}),
})
expect(response.status).toBe(409)
expect(await response.json()).toMatchObject({ code: "target-changed" })
})
test("allows only one concurrent writer for a revision", async () => {
await using project = await tmpdir()
const before = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
const update = (model: string) =>
Server.Default().app.request("/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json", "x-kilo-directory": project.path },
body: JSON.stringify({
scope: "project",
expected: {
path: before.targets.project.path,
revision: before.targets.project.revision,
},
set: { model },
}),
})
const responses = await Promise.all([update("test/first"), update("test/second")])
expect(responses.map((response) => response.status).sort()).toEqual([200, 409])
})
test("rejects a project config target that escapes through a symlink", async () => {
if (process.platform === "win32") return
await using project = await tmpdir()
await using outside = await tmpdir()
await Filesystem.write(path.join(outside.path, "kilo.jsonc"), "{}")
await symlink(outside.path, path.join(project.path, ".kilo"), "dir")
const before = await json<Overlay>(await req(project.path, "/config/overlay?scope=project"))
const response = await Server.Default().app.request("/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json", "x-kilo-directory": project.path },
body: JSON.stringify({
scope: "project",
expected: {
path: before.targets.project.path,
revision: before.targets.project.revision,
},
set: { model: "test/model" },
}),
})
expect(response.status).toBe(400)
expect(await Bun.file(path.join(outside.path, "kilo.jsonc")).text()).not.toContain('"model"')
})
test("does not expose partial content when an atomic replacement fails", async () => {
await using project = await tmpdir()
const file = path.join(project.path, "kilo.jsonc")
await Filesystem.write(file, '{\n "model": "test/before"\n}\n')
const target = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
await expect(
KilocodeConfigWriter.write({
scope: "project",
directory: project.path,
expected: target,
set: { model: "test/after" },
write: async () => {
throw new Error("simulated replacement failure")
},
}),
).rejects.toThrow("simulated replacement failure")
expect(await Bun.file(file).text()).toContain("test/before")
})
test("rechecks missing target parents before replacement", async () => {
if (process.platform === "win32") return
await using project = await tmpdir()
await using outside = await tmpdir()
const target = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
const result = await KilocodeConfigWriter.write({
scope: "project",
directory: project.path,
expected: target,
set: { model: "test/model" },
beforeWrite: async () => {
await rm(path.dirname(target.path), { recursive: true })
await symlink(outside.path, path.dirname(target.path), "dir")
},
})
expect(result).toMatchObject({ ok: false, code: "target-not-writable" })
expect(await Bun.file(path.join(outside.path, "kilo.jsonc")).exists()).toBe(false)
})
test("preserves restrictive config file permissions", async () => {
if (process.platform === "win32") return
await using project = await tmpdir()
const file = path.join(project.path, "kilo.jsonc")
await Filesystem.write(file, "{}", 0o600)
await chmod(file, 0o600)
const target = await KilocodeConfigOverlay.target({ scope: "project", directory: project.path })
const result = await KilocodeConfigWriter.write({
scope: "project",
directory: project.path,
expected: target,
set: { model: "test/model" },
})
expect(result.ok).toBe(true)
expect((await stat(file)).mode & 0o777).toBe(0o600)
})
test("ignores unsafe patch paths", () => {
const patched = KilocodeConfigOverlay.patch({
scope: "project",
@@ -141,7 +332,7 @@ describe("config overlay routes", () => {
prompt: "kilo agent prompt",
})
expect(body.project.agent?.["opencode-only"]).toBeUndefined()
expect(body.targets.project).toBe(path.join(project.path, ".kilo", "kilo.json"))
expect(body.targets.project.path).toBe(path.join(project.path, ".kilo", "kilo.json"))
})
test.serial("tolerates unsafe project config instead of failing the overlay", async () => {
@@ -357,38 +548,42 @@ describe("config overlay routes", () => {
expect(saved.mcp).toEqual({ shared: { enabled: false } })
})
test.serial("refreshes effective config after project permission update", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
await setGlobal(global.path, { permission: { edit: "allow" } })
test.serial(
"refreshes effective config after project permission update",
async () => {
await using global = await tmpdir()
await using project = await tmpdir()
await setGlobal(global.path, { permission: { edit: "allow" } })
const before = await json<Agent[]>(await req(project.path, "/agent"))
expect(Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"allow",
)
const before = await json<Agent[]>(await req(project.path, "/agent"))
expect(
Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action,
).toBe("allow")
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }),
}),
)
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
await req(project.path, "/config/overlay?scope=project"),
)
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }),
}),
)
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
await req(project.path, "/config/overlay?scope=project"),
)
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
expect(Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"ask",
)
expect(body.collections.permission.find((item) => item.key === "edit")).toMatchObject({
source: "project",
overridden: true,
})
})
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
expect(
Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action,
).toBe("ask")
expect(body.collections.permission.find((item) => item.key === "edit")).toMatchObject({
source: "project",
overridden: true,
})
},
15_000,
)
test.serial("refreshes agent permissions after global permission update", async () => {
await using global = await tmpdir()
@@ -476,6 +671,7 @@ describe("config overlay routes", () => {
Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action,
).toBe("allow")
},
30_000,
)
}
})
@@ -461,6 +461,8 @@ describe("kilocode tool registry indexing", () => {
})
test("logs indexing bootstrap failures without blocking session bootstrap", async () => {
const platform = process.env["KILO_PLATFORM"]
process.env["KILO_PLATFORM"] = "cli"
const logger = Log.create({ service: "kilocode-bootstrap" })
const err = new Error("indexing init failed")
const calls: string[] = []
@@ -504,6 +506,8 @@ describe("kilocode tool registry indexing", () => {
expect(indexing).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith("indexing bootstrap failed", { err })
} finally {
if (platform === undefined) delete process.env["KILO_PLATFORM"]
else process.env["KILO_PLATFORM"] = platform
indexing.mockRestore()
warn.mockRestore()
}