From 907f7dfcf398e6ce44d8ee59dc031b3a1da5464f Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Mon, 3 Aug 2026 14:08:35 +0200 Subject: [PATCH 01/11] feat(cli): add --worktree flag to create/reuse a git worktree for the TUI --- .changeset/worktree-for-cli.md | 5 + packages/opencode/src/cli/cmd/tui.ts | 16 ++- .../src/kilocode/cli/cmd/tui-worktree.ts | 129 ++++++++++++++++++ 3 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 .changeset/worktree-for-cli.md create mode 100644 packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts diff --git a/.changeset/worktree-for-cli.md b/.changeset/worktree-for-cli.md new file mode 100644 index 00000000000..630dbce742d --- /dev/null +++ b/.changeset/worktree-for-cli.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Add `kilo --worktree ` to create (or reuse) a git worktree and start the TUI there. Resuming an explicit `--session ` now tries to restart in the worktree the session was originally created in, if it still exists. diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index a02a468b470..b706991ff2f 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -168,6 +168,12 @@ export const TuiThreadCommand = cmd({ type: "boolean", describe: "fetch session from cloud and continue locally (use with --session)", }) + // kilocode_change start - create/reuse a git worktree before starting + .option("worktree", { + type: "string", + describe: "create (or reuse) a git worktree with this name and start kilo there", + }) + // kilocode_change end .option("prompt", { type: "string", describe: "prompt to use", @@ -182,6 +188,7 @@ export const TuiThreadCommand = cmd({ const { importCloudSession, localSessionID, validateCloudFork } = await import("@/kilocode/cloud-session") const { KiloTuiThreadDaemon } = await import("@/kilocode/cli/cmd/tui/thread") const { preload } = await import("@/kilocode/cli/cmd/tui") + const { resolveTuiDirectory } = await import("@/kilocode/cli/cmd/tui-worktree") // kilocode_change end const unguard = win32InstallCtrlCGuard() const shutdown = { @@ -206,7 +213,14 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. - const next = resolveThreadDirectory(args.project) + // kilocode_change start - `--worktree ` creates/reuses a worktree; resuming + // an explicit `--session ` tries to restart in that session's worktree + const next = await resolveTuiDirectory(args, resolveThreadDirectory(args.project)).catch((error) => { + UI.error(errorMessage(error)) + process.exitCode = 1 + }) + if (!next) return + // kilocode_change end const file = await target() // kilocode_change start const preloads = preload(typeof KILO_WORKER_PATH !== "undefined", () => diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts new file mode 100644 index 00000000000..038abeb12c6 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -0,0 +1,129 @@ +// kilocode_change - new file +// Supports `kilo --worktree ` (create/reuse a git worktree before the TUI +// starts) and resuming an explicit `--session ` in the worktree it was +// created in. +import path from "path" +import { Effect } from "effect" +import { UI } from "@/cli/ui" +import { Filesystem } from "@/util/filesystem" + +function slugify(name: string) { + return name + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") +} + +async function withInstance(root: string, fn: (run: (effect: Effect.Effect) => Promise) => Promise) { + const { AppRuntime } = await import("@/effect/app-runtime") + const { InstanceStore } = await import("@/project/instance-store") + const { InstanceRef } = await import("@/effect/instance-ref") + const { store, ctx } = await AppRuntime.runPromise( + InstanceStore.Service.use((store) => store.load({ directory: root }).pipe(Effect.map((ctx) => ({ store, ctx })))), + ) + const run = (effect: Effect.Effect) => + AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx))) + try { + return await fn(run) + } finally { + await AppRuntime.runPromise(store.dispose(ctx)) + } +} + +type WaitResult = { ok: true } | { ok: false; message: string } + +/** Waits for the `worktree.ready`/`worktree.failed` event for `directory`, with + * a `cancel()` handle so a caller can drop the timer/listener on early failure. */ +function waitForWorktreeEvent( + bus: typeof import("@/bus/global").GlobalBus, + event: typeof import("@/worktree").Worktree.Event, + directory: string, + timeoutMs: number, +) { + const deferred = Promise.withResolvers() + let handler = (_e: { directory?: string; payload?: any }) => {} + const cleanup = () => { + clearTimeout(timer) + bus.off("event", handler) + } + const timer = setTimeout(() => { + cleanup() + deferred.resolve({ ok: false, message: "Timed out waiting for the worktree to finish setting up" }) + }, timeoutMs) + timer.unref?.() + handler = (e) => { + if (e.directory !== directory) return + if (e.payload?.type === event.Ready.type) { + cleanup() + deferred.resolve({ ok: true }) + } else if (e.payload?.type === event.Failed.type) { + cleanup() + deferred.resolve({ ok: false, message: e.payload.properties?.message ?? "Worktree setup failed" }) + } + } + bus.on("event", handler) + return { promise: deferred.promise, cancel: cleanup } +} + +async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_000) { + const { Worktree } = await import("@/worktree") + const { GlobalBus } = await import("@/bus/global") + const slug = slugify(name) + if (!slug) throw new Error(`Invalid worktree name "${name}"`) + return withInstance(root, async (run) => { + const existing = await run(Worktree.Service.use((svc) => svc.list())) + // list() remaps `name` to the project ID when a worktree's basename collides + // with the primary checkout's basename, so also match on the directory itself. + const found = existing.find( + (w) => w.name.toLowerCase() === slug || path.basename(w.directory).toLowerCase() === slug, + ) + if (found && (await Filesystem.exists(found.directory))) { + UI.println(`Using existing worktree "${found.name}" at ${found.directory}`) + return found.directory + } + + const info = await run(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ name }))) + UI.println(`Creating worktree "${info.name}"...`) + const wait = waitForWorktreeEvent(GlobalBus, Worktree.Event, info.directory, timeoutMs) + await run(Worktree.Service.use((svc) => svc.createFromInfo(info))).catch((error) => { + wait.cancel() + throw error + }) + const result = await wait.promise + if (!result.ok) throw new Error(`Failed to create worktree "${info.name}": ${result.message}`) + UI.println(`Worktree ready at ${info.directory}`) + return info.directory + }) +} + +async function resolveSessionWorktree(sessionID: string, fallback: string) { + try { + const { AppRuntime } = await import("@/effect/app-runtime") + const { Session } = await import("@/session/session") + const { SessionID } = await import("@/session/schema") + const { Schema } = await import("effect") + const session = await AppRuntime.runPromise( + Session.Service.use((svc) => svc.get(Schema.decodeUnknownSync(SessionID)(sessionID))), + ) + if (!session.directory || session.directory === fallback) return fallback + if (!(await Filesystem.exists(session.directory))) return fallback + UI.println(`Resuming session in its original worktree: ${session.directory}`) + return session.directory + } catch { + // Unknown session, missing directory, or lookup failure: fall back to the + // resolved cwd and let normal session validation report the real error. + return fallback + } +} + +/** + * Resolves the directory to launch the TUI in: creates/reuses `--worktree + * `, or when resuming an explicit `--session ` without `--project`, + * tries that session's original worktree. Otherwise returns `root` unchanged. + */ +export function resolveTuiDirectory(args: { worktree?: string; session?: string; project?: string }, root: string) { + if (args.worktree) return resolveWorktree(args.worktree, root) + if (args.session && !args.project) return resolveSessionWorktree(args.session, root) + return Promise.resolve(root) +} From fb5f41082f73e8b6b17b5001c0feecd3f6b41ccf Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Mon, 3 Aug 2026 15:11:50 +0200 Subject: [PATCH 02/11] fix(cli): prune stale worktree registrations and avoid full app bootstrap for --session resume --- .../src/kilocode/cli/cmd/tui-worktree.ts | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts index 038abeb12c6..e47efd36702 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -78,13 +78,22 @@ async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_0 const found = existing.find( (w) => w.name.toLowerCase() === slug || path.basename(w.directory).toLowerCase() === slug, ) - if (found && (await Filesystem.exists(found.directory))) { - UI.println(`Using existing worktree "${found.name}" at ${found.directory}`) - return found.directory + if (found) { + if (await Filesystem.exists(found.directory)) { + UI.println(`Using existing worktree "${found.name}" at ${found.directory}`) + return found.directory + } + // The worktree directory was deleted out-of-band; git still holds the + // registration and branch, which would otherwise force the next + // makeWorktreeInfo to pick a different, random name. Prune it first so + // the requested name/branch can be reused. + await run(Worktree.Service.use((svc) => svc.remove({ directory: found.directory }))).catch(() => {}) } const info = await run(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ name }))) UI.println(`Creating worktree "${info.name}"...`) + // `worktree.ready` fires once checkout finishes, before the project's start + // script (install/build) has run — that script continues in the background. const wait = waitForWorktreeEvent(GlobalBus, Worktree.Event, info.directory, timeoutMs) await run(Worktree.Service.use((svc) => svc.createFromInfo(info))).catch((error) => { wait.cancel() @@ -92,24 +101,33 @@ async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_0 }) const result = await wait.promise if (!result.ok) throw new Error(`Failed to create worktree "${info.name}": ${result.message}`) - UI.println(`Worktree ready at ${info.directory}`) + UI.println(`Worktree checked out at ${info.directory} (project setup continuing in the background)`) return info.directory }) } +// Reads only the session's `directory` column against a throwaway +// Database-only layer instead of the full AppRuntime (Plugin/LSP/MCP/Provider/ +// Observability/etc), since `--session ` is common and shouldn't pay for +// bootstrapping the whole app graph in the launcher process just for this. async function resolveSessionWorktree(sessionID: string, fallback: string) { try { - const { AppRuntime } = await import("@/effect/app-runtime") - const { Session } = await import("@/session/session") + const { Effect, Schema } = await import("effect") + const { Database } = await import("@opencode-ai/core/database/database") + const { SessionTable } = await import("@opencode-ai/core/session/sql") + const { eq } = await import("drizzle-orm") const { SessionID } = await import("@/session/schema") - const { Schema } = await import("effect") - const session = await AppRuntime.runPromise( - Session.Service.use((svc) => svc.get(Schema.decodeUnknownSync(SessionID)(sessionID))), + const id = Schema.decodeUnknownSync(SessionID)(sessionID) + const row = await Effect.runPromise( + Database.Service.use(({ db }) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).pipe( + Effect.provide(Database.defaultLayer), + ), ) - if (!session.directory || session.directory === fallback) return fallback - if (!(await Filesystem.exists(session.directory))) return fallback - UI.println(`Resuming session in its original worktree: ${session.directory}`) - return session.directory + const directory = row?.directory + if (!directory || directory === fallback) return fallback + if (!(await Filesystem.exists(directory))) return fallback + UI.println(`Resuming session in its original worktree: ${directory}`) + return directory } catch { // Unknown session, missing directory, or lookup failure: fall back to the // resolved cwd and let normal session validation report the real error. From 2762d09c59d82cb4b0c1847e9836e4a0b3551ef9 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Tue, 4 Aug 2026 15:28:42 +0200 Subject: [PATCH 03/11] fix(cli): wait for worktree setup and align --worktree with Agent Manager's layout --- .changeset/worktree-for-cli.md | 2 +- .../src/kilocode/cli/cmd/tui-worktree.ts | 114 ++++++++++++------ packages/opencode/src/worktree/index.ts | 18 +++ .../kilocode/cli/cmd/tui-worktree.test.ts | 67 ++++++++++ 4 files changed, 161 insertions(+), 40 deletions(-) create mode 100644 packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts diff --git a/.changeset/worktree-for-cli.md b/.changeset/worktree-for-cli.md index 630dbce742d..3c9e2a43b2f 100644 --- a/.changeset/worktree-for-cli.md +++ b/.changeset/worktree-for-cli.md @@ -2,4 +2,4 @@ "@kilocode/cli": minor --- -Add `kilo --worktree ` to create (or reuse) a git worktree and start the TUI there. Resuming an explicit `--session ` now tries to restart in the worktree the session was originally created in, if it still exists. +Add `kilo --worktree ` to create (or reuse) a git worktree and start the TUI there, placed at `.kilo/worktrees/` alongside worktrees created by the VS Code extension's Agent Manager. Resuming an explicit `--session ` now tries to restart in the worktree the session was originally created in, if it still exists. diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts index e47efd36702..21fc8191809 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -1,13 +1,37 @@ // kilocode_change - new file // Supports `kilo --worktree ` (create/reuse a git worktree before the TUI -// starts) and resuming an explicit `--session ` in the worktree it was +// starts, placed at `.kilo/worktrees/` to match Agent Manager's own +// worktrees) and resuming an explicit `--session ` in the worktree it was // created in. import path from "path" -import { Effect } from "effect" +import type { Effect } from "effect" import { UI } from "@/cli/ui" import { Filesystem } from "@/util/filesystem" +import { errorMessage } from "@/util/error" -function slugify(name: string) { +// Matches packages/kilo-vscode/src/agent-manager/WorktreeManager.ts's placement. +const KILO_WORKTREE_DIR = ".kilo/worktrees" + +// Mirrors WorktreeManager.ts's ensureGitExclude(): keeps `.kilo/worktrees/` +// out of `git status` for repos Agent Manager hasn't touched yet. +// Exported for unit testing; not part of the module's public contract. +export async function ensureGitExclude(root: string) { + const excludePath = path.join(root, ".git", "info", "exclude") + const current = await Filesystem.readText(excludePath).catch(() => "") + if (current.includes(`${KILO_WORKTREE_DIR}/`)) return + const separator = current.length && !current.endsWith("\n") ? "\n" : "" + await Filesystem.write( + excludePath, + `${current}${separator}\n# Kilo Code agent worktrees\n${KILO_WORKTREE_DIR}/\n`, + ).catch(() => {}) +} + +function samePath(a: string, b: string) { + return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b +} + +// Exported for unit testing; not part of the module's public contract. +export function slugify(name: string) { return name .trim() .toLowerCase() @@ -15,26 +39,26 @@ function slugify(name: string) { .replace(/^-+|-+$/g, "") } +// `InstanceStore.Interface.provide` already loads the instance context and +// scopes an effect to it; this just adds the AppRuntime execution and a +// matching `disposeDirectory` once the caller is done with `root`. async function withInstance(root: string, fn: (run: (effect: Effect.Effect) => Promise) => Promise) { const { AppRuntime } = await import("@/effect/app-runtime") const { InstanceStore } = await import("@/project/instance-store") - const { InstanceRef } = await import("@/effect/instance-ref") - const { store, ctx } = await AppRuntime.runPromise( - InstanceStore.Service.use((store) => store.load({ directory: root }).pipe(Effect.map((ctx) => ({ store, ctx })))), - ) const run = (effect: Effect.Effect) => - AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, ctx))) + AppRuntime.runPromise(InstanceStore.Service.use((store) => store.provide({ directory: root }, effect))) try { return await fn(run) } finally { - await AppRuntime.runPromise(store.dispose(ctx)) + await AppRuntime.runPromise(InstanceStore.Service.use((store) => store.disposeDirectory(root))) } } type WaitResult = { ok: true } | { ok: false; message: string } -/** Waits for the `worktree.ready`/`worktree.failed` event for `directory`, with - * a `cancel()` handle so a caller can drop the timer/listener on early failure. */ +/** Waits for `directory`'s `worktree.setup.ready`/`worktree.failed` event (full + * readiness, not just checkout), with a `cancel()` to drop the timer/listener + * on early failure. */ function waitForWorktreeEvent( bus: typeof import("@/bus/global").GlobalBus, event: typeof import("@/worktree").Worktree.Event, @@ -47,14 +71,16 @@ function waitForWorktreeEvent( clearTimeout(timer) bus.off("event", handler) } + // Intentionally not `unref()`'d: this timer is the timeout guarantee for a + // launcher process that has nothing else keeping the event loop alive, so + // letting it be collected would let the process exit 0 on a stalled boot. const timer = setTimeout(() => { cleanup() deferred.resolve({ ok: false, message: "Timed out waiting for the worktree to finish setting up" }) }, timeoutMs) - timer.unref?.() handler = (e) => { if (e.directory !== directory) return - if (e.payload?.type === event.Ready.type) { + if (e.payload?.type === event.SetupReady.type) { cleanup() deferred.resolve({ ok: true }) } else if (e.payload?.type === event.Failed.type) { @@ -69,40 +95,50 @@ function waitForWorktreeEvent( async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_000) { const { Worktree } = await import("@/worktree") const { GlobalBus } = await import("@/bus/global") + const { InstanceState } = await import("@/effect/instance-state") const slug = slugify(name) if (!slug) throw new Error(`Invalid worktree name "${name}"`) return withInstance(root, async (run) => { + const ctx = await run(InstanceState.context) + const directory = path.join(ctx.worktree, KILO_WORKTREE_DIR, slug) + + // Trust neither signal alone: a directory can exist without a live git + // registration (orphaned), and a registration can outlive its directory + // (deleted out-of-band). const existing = await run(Worktree.Service.use((svc) => svc.list())) - // list() remaps `name` to the project ID when a worktree's basename collides - // with the primary checkout's basename, so also match on the directory itself. - const found = existing.find( - (w) => w.name.toLowerCase() === slug || path.basename(w.directory).toLowerCase() === slug, - ) - if (found) { - if (await Filesystem.exists(found.directory)) { - UI.println(`Using existing worktree "${found.name}" at ${found.directory}`) - return found.directory + const registered = existing.some((w) => samePath(w.directory, directory)) + const exists = await Filesystem.exists(directory) + + if (registered && exists) { + if (!(await Filesystem.exists(path.join(directory, ".git")))) { + throw new Error(`"${directory}" is registered but was never fully checked out. Remove it and retry.`) } - // The worktree directory was deleted out-of-band; git still holds the - // registration and branch, which would otherwise force the next - // makeWorktreeInfo to pick a different, random name. Prune it first so - // the requested name/branch can be reused. - await run(Worktree.Service.use((svc) => svc.remove({ directory: found.directory }))).catch(() => {}) + UI.println(`Using existing worktree "${slug}" at ${directory}`) + return directory + } + if (exists) throw new Error(`"${directory}" already exists but is not a registered git worktree.`) + // Registered but missing: reclaim the dead registration (and its branch) + // the same way a fresh `--worktree ` run would need to, so the name + // is free to reuse below. + if (registered) { + await run(Worktree.Service.use((svc) => svc.remove({ directory }))).catch((error) => { + throw new Error(`Failed to reclaim stale worktree "${slug}": ${errorMessage(error)}`) + }) } - const info = await run(Worktree.Service.use((svc) => svc.makeWorktreeInfo({ name }))) - UI.println(`Creating worktree "${info.name}"...`) - // `worktree.ready` fires once checkout finishes, before the project's start - // script (install/build) has run — that script continues in the background. - const wait = waitForWorktreeEvent(GlobalBus, Worktree.Event, info.directory, timeoutMs) - await run(Worktree.Service.use((svc) => svc.createFromInfo(info))).catch((error) => { - wait.cancel() - throw error - }) + await ensureGitExclude(ctx.worktree) + UI.println(`Creating worktree "${slug}"...`) + const wait = waitForWorktreeEvent(GlobalBus, Worktree.Event, directory, timeoutMs) + await run(Worktree.Service.use((svc) => svc.createFromInfo({ name: slug, branch: slug, directory }))).catch( + (error) => { + wait.cancel() + throw error + }, + ) const result = await wait.promise - if (!result.ok) throw new Error(`Failed to create worktree "${info.name}": ${result.message}`) - UI.println(`Worktree checked out at ${info.directory} (project setup continuing in the background)`) - return info.directory + if (!result.ok) throw new Error(`Failed to create worktree "${slug}": ${result.message}`) + UI.println(`Worktree ready at ${directory}`) + return directory }) } diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 029d4eb6324..3a807463e27 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -35,6 +35,12 @@ export const Event = { message: Schema.String, }, }), + // kilocode_change start - fires after start scripts finish, unlike Ready + SetupReady: EventV2.define({ + type: "worktree.setup.ready", + schema: { name: Schema.String, branch: Schema.optional(Schema.String) }, + }), + // kilocode_change end } export const Info = Schema.Struct({ @@ -293,6 +299,18 @@ export const layer: Layer.Layer< }) yield* runStartScripts(info.directory, { projectID, extra }) + + // kilocode_change start - signal full readiness once setup also completes + GlobalBus.emit("event", { + directory: info.directory, + project: ctx.project.id, + workspace: workspaceID, + payload: { + type: Event.SetupReady.type, + properties: { name: info.name, ...(info.branch ? { branch: info.branch } : {}) }, + }, + }) + // kilocode_change end }) const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) { diff --git a/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts new file mode 100644 index 00000000000..b3d8f8f20dc --- /dev/null +++ b/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { mkdtemp, rm } from "fs/promises" +import { tmpdir as osTmpdir } from "os" +import { ensureGitExclude, slugify } from "@/kilocode/cli/cmd/tui-worktree" +import { Filesystem } from "@/util/filesystem" + +describe("slugify", () => { + test("lowercases and dashes non-alphanumeric runs", () => { + expect(slugify("My Feature!")).toBe("my-feature") + expect(slugify("fix_bug--123")).toBe("fix-bug-123") + }) + + test("trims leading/trailing dashes", () => { + expect(slugify("--hello--")).toBe("hello") + }) + + test("returns empty for names with no alphanumeric characters", () => { + expect(slugify("!!!")).toBe("") + expect(slugify(" ")).toBe("") + }) +}) + +describe("ensureGitExclude", () => { + async function withRepo(fn: (root: string) => Promise) { + const root = await mkdtemp(path.join(osTmpdir(), "tui-worktree-exclude-")) + try { + await fn(root) + } finally { + await rm(root, { recursive: true, force: true }) + } + } + + test("appends the exclude entry when the file exists but is empty", () => + withRepo(async (root) => { + const excludePath = path.join(root, ".git", "info", "exclude") + await Filesystem.write(excludePath, "") + await ensureGitExclude(root) + const content = await Filesystem.readText(excludePath) + expect(content).toContain(".kilo/worktrees/") + })) + + test("preserves existing content and adds a newline before the new entry", () => + withRepo(async (root) => { + const excludePath = path.join(root, ".git", "info", "exclude") + await Filesystem.write(excludePath, "*.log") + await ensureGitExclude(root) + const content = await Filesystem.readText(excludePath) + expect(content).toContain("*.log") + expect(content).toContain(".kilo/worktrees/") + })) + + test("is idempotent when the entry already exists", () => + withRepo(async (root) => { + const excludePath = path.join(root, ".git", "info", "exclude") + await Filesystem.write(excludePath, "") + await ensureGitExclude(root) + await ensureGitExclude(root) + const content = await Filesystem.readText(excludePath) + expect(content.match(/\.kilo\/worktrees\//g)?.length).toBe(1) + })) + + test("does not throw when .git/info is missing", () => + withRepo(async (root) => { + await ensureGitExclude(root) + })) +}) From 627e24deb161c95ac634560d13f91014ba60bf44 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Tue, 4 Aug 2026 18:12:22 +0200 Subject: [PATCH 04/11] refactor(cli): tighten tui-worktree implementation and note reuse limitation --- .../src/kilocode/cli/cmd/tui-worktree.ts | 84 ++++++++++++------- 1 file changed, 55 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts index 21fc8191809..9e58da020dc 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -9,28 +9,24 @@ import { UI } from "@/cli/ui" import { Filesystem } from "@/util/filesystem" import { errorMessage } from "@/util/error" -// Matches packages/kilo-vscode/src/agent-manager/WorktreeManager.ts's placement. +// Matches packages/kilo-vscode/src/agent-manager/WorktreeManager.ts's placement +// and its ensureGitExclude(), keeping `.kilo/worktrees/` out of `git status`. const KILO_WORKTREE_DIR = ".kilo/worktrees" -// Mirrors WorktreeManager.ts's ensureGitExclude(): keeps `.kilo/worktrees/` -// out of `git status` for repos Agent Manager hasn't touched yet. -// Exported for unit testing; not part of the module's public contract. +// Exported for tests. export async function ensureGitExclude(root: string) { const excludePath = path.join(root, ".git", "info", "exclude") - const current = await Filesystem.readText(excludePath).catch(() => "") + const current = (await Filesystem.readText(excludePath).catch(() => "")).replace(/\s+$/, "") if (current.includes(`${KILO_WORKTREE_DIR}/`)) return - const separator = current.length && !current.endsWith("\n") ? "\n" : "" - await Filesystem.write( - excludePath, - `${current}${separator}\n# Kilo Code agent worktrees\n${KILO_WORKTREE_DIR}/\n`, - ).catch(() => {}) + const prefix = current ? `${current}\n\n` : "" + await Filesystem.write(excludePath, `${prefix}# Kilo Code agent worktrees\n${KILO_WORKTREE_DIR}/\n`).catch(() => {}) } function samePath(a: string, b: string) { return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b } -// Exported for unit testing; not part of the module's public contract. +// Exported for tests. export function slugify(name: string) { return name .trim() @@ -39,10 +35,13 @@ export function slugify(name: string) { .replace(/^-+|-+$/g, "") } -// `InstanceStore.Interface.provide` already loads the instance context and -// scopes an effect to it; this just adds the AppRuntime execution and a -// matching `disposeDirectory` once the caller is done with `root`. -async function withInstance(root: string, fn: (run: (effect: Effect.Effect) => Promise) => Promise) { +// `InstanceStore.Interface.provide` loads (and caches) the instance context and +// scopes an effect to it; this adds the AppRuntime execution plus a matching +// `disposeDirectory` once the caller is done with `root`. +async function withInstance( + root: string, + fn: (run: (effect: Effect.Effect) => Promise) => Promise, +) { const { AppRuntime } = await import("@/effect/app-runtime") const { InstanceStore } = await import("@/project/instance-store") const run = (effect: Effect.Effect) => @@ -57,8 +56,7 @@ async function withInstance(root: string, fn: (run: (effect: Effect.Effect type WaitResult = { ok: true } | { ok: false; message: string } /** Waits for `directory`'s `worktree.setup.ready`/`worktree.failed` event (full - * readiness, not just checkout), with a `cancel()` to drop the timer/listener - * on early failure. */ + * readiness, not just checkout); `cancel()` drops the timer/listener early. */ function waitForWorktreeEvent( bus: typeof import("@/bus/global").GlobalBus, event: typeof import("@/worktree").Worktree.Event, @@ -71,9 +69,8 @@ function waitForWorktreeEvent( clearTimeout(timer) bus.off("event", handler) } - // Intentionally not `unref()`'d: this timer is the timeout guarantee for a - // launcher process that has nothing else keeping the event loop alive, so - // letting it be collected would let the process exit 0 on a stalled boot. + // Not `unref()`'d: it's the only thing keeping the launcher's event loop + // alive while waiting, so a collected timer would let it exit 0 on a stall. const timer = setTimeout(() => { cleanup() deferred.resolve({ ok: false, message: "Timed out waiting for the worktree to finish setting up" }) @@ -96,36 +93,65 @@ async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_0 const { Worktree } = await import("@/worktree") const { GlobalBus } = await import("@/bus/global") const { InstanceState } = await import("@/effect/instance-state") + const { primaryWorktree } = await import("@/kilocode/primary-worktree") + const { Git } = await import("@/git") const slug = slugify(name) if (!slug) throw new Error(`Invalid worktree name "${name}"`) return withInstance(root, async (run) => { const ctx = await run(InstanceState.context) - const directory = path.join(ctx.worktree, KILO_WORKTREE_DIR, slug) + // ctx.worktree is whichever checkout the command runs in. Creating under + // it unconditionally would nest `.kilo/worktrees/` inside another + // worktree and branch off that worktree's HEAD instead of the primary + // checkout's — refuse rather than get this wrong. + const primary = await run(primaryWorktree(ctx.worktree)) + if (primary && !samePath(primary, ctx.worktree)) + throw new Error( + `Cannot create worktree "${slug}" from inside another worktree (${ctx.worktree}). Run this from the primary checkout at ${primary} instead.`, + ) + const directory = path.join(ctx.worktree, KILO_WORKTREE_DIR, slug) // Trust neither signal alone: a directory can exist without a live git // registration (orphaned), and a registration can outlive its directory - // (deleted out-of-band). - const existing = await run(Worktree.Service.use((svc) => svc.list())) - const registered = existing.some((w) => samePath(w.directory, directory)) + // (deleted out-of-band, or pruned). + const registered = (await run(Worktree.Service.use((svc) => svc.list()))).some((w) => + samePath(w.directory, directory), + ) const exists = await Filesystem.exists(directory) if (registered && exists) { - if (!(await Filesystem.exists(path.join(directory, ".git")))) { - throw new Error(`"${directory}" is registered but was never fully checked out. Remove it and retry.`) - } + // Assumes whatever created it already finished setting up; a worktree + // still booting when its own process was interrupted won't be detected. UI.println(`Using existing worktree "${slug}" at ${directory}`) return directory } if (exists) throw new Error(`"${directory}" already exists but is not a registered git worktree.`) // Registered but missing: reclaim the dead registration (and its branch) - // the same way a fresh `--worktree ` run would need to, so the name - // is free to reuse below. + // the same way a fresh run would need to, so the name is free to reuse. if (registered) { await run(Worktree.Service.use((svc) => svc.remove({ directory }))).catch((error) => { throw new Error(`Failed to reclaim stale worktree "${slug}": ${errorMessage(error)}`) }) } + // `createFromInfo` below passes `branch: slug` straight to `git worktree + // add -b`, which fails outright if that branch already exists — e.g. one + // left behind by `git worktree prune` (which drops the registration but + // never deletes the branch). Clear it first so the name can be reused. + const branchRef = await run( + Git.Service.use((git) => + git.run(["show-ref", "--verify", "--quiet", `refs/heads/${slug}`], { cwd: ctx.worktree }), + ), + ) + if (branchRef.exitCode === 0) { + const deleted = await run(Git.Service.use((git) => git.run(["branch", "-D", slug], { cwd: ctx.worktree }))) + if (deleted.exitCode !== 0) { + const message = deleted.stderr.toString("utf8").trim() || deleted.text().trim() + throw new Error( + `Branch "${slug}" already exists and could not be removed automatically${message ? `: ${message}` : ""}. Remove it with \`git branch -D ${slug}\` and retry.`, + ) + } + } + await ensureGitExclude(ctx.worktree) UI.println(`Creating worktree "${slug}"...`) const wait = waitForWorktreeEvent(GlobalBus, Worktree.Event, directory, timeoutMs) From f3e93ecb3868b44b759dcbec837e4f204317000c Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 5 Aug 2026 10:51:35 +0200 Subject: [PATCH 05/11] fix(cli): don't force-delete pre-existing branches when reclaiming a worktree --- packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts index 9e58da020dc..d0fcb996292 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -136,18 +136,21 @@ async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_0 // `createFromInfo` below passes `branch: slug` straight to `git worktree // add -b`, which fails outright if that branch already exists — e.g. one // left behind by `git worktree prune` (which drops the registration but - // never deletes the branch). Clear it first so the name can be reused. + // never deletes the branch). Clear it first so the name can be reused, but + // only with `-d` (not `-D`): it refuses unless the branch is fully merged, + // so we never silently discard unmerged work from an interrupted worktree + // or an unrelated branch that happens to share the name. const branchRef = await run( Git.Service.use((git) => git.run(["show-ref", "--verify", "--quiet", `refs/heads/${slug}`], { cwd: ctx.worktree }), ), ) if (branchRef.exitCode === 0) { - const deleted = await run(Git.Service.use((git) => git.run(["branch", "-D", slug], { cwd: ctx.worktree }))) + const deleted = await run(Git.Service.use((git) => git.run(["branch", "-d", slug], { cwd: ctx.worktree }))) if (deleted.exitCode !== 0) { const message = deleted.stderr.toString("utf8").trim() || deleted.text().trim() throw new Error( - `Branch "${slug}" already exists and could not be removed automatically${message ? `: ${message}` : ""}. Remove it with \`git branch -D ${slug}\` and retry.`, + `Branch "${slug}" already exists${message ? ` (${message})` : ""}. Remove it (e.g. \`git branch -D ${slug}\` if you're sure it's safe to discard) and retry.`, ) } } From 90989d451a02ffc0acdb80ed5577cadc12b578c6 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 5 Aug 2026 11:11:21 +0200 Subject: [PATCH 06/11] fix(cli): update tui-worktree to use Database.layerFromPath after upstream merge --- packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts index d0fcb996292..46428f79851 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -185,7 +185,7 @@ async function resolveSessionWorktree(sessionID: string, fallback: string) { const id = Schema.decodeUnknownSync(SessionID)(sessionID) const row = await Effect.runPromise( Database.Service.use(({ db }) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get()).pipe( - Effect.provide(Database.defaultLayer), + Effect.provide(Database.layerFromPath(Database.path())), ), ) const directory = row?.directory From 4fdaa4c41beea34d54b53fbf4b8d087dc5c7a833 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Wed, 5 Aug 2026 11:56:33 +0200 Subject: [PATCH 07/11] refactor(cli): tighten tui-worktree, log exclude-write failures, fix event count --- .../src/kilocode/cli/cmd/tui-worktree.ts | 16 +++-- packages/opencode/test/event-manifest.test.ts | 2 +- .../kilocode/cli/cmd/tui-worktree.test.ts | 71 ++++++++----------- 3 files changed, 40 insertions(+), 49 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts index 46428f79851..be3318c5fe9 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -5,10 +5,13 @@ // created in. import path from "path" import type { Effect } from "effect" +import * as Log from "@opencode-ai/core/util/log" import { UI } from "@/cli/ui" import { Filesystem } from "@/util/filesystem" import { errorMessage } from "@/util/error" +const log = Log.create({ service: "kilocode.tui-worktree" }) + // Matches packages/kilo-vscode/src/agent-manager/WorktreeManager.ts's placement // and its ensureGitExclude(), keeping `.kilo/worktrees/` out of `git status`. const KILO_WORKTREE_DIR = ".kilo/worktrees" @@ -19,7 +22,9 @@ export async function ensureGitExclude(root: string) { const current = (await Filesystem.readText(excludePath).catch(() => "")).replace(/\s+$/, "") if (current.includes(`${KILO_WORKTREE_DIR}/`)) return const prefix = current ? `${current}\n\n` : "" - await Filesystem.write(excludePath, `${prefix}# Kilo Code agent worktrees\n${KILO_WORKTREE_DIR}/\n`).catch(() => {}) + await Filesystem.write(excludePath, `${prefix}# Kilo Code agent worktrees\n${KILO_WORKTREE_DIR}/\n`).catch((err) => + log.error("failed to update .git/info/exclude", { excludePath, err }), + ) } function samePath(a: string, b: string) { @@ -140,13 +145,10 @@ async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_0 // only with `-d` (not `-D`): it refuses unless the branch is fully merged, // so we never silently discard unmerged work from an interrupted worktree // or an unrelated branch that happens to share the name. - const branchRef = await run( - Git.Service.use((git) => - git.run(["show-ref", "--verify", "--quiet", `refs/heads/${slug}`], { cwd: ctx.worktree }), - ), - ) + const runGit = (args: string[]) => run(Git.Service.use((git) => git.run(args, { cwd: ctx.worktree }))) + const branchRef = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${slug}`]) if (branchRef.exitCode === 0) { - const deleted = await run(Git.Service.use((git) => git.run(["branch", "-d", slug], { cwd: ctx.worktree }))) + const deleted = await runGit(["branch", "-d", slug]) if (deleted.exitCode !== 0) { const message = deleted.stderr.toString("utf8").trim() || deleted.text().trim() throw new Error( diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index be22f5648ae..99403925aad 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/packages/opencode/test/event-manifest.test.ts @@ -9,7 +9,7 @@ describe("public event manifest", () => { expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) - expect(EventManifest.Latest.size).toBe(89) // kilocode_change - include global.config.updated + expect(EventManifest.Latest.size).toBe(90) // kilocode_change - include global.config.updated and worktree.setup.ready expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) expect(EventManifest.Latest.has("ide.installed")).toBe(false) diff --git a/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts b/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts index b3d8f8f20dc..627017ec0fc 100644 --- a/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts +++ b/packages/opencode/test/kilocode/cli/cmd/tui-worktree.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import path from "path" -import { mkdtemp, rm } from "fs/promises" -import { tmpdir as osTmpdir } from "os" +import { tmpdir } from "../../../fixture/fixture" import { ensureGitExclude, slugify } from "@/kilocode/cli/cmd/tui-worktree" import { Filesystem } from "@/util/filesystem" @@ -22,46 +21,36 @@ describe("slugify", () => { }) describe("ensureGitExclude", () => { - async function withRepo(fn: (root: string) => Promise) { - const root = await mkdtemp(path.join(osTmpdir(), "tui-worktree-exclude-")) - try { - await fn(root) - } finally { - await rm(root, { recursive: true, force: true }) - } - } - - test("appends the exclude entry when the file exists but is empty", () => - withRepo(async (root) => { - const excludePath = path.join(root, ".git", "info", "exclude") - await Filesystem.write(excludePath, "") - await ensureGitExclude(root) - const content = await Filesystem.readText(excludePath) - expect(content).toContain(".kilo/worktrees/") - })) + test("appends the exclude entry when the file exists but is empty", async () => { + await using tmp = await tmpdir({ git: true }) + const excludePath = path.join(tmp.path, ".git", "info", "exclude") + await Filesystem.write(excludePath, "") + await ensureGitExclude(tmp.path) + expect(await Filesystem.readText(excludePath)).toContain(".kilo/worktrees/") + }) - test("preserves existing content and adds a newline before the new entry", () => - withRepo(async (root) => { - const excludePath = path.join(root, ".git", "info", "exclude") - await Filesystem.write(excludePath, "*.log") - await ensureGitExclude(root) - const content = await Filesystem.readText(excludePath) - expect(content).toContain("*.log") - expect(content).toContain(".kilo/worktrees/") - })) + test("preserves existing content and adds a newline before the new entry", async () => { + await using tmp = await tmpdir({ git: true }) + const excludePath = path.join(tmp.path, ".git", "info", "exclude") + await Filesystem.write(excludePath, "*.log") + await ensureGitExclude(tmp.path) + const content = await Filesystem.readText(excludePath) + expect(content).toContain("*.log") + expect(content).toContain(".kilo/worktrees/") + }) - test("is idempotent when the entry already exists", () => - withRepo(async (root) => { - const excludePath = path.join(root, ".git", "info", "exclude") - await Filesystem.write(excludePath, "") - await ensureGitExclude(root) - await ensureGitExclude(root) - const content = await Filesystem.readText(excludePath) - expect(content.match(/\.kilo\/worktrees\//g)?.length).toBe(1) - })) + test("is idempotent when the entry already exists", async () => { + await using tmp = await tmpdir({ git: true }) + const excludePath = path.join(tmp.path, ".git", "info", "exclude") + await Filesystem.write(excludePath, "") + await ensureGitExclude(tmp.path) + await ensureGitExclude(tmp.path) + const content = await Filesystem.readText(excludePath) + expect(content.match(/\.kilo\/worktrees\//g)?.length).toBe(1) + }) - test("does not throw when .git/info is missing", () => - withRepo(async (root) => { - await ensureGitExclude(root) - })) + test("does not throw when .git/info is missing", async () => { + await using tmp = await tmpdir() + await ensureGitExclude(tmp.path) + }) }) From b57cfdce4325c35241f7fbee5ea483b141d3b72a Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Thu, 6 Aug 2026 16:23:43 +0200 Subject: [PATCH 08/11] feat(cli): add kilo worktree create/list/remove commands and TUI alias --- .../src/kilocode/cli/cmd/tui-worktree.ts | 4 +- .../opencode/src/kilocode/cli/cmd/worktree.ts | 80 +++++++++++++++++++ packages/opencode/src/kilocode/cli/setup.ts | 2 + packages/tui/src/app.tsx | 1 + 4 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/kilocode/cli/cmd/worktree.ts diff --git a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts index be3318c5fe9..78ba537c957 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts +++ b/packages/opencode/src/kilocode/cli/cmd/tui-worktree.ts @@ -94,7 +94,9 @@ function waitForWorktreeEvent( return { promise: deferred.promise, cancel: cleanup } } -async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_000) { +// Exported for `kilo worktree create` (worktree.ts), which calls this and +// exits instead of going on to launch the TUI. +export async function resolveWorktree(name: string, root: string, timeoutMs = 10 * 60_000) { const { Worktree } = await import("@/worktree") const { GlobalBus } = await import("@/bus/global") const { InstanceState } = await import("@/effect/instance-state") diff --git a/packages/opencode/src/kilocode/cli/cmd/worktree.ts b/packages/opencode/src/kilocode/cli/cmd/worktree.ts new file mode 100644 index 00000000000..016ef69b57f --- /dev/null +++ b/packages/opencode/src/kilocode/cli/cmd/worktree.ts @@ -0,0 +1,80 @@ +// kilocode_change - new file +// `kilo worktree list`/`remove`: CLI-side counterpart to `kilo --worktree ` +// (tui-worktree.ts) and the TUI's `/worktree` alias for the workspaces dialog +// (packages/tui/src/app.tsx). All three go through the same `Worktree.Service`. +import path from "path" +import { Effect } from "effect" +import { cmd } from "@/cli/cmd/cmd" +import { CliError, effectCmd, fail } from "@/cli/effect-cmd" +import { UI } from "@/cli/ui" +import { errorMessage } from "@/util/error" +import { slugify } from "@/kilocode/cli/cmd/tui-worktree" +import { Worktree } from "@/worktree" + +const wrapErr = (message: string) => (effect: Effect.Effect) => + effect.pipe(Effect.mapError((error) => new CliError({ message: `${message}: ${errorMessage(error)}` }))) + +const listWorktrees = Worktree.Service.use((svc) => svc.list()).pipe(wrapErr("Failed to list worktrees")) + +export const WorktreeCommand = cmd({ + command: "worktree", + describe: "manage git worktrees", + builder: (yargs) => + yargs.command(WorktreeCreateCommand).command(WorktreeListCommand).command(WorktreeRemoveCommand).demandCommand(), + async handler() {}, +}) + +export const WorktreeCreateCommand = cmd({ + command: "create ", + describe: "create (or reuse) a git worktree by name", + builder: (yargs) => yargs.positional("name", { type: "string", demandOption: true }), + async handler(args) { + // Plain cmd(), not effectCmd(): resolveWorktree loads/disposes its own + // instance context (it's shared with `kilo --worktree`'s pre-TUI-launch + // path in tui-worktree.ts), so it can't run inside effectCmd's own. + const { resolveWorktree } = await import("@/kilocode/cli/cmd/tui-worktree") + await resolveWorktree(args.name, process.cwd()).catch((error) => { + UI.error(errorMessage(error)) + process.exitCode = 1 + }) + }, +}) + +export const WorktreeListCommand = effectCmd({ + command: "list", + describe: "list git worktrees for the current project", + handler: Effect.fn("Cli.worktree.list")(function* () { + const list = yield* listWorktrees + if (!list.length) { + UI.println("No worktrees found.") + return + } + for (const w of list) UI.println(`${w.name}${w.branch ? ` (${w.branch})` : ""} ${w.directory}`) + }), +}) + +export const WorktreeRemoveCommand = effectCmd({ + command: "remove ", + describe: "remove a git worktree by name", + builder: (yargs) => yargs.positional("name", { type: "string", demandOption: true }), + handler: Effect.fn("Cli.worktree.remove")(function* (args) { + const slug = slugify(args.name) + if (!slug) { + yield* fail(`Invalid worktree name "${args.name}"`) + return + } + const list = yield* listWorktrees + // Matches the reuse logic in tui-worktree.ts: list() remaps `name` to the + // project ID when a worktree's basename collides with the primary + // checkout's, so also match on the directory basename. + const found = list.find((w) => w.name.toLowerCase() === slug || path.basename(w.directory).toLowerCase() === slug) + if (!found) { + yield* fail(`No worktree named "${args.name}" found.`) + return + } + yield* Worktree.Service.use((svc) => svc.remove({ directory: found.directory })).pipe( + wrapErr(`Failed to remove worktree "${args.name}"`), + ) + UI.println(`Removed worktree "${found.name}" at ${found.directory}`) + }), +}) diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index fd14af0f88f..d7fd3e3c071 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -11,6 +11,7 @@ import { DaemonCommand } from "@/kilocode/cli/cmd/daemon" import { DevSetupCommand, DevAliasCommand } from "@/kilocode/cli/dev-setup" import { RemoteCommand } from "@/cli/cmd/remote" import { ConfigCommand as ConfigCLICommand } from "@/cli/cmd/config" +import { WorktreeCommand } from "@/kilocode/cli/cmd/worktree" const log = Log.create({ service: "kilocode.cli" }) @@ -56,6 +57,7 @@ export namespace KiloCli { .command(RemoteCommand) .command(DaemonCommand) .command(ConfigCLICommand) + .command(WorktreeCommand) if (InstallationBuildKind !== "release") cli.command(DevSetupCommand).command(DevAliasCommand) // Safe self-reference: `cli` is a typed parameter and yargs `.command()` returns the same // instance, so the help command can resolve the fully-built root at handler time. This also diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 70843b974d3..f2e00fc000f 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -627,6 +627,7 @@ function App(props: { onSnapshot?: () => Promise; pluginHost: TuiPlugi category: "Workspace", hidden: !Flag.KILO_EXPERIMENTAL_WORKSPACES, slashName: "workspaces", + slashAliases: ["worktree", "worktrees"], // kilocode_change - `kilo --worktree` worktrees are workspaces too run: () => { dialog.replace(() => ) }, From e344cea702a59b5e6119db868a891f5bf7cb3ea5 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Thu, 6 Aug 2026 16:51:15 +0200 Subject: [PATCH 09/11] fix(cli): lazy-import @/worktree in worktree.ts to unblock cli-shutdown test --- packages/opencode/src/kilocode/cli/cmd/worktree.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/worktree.ts b/packages/opencode/src/kilocode/cli/cmd/worktree.ts index 016ef69b57f..0c60662aa0c 100644 --- a/packages/opencode/src/kilocode/cli/cmd/worktree.ts +++ b/packages/opencode/src/kilocode/cli/cmd/worktree.ts @@ -9,12 +9,19 @@ import { CliError, effectCmd, fail } from "@/cli/effect-cmd" import { UI } from "@/cli/ui" import { errorMessage } from "@/util/error" import { slugify } from "@/kilocode/cli/cmd/tui-worktree" -import { Worktree } from "@/worktree" const wrapErr = (message: string) => (effect: Effect.Effect) => effect.pipe(Effect.mapError((error) => new CliError({ message: `${message}: ${errorMessage(error)}` }))) -const listWorktrees = Worktree.Service.use((svc) => svc.list()).pipe(wrapErr("Failed to list worktrees")) +// Lazy: this module is imported eagerly by KiloCli.register (see setup.ts), so +// `@/worktree`'s heavier transitive graph (Project, Provider, ...) must not +// load until a handler actually runs, matching tui-worktree.ts's own imports. +const importWorktree = Effect.promise(() => import("@/worktree")) + +const listWorktrees = importWorktree.pipe( + Effect.flatMap(({ Worktree }) => Worktree.Service.use((svc) => svc.list())), + wrapErr("Failed to list worktrees"), +) export const WorktreeCommand = cmd({ command: "worktree", @@ -72,6 +79,7 @@ export const WorktreeRemoveCommand = effectCmd({ yield* fail(`No worktree named "${args.name}" found.`) return } + const { Worktree } = yield* importWorktree yield* Worktree.Service.use((svc) => svc.remove({ directory: found.directory })).pipe( wrapErr(`Failed to remove worktree "${args.name}"`), ) From 91747c04a153a76952a69c9a4b5dc635b0e11954 Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Thu, 6 Aug 2026 17:22:38 +0200 Subject: [PATCH 10/11] docs: update worktree-for-cli changeset to cover create/list/remove and /worktree --- .changeset/worktree-for-cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/worktree-for-cli.md b/.changeset/worktree-for-cli.md index 3c9e2a43b2f..e13eb58f33a 100644 --- a/.changeset/worktree-for-cli.md +++ b/.changeset/worktree-for-cli.md @@ -2,4 +2,4 @@ "@kilocode/cli": minor --- -Add `kilo --worktree ` to create (or reuse) a git worktree and start the TUI there, placed at `.kilo/worktrees/` alongside worktrees created by the VS Code extension's Agent Manager. Resuming an explicit `--session ` now tries to restart in the worktree the session was originally created in, if it still exists. +Add `kilo --worktree ` to create (or reuse) a git worktree and start the TUI there, placed at `.kilo/worktrees/` alongside worktrees created by the VS Code extension's Agent Manager. Also adds `kilo worktree create/list/remove` for managing worktrees without launching the TUI, and a `/worktree` command in the TUI to list and remove them. Resuming an explicit `--session ` now tries to restart in the worktree the session was originally created in, if it still exists. From b93b97557b064f646b1b02f89478e0d8bbbe012f Mon Sep 17 00:00:00 2001 From: Bruno Agatao Date: Fri, 7 Aug 2026 12:26:08 +0200 Subject: [PATCH 11/11] fix(cli): use Effect.tryPromise instead of Effect.promise for lazy @/worktree import --- .../opencode/src/kilocode/cli/cmd/worktree.ts | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/kilocode/cli/cmd/worktree.ts b/packages/opencode/src/kilocode/cli/cmd/worktree.ts index 0c60662aa0c..f9179b40d51 100644 --- a/packages/opencode/src/kilocode/cli/cmd/worktree.ts +++ b/packages/opencode/src/kilocode/cli/cmd/worktree.ts @@ -16,7 +16,9 @@ const wrapErr = (message: string) => (effect: Effect.Effect) = // Lazy: this module is imported eagerly by KiloCli.register (see setup.ts), so // `@/worktree`'s heavier transitive graph (Project, Provider, ...) must not // load until a handler actually runs, matching tui-worktree.ts's own imports. -const importWorktree = Effect.promise(() => import("@/worktree")) +// `tryPromise` (not `promise`): a failed dynamic import must flow through each +// call site's own `wrapErr`, not become an unrecoverable defect. +const importWorktree = Effect.tryPromise(() => import("@/worktree")) const listWorktrees = importWorktree.pipe( Effect.flatMap(({ Worktree }) => Worktree.Service.use((svc) => svc.list())), @@ -40,10 +42,14 @@ export const WorktreeCreateCommand = cmd({ // instance context (it's shared with `kilo --worktree`'s pre-TUI-launch // path in tui-worktree.ts), so it can't run inside effectCmd's own. const { resolveWorktree } = await import("@/kilocode/cli/cmd/tui-worktree") - await resolveWorktree(args.name, process.cwd()).catch((error) => { + const directory = await resolveWorktree(args.name, process.cwd()).catch((error) => { UI.error(errorMessage(error)) process.exitCode = 1 }) + // Prints only the resolved path on stdout (status messages already went + // to stderr via resolveWorktree's own UI.println calls), so scripts can + // do e.g. `cd "$(kilo worktree create foo)"`. + if (directory) console.log(directory) }, }) @@ -56,13 +62,18 @@ export const WorktreeListCommand = effectCmd({ UI.println("No worktrees found.") return } - for (const w of list) UI.println(`${w.name}${w.branch ? ` (${w.branch})` : ""} ${w.directory}`) + // Data rows go to stdout (like `kilo session list`'s table/JSON), not + // UI.println's stderr, so `kilo worktree list | ...` actually captures them. + for (const w of list) console.log(`${w.name}${w.branch ? ` (${w.branch})` : ""} ${w.directory}`) }), }) export const WorktreeRemoveCommand = effectCmd({ command: "remove ", - describe: "remove a git worktree by name", + // Worktree.Service.remove() force-deletes (`git branch -D`) the worktree's + // branch too, with no way to opt out (matches the TUI workspaces dialog's + // existing semantics) — make that explicit rather than surprise users. + describe: "remove a git worktree by name, deleting its branch too", builder: (yargs) => yargs.positional("name", { type: "string", demandOption: true }), handler: Effect.fn("Cli.worktree.remove")(function* (args) { const slug = slugify(args.name) @@ -79,10 +90,10 @@ export const WorktreeRemoveCommand = effectCmd({ yield* fail(`No worktree named "${args.name}" found.`) return } - const { Worktree } = yield* importWorktree - yield* Worktree.Service.use((svc) => svc.remove({ directory: found.directory })).pipe( - wrapErr(`Failed to remove worktree "${args.name}"`), - ) - UI.println(`Removed worktree "${found.name}" at ${found.directory}`) + const removeMsg = `Failed to remove worktree "${args.name}"` + const { Worktree } = yield* importWorktree.pipe(wrapErr(removeMsg)) + yield* Worktree.Service.use((svc) => svc.remove({ directory: found.directory })).pipe(wrapErr(removeMsg)) + const branchNote = found.branch ? ` and branch "${found.branch}"` : "" + UI.println(`Removed worktree "${found.name}" at ${found.directory}${branchNote}`) }), })