diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx index f4c38ca89e8..2a806ce9b72 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx @@ -63,7 +63,7 @@ export function DialogSessionList() { if (global()) return all const root = project.instance.path().worktree if (!root || root === "/") return all - return all.filter((s) => s.directory === root || s.directory.startsWith(root + path.sep)) + return all.filter((s) => s.worktreeDirectory === root) }) // kilocode_change end diff --git a/packages/opencode/src/kilocode/session/index.ts b/packages/opencode/src/kilocode/session/index.ts index b77f3b0d658..aad8a91f7f8 100644 --- a/packages/opencode/src/kilocode/session/index.ts +++ b/packages/opencode/src/kilocode/session/index.ts @@ -216,9 +216,9 @@ export namespace KiloSession { archived?: boolean }) { const conditions: SQL[] = [] + const ids = input.projectID ? family(input.projectID) : [] if (input.projectID) { - const ids = family(input.projectID) if (ids.length === 1 && ids[0] === input.projectID) { conditions.push(eq(SessionTable.project_id, ProjectID.make(input.projectID))) } else { @@ -252,6 +252,7 @@ export namespace KiloSession { const limit = input.limit ?? 100 const dirs = [...new Set((input.directories ?? []).map((dir) => Filesystem.resolve(dir)))] + const projectIDs = new Set(ids) const rows = Database.use((db) => { const query = @@ -269,19 +270,19 @@ export namespace KiloSession { dirs.length > 0 ? rows.filter((row) => { const dir = Filesystem.resolve(row.directory) - return dirs.some((root) => Filesystem.contains(root, dir)) + return dirs.some((root) => Filesystem.contains(root, dir)) || projectIDs.has(row.project_id) }) : rows - const ids = [...new Set(list.slice(0, limit).map((row) => row.project_id))] + const pids = [...new Set(list.slice(0, limit).map((row) => row.project_id))] const projects = new Map() - if (ids.length > 0) { + if (pids.length > 0) { const items = Database.use((db) => db .select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree }) .from(ProjectTable) - .where(inArray(ProjectTable.id, ids)) + .where(inArray(ProjectTable.id, pids)) .all(), ) for (const item of items) { diff --git a/packages/opencode/src/kilocode/worktree-family.ts b/packages/opencode/src/kilocode/worktree-family.ts index c5f71e06489..e98d0c85486 100644 --- a/packages/opencode/src/kilocode/worktree-family.ts +++ b/packages/opencode/src/kilocode/worktree-family.ts @@ -1,10 +1,23 @@ // kilocode_change - new file import { Instance } from "../project/instance" -import { Project } from "../project/project" +import { ProjectTable } from "../project/project.sql" +import { Database, eq } from "../storage/db" import { Filesystem } from "../util/filesystem" import { Git } from "../git" export namespace WorktreeFamily { + function saved() { + const row = Database.use((db) => + db + .select({ worktree: ProjectTable.worktree, sandboxes: ProjectTable.sandboxes }) + .from(ProjectTable) + .where(eq(ProjectTable.id, Instance.project.id)) + .get(), + ) + if (!row) return [] + return [row.worktree, ...row.sandboxes] + } + export async function list() { if (Instance.project.vcs !== "git") { return [Filesystem.resolve(Instance.directory)] @@ -24,12 +37,10 @@ export namespace WorktreeFamily { return [Filesystem.resolve(line.slice("worktree ".length).trim())] }) - if (dirs.length > 0) { - return [...new Set(dirs)] - } + if (dirs.length > 0) return [...new Set([...dirs, ...saved()].map((dir) => Filesystem.resolve(dir)))] } - const dirs = [Instance.worktree, ...(await Project.sandboxes(Instance.project.id))] + const dirs = [Instance.worktree, ...saved()] return [...new Set(dirs.map((dir) => Filesystem.resolve(dir)))] } } diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index b3988672d1b..4f8e024d4b0 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -44,6 +44,12 @@ const ConsoleSwitchBody = z.object({ orgID: z.string(), }) +// kilocode_change start - Agent Manager worktrees live under ignored .kilo folders +function kiloRoot(dir: string) { + return /^(.*(?:\/|\\)\.kilo(?:code)?(?:\/|\\)worktrees(?:\/|\\)[^\/\\]+)/.exec(dir)?.[1] +} +// kilocode_change end + const QueryBoolean = z.union([ z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()), z.enum(["true", "false"]), @@ -522,8 +528,12 @@ export const ExperimentalRoutes = lazy(() => })) { // kilocode_change start - resolve worktree folder name for each session if (sorted) { - const root = sorted.find((d) => Filesystem.contains(d, session.directory)) - sessions.push({ ...session, worktreeName: path.basename(root ?? session.directory) }) + const exact = sorted.find((d) => d === session.directory) + const kilo = kiloRoot(session.directory) + const parent = sorted.find((d) => Filesystem.contains(d, session.directory)) + const root = exact ?? (kilo && parent ? kilo : parent) + const dir = root ?? session.directory + sessions.push({ ...session, worktreeDirectory: dir, worktreeName: path.basename(dir) }) continue } // kilocode_change end diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 2b9f644e15e..249e57b2a3e 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -190,6 +190,7 @@ export type ProjectInfo = Types.DeepMutable { + spyOn(RemoteSender, "create").mockReturnValue({ handle() {}, dispose() {} }) + spyOn(Config, "get").mockImplementation(async () => ({ share: "manual" }) as Awaited>) +}) + +afterEach(async () => { + mock.restore() + await Instance.disposeAll() + await resetDatabase() +}) + +type Item = { + id: string + directory: string + title: string + worktreeDirectory?: string + worktreeName?: string +} + +describe("Kilo experimental session worktree list", () => { + test("returns exact worktree directories for nested worktree sessions", async () => { + await using repo = await tmpdir({ git: true }) + const worktree = path.join(repo.path, ".kilo", "worktrees", "nested") + + try { + await fs.mkdir(path.dirname(worktree), { recursive: true }) + await Bun.write(path.join(repo.path, ".gitignore"), ".kilo/worktrees/\n") + await $`git worktree add ${worktree} -b nested-${Date.now()}`.cwd(repo.path).quiet() + + const { Server } = await import("../../src/server/server") + const { Session } = await import("../../src/session/session") + + const branch = await Instance.provide({ + directory: worktree, + fn: async () => Session.create({ title: "nested-worktree-session" }), + }) + const root = await Instance.provide({ + directory: repo.path, + fn: async () => ({ + app: Server.Default().app, + session: await Session.create({ title: "root-session" }), + }), + }) + + const response = await root.app.request("/experimental/session?roots=true&worktrees=true", { + headers: { "x-kilo-directory": repo.path }, + }) + + expect(response.status).toBe(200) + const body = (await response.json()) as Item[] + const base = body.find((item) => item.id === root.session.id) + const nested = body.find((item) => item.id === branch.id) + + expect(base?.worktreeDirectory).toBe(repo.path) + expect(base?.worktreeName).toBe(path.basename(repo.path)) + expect(nested?.worktreeDirectory).toBe(worktree) + expect(nested?.worktreeName).toBe(path.basename(worktree)) + } finally { + await $`git worktree remove ${worktree} --force`.cwd(repo.path).quiet().nothrow() + } + }) + + test("keeps sessions for removed worktrees visible from the root repo", async () => { + await using repo = await tmpdir({ git: true }) + const worktree = path.join(repo.path, "..", path.basename(repo.path) + "-removed") + + try { + await $`git worktree add ${worktree} -b removed-${Date.now()}`.cwd(repo.path).quiet() + + const { Server } = await import("../../src/server/server") + const { Session } = await import("../../src/session/session") + + const branch = await Instance.provide({ + directory: worktree, + fn: async () => Session.create({ title: "removed-worktree-session" }), + }) + const root = await Instance.provide({ + directory: repo.path, + fn: async () => ({ + app: Server.Default().app, + session: await Session.create({ title: "root-session" }), + }), + }) + + await $`git worktree remove ${worktree} --force`.cwd(repo.path).quiet() + await Instance.disposeAll() + + const response = await root.app.request("/experimental/session?roots=true&worktrees=true", { + headers: { "x-kilo-directory": repo.path }, + }) + + expect(response.status).toBe(200) + const body = (await response.json()) as Item[] + const item = body.find((session) => session.id === branch.id) + + expect(item?.directory).toBe(worktree) + expect(item?.worktreeDirectory).toBe(worktree) + expect(item?.worktreeName).toBe(path.basename(worktree)) + } finally { + await $`git worktree remove ${worktree} --force`.cwd(repo.path).quiet().nothrow() + } + }) + + test("keeps sessions for removed worktrees visible after process restart", async () => { + await using repo = await tmpdir({ git: true }) + const worktree = path.join(repo.path, ".kilo", "worktrees", "stale") + const now = Date.now() + const session = SessionID.descending() + + await Instance.provide({ + directory: repo.path, + fn: async () => { + Database.use((db) => { + db.update(ProjectTable) + .set({ sandboxes: [worktree] }) + .where(eq(ProjectTable.id, Instance.project.id)) + .run() + db.insert(SessionTable) + .values({ + id: session, + project_id: Instance.project.id, + slug: "stale", + directory: worktree, + path: "..", + title: "stale-worktree-session", + version: "test", + time_created: now, + time_updated: now, + }) + .run() + }) + }, + }) + await Instance.disposeAll() + + const { Server } = await import("../../src/server/server") + const response = await Server.Default().app.request("/experimental/session?roots=true&worktrees=true", { + headers: { "x-kilo-directory": repo.path }, + }) + + expect(response.status).toBe(200) + const body = (await response.json()) as Item[] + const item = body.find((entry) => entry.id === session) + + expect(item?.directory).toBe(worktree) + expect(item?.worktreeDirectory).toBe(worktree) + expect(item?.worktreeName).toBe(path.basename(worktree)) + }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d0acd7960bf..f712da282cf 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2319,6 +2319,7 @@ export type GlobalSession = { diff?: string } project: ProjectSummary | null + worktreeDirectory?: string worktreeName?: string } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 54fadb6eeac..f92b3295a5a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -17079,6 +17079,9 @@ } ] }, + "worktreeDirectory": { + "type": "string" + }, "worktreeName": { "type": "string" }