From 3a2c5d5c2e28980f36b8ac020206c11a626183ff Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 14 Sep 2026 09:30:59 +0200 Subject: [PATCH 1/2] feat(cli): reload the whole project from /reload Reload now reboots every loaded instance of the current project instead of only the directory the request came from. A reload started in an Agent Manager worktree therefore also refreshes the main checkout and sibling worktrees, which is what users expect after a project config change. The 409 guard now checks sessions across the whole project, so a busy session in any loaded instance of the project blocks the reload. The endpoint stays project-scoped for every caller, so the chat slash command, the settings button, the CLI palette, and JetBrains behave the same. The VS Code provider refreshes config, agents, skills, and commands after a successful reload so the UI reflects the reloaded state. --- .changeset/project-scoped-reload.md | 6 ++ .../pages/code-with-ai/platforms/cli.md | 2 +- packages/kilo-vscode/src/KiloProvider.ts | 11 +++- .../opencode/src/kilocode/project/reload.ts | 17 ++++++ .../server/httpapi/groups/instance-reload.ts | 4 +- .../httpapi/handlers/instance-reload.ts | 13 ++-- .../opencode/src/project/instance-store.ts | 11 ++++ .../instance-store-project-reload.test.ts | 59 +++++++++++++++++++ 8 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 .changeset/project-scoped-reload.md create mode 100644 packages/opencode/src/kilocode/project/reload.ts create mode 100644 packages/opencode/test/kilocode/instance-store-project-reload.test.ts diff --git a/.changeset/project-scoped-reload.md b/.changeset/project-scoped-reload.md new file mode 100644 index 00000000000..b0dbbbea3ed --- /dev/null +++ b/.changeset/project-scoped-reload.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": patch +--- + +Reload the entire project for `/reload` and the reload actions. A reload from an Agent Manager worktree now reboots every loaded instance of the same project, so a project config change applies to the main checkout and all worktrees. The reload is refused while any session in the project is running. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index 89c550f9a7e..a12731d2088 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -133,7 +133,7 @@ The `kilo console` command and its browser interface are deprecated and will be | `/status` | - | View status | | `/themes` | - | Switch theme | | `/help` | - | Show help | -| `/reload` | - | Reload config, skills, agents, and commands from disk | +| `/reload` | - | Reload every instance of the project from disk (config, skills, agents, and commands) | | `/editor` | - | Open external editor | | `/auto-approve` | `/autoapprove`, `/approve-all`, `/approveall` | Toggle auto-approve mode for all permission prompts (saved to global config) | | `/caffeinate` | `/caffenate` | Toggle Keep Awake: prevent system sleep while Kilo sessions run | diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 153505f439a..737a068016e 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -4767,7 +4767,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ]) } - /** Reload config, skills, agents, and commands from disk by rebooting the instance. */ + /** Reload config, skills, agents, and commands from disk by rebooting the project's instances. */ private async handleReload(): Promise { if (!this.client) { console.warn("[Kilo New] handleReload: no client connection") @@ -4783,7 +4783,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper cause && typeof cause === "object" && "status" in cause ? (cause as { status?: number }).status : undefined if (status === 409) { vscode.window.showWarningMessage( - "Cannot reload while a session is running. Wait for it to finish or abort it first.", + "Cannot reload while a session is running in this project. Wait for it to finish or abort it first.", ) return } @@ -4795,7 +4795,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.clearCommandsCache() if (!sameDirectory(dir, this.getWorkspaceDirectory())) { await this.reloadAfterAuthChange() + return } + await Promise.all([ + this.fetchAndSendConfig(), + this.fetchAndSendAgents(), + this.fetchAndSendSkills(), + this.fetchAndSendCommands(), + ]) } /** Public reload entry point for VS Code commands. */ diff --git a/packages/opencode/src/kilocode/project/reload.ts b/packages/opencode/src/kilocode/project/reload.ts new file mode 100644 index 00000000000..450c0aa4175 --- /dev/null +++ b/packages/opencode/src/kilocode/project/reload.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect" +import { InstanceStore } from "@/project/instance-store" + +/** Reload every loaded instance that belongs to a project. */ +export const reloadProject = (store: InstanceStore.Interface, projectID: string): Effect.Effect => + Effect.gen(function* () { + for (const ctx of yield* store.list()) { + if (String(ctx.project.id) !== projectID) continue + yield* store + .reload({ directory: ctx.directory, worktree: ctx.worktree, project: ctx.project }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("project instance reload failed", { directory: ctx.directory, cause }), + ), + ) + } + }) diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts b/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts index 64d04a598d7..ce7cc0613b9 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/instance-reload.ts @@ -24,9 +24,9 @@ export const InstanceReloadApi = HttpApi.make("instance-reload") }).annotateMerge( OpenApi.annotations({ identifier: "instance.reload", - summary: "Reload instance", + summary: "Reload project", description: - "Atomically dispose and reboot the current Kilo instance, reloading config, skills, agents, commands, and MCP prompts from disk. Returns 409 if a session is actively running.", + "Atomically dispose and reboot every loaded instance of the project, reloading config, skills, agents, commands, and MCP prompts from disk. Returns 409 if any session in the project is actively running.", }), ), ) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts index 4532b945f70..ca77f7c4db1 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts @@ -1,4 +1,5 @@ import * as InstanceState from "@/effect/instance-state" +import { reloadProject } from "@/kilocode/project/reload" import { InstanceStore } from "@/project/instance-store" import { SessionStatus } from "@/session/status" import { ConflictError } from "@/server/routes/instance/httpapi/errors" @@ -16,23 +17,19 @@ export function hasActiveSession(statuses: Map): export const instanceReloadHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance-reload", (handlers) => Effect.gen(function* () { - const status = yield* SessionStatus.Service const store = yield* InstanceStore.Service const reload = Effect.fn("InstanceReloadHttpApi.reload")(function* () { const ctx = yield* InstanceState.context - if (hasActiveSession(yield* status.list())) { + if (hasActiveSession(yield* SessionStatus.listAll())) { return yield* Effect.fail( new ConflictError({ - message: "Cannot reload while a session is running. Wait for it to finish or abort it first.", + message: + "Cannot reload while a session is running in this project. Wait for it to finish or abort it first.", }), ) } - yield* store.reload({ - directory: ctx.directory, - worktree: ctx.worktree, - project: ctx.project, - }) + yield* reloadProject(store, String(ctx.project.id)) return true }) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index be56d0406a6..330b450292c 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -24,6 +24,9 @@ export interface Interface { readonly disposeDirectory: (directory: string) => Effect.Effect readonly disposeAll: () => Effect.Effect readonly provide: (input: LoadInput, effect: Effect.Effect) => Effect.Effect + // kilocode_change start + readonly list: () => Effect.Effect + // kilocode_change end } export class Service extends Context.Service()("@opencode/InstanceStore") {} @@ -223,11 +226,19 @@ const layer: Layer.Layer(input: LoadInput, effect: Effect.Effect): Effect.Effect => load(input).pipe(Effect.flatMap((ctx) => effect.pipe(Effect.provideService(InstanceRef, ctx)))) + // kilocode_change start - loaded instance contexts for project-scoped operations + const list = (): Effect.Effect => + Effect.forEach([...cache.values()], (entry) => Deferred.await(entry.deferred).pipe(Effect.exit)).pipe( + Effect.map((exits) => exits.filter(Exit.isSuccess).map((exit) => exit.value)), + ) + // kilocode_change end + yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) return Service.of({ load, reload, + list, // kilocode_change dispose, disposeDirectory, disposeAll, diff --git a/packages/opencode/test/kilocode/instance-store-project-reload.test.ts b/packages/opencode/test/kilocode/instance-store-project-reload.test.ts new file mode 100644 index 00000000000..03cde0f0ccf --- /dev/null +++ b/packages/opencode/test/kilocode/instance-store-project-reload.test.ts @@ -0,0 +1,59 @@ +import { describe, expect } from "bun:test" +import { mkdtemp, realpath, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { ProjectV2 } from "@opencode-ai/core/project" +import { Effect } from "effect" +import { reloadProject } from "@/kilocode/project/reload" +import type { Project } from "@/project/project" +import { InstanceStore } from "@/project/instance-store" +import { testEffect } from "../lib/effect" +import { testInstanceStoreLayer } from "../fixture/fixture" + +const it = testEffect(testInstanceStoreLayer) + +const temporary = Effect.acquireRelease( + Effect.promise(async () => await realpath(await mkdtemp(join(tmpdir(), "opencode-test-")))), + (dir) => Effect.promise(() => rm(dir, { recursive: true, force: true })), +) + +const makeProject = (id: string, worktree: string): Project.Info => ({ + id: ProjectV2.ID.make(id), + worktree, + sandboxes: [], + time: { created: 0, updated: 0 }, +}) + +describe("reloadProject", () => { + it.live("reloads every loaded instance of the project and leaves other projects alone", () => + Effect.gen(function* () { + const dirA = yield* temporary + const dirB = yield* temporary + const dirC = yield* temporary + const store = yield* InstanceStore.Service + + const a = yield* store.load({ directory: dirA, worktree: dirA, project: makeProject("proj_reload_shared", dirA) }) + const b = yield* store.load({ directory: dirB, worktree: dirB, project: makeProject("proj_reload_shared", dirB) }) + const c = yield* store.load({ directory: dirC, worktree: dirC, project: makeProject("proj_reload_other", dirC) }) + + yield* reloadProject(store, "proj_reload_shared") + + expect(yield* store.load({ directory: dirA })).not.toBe(a) + expect(yield* store.load({ directory: dirB })).not.toBe(b) + expect(yield* store.load({ directory: dirC })).toBe(c) + }), + ) + + it.live("is a no-op for a project with no loaded instances", () => + Effect.gen(function* () { + const dir = yield* temporary + const store = yield* InstanceStore.Service + + const loaded = yield* store.load({ directory: dir, worktree: dir, project: makeProject("proj_reload_solo", dir) }) + + yield* reloadProject(store, "proj_reload_missing") + + expect(yield* store.load({ directory: dir })).toBe(loaded) + }), + ) +}) From 54619501917bb24ecc07dd8aad3d1d3f4be221ea Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 14 Sep 2026 09:40:58 +0200 Subject: [PATCH 2/2] fix(cli): surface reload failures and skip in-flight instances The request directory's reload failure now propagates so callers still see a failed reboot; sibling failures stay logged and isolated. InstanceStore.list() only returns settled instances, so a reload is no longer blocked by another project's in-flight boot. --- .../opencode/src/kilocode/project/reload.ts | 30 ++++++++---- .../httpapi/handlers/instance-reload.ts | 2 +- .../opencode/src/project/instance-store.ts | 15 ++++-- .../instance-store-project-reload.test.ts | 46 +++++++++++++++++-- 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/kilocode/project/reload.ts b/packages/opencode/src/kilocode/project/reload.ts index 450c0aa4175..5f98cba2271 100644 --- a/packages/opencode/src/kilocode/project/reload.ts +++ b/packages/opencode/src/kilocode/project/reload.ts @@ -1,17 +1,29 @@ import { Effect } from "effect" import { InstanceStore } from "@/project/instance-store" -/** Reload every loaded instance that belongs to a project. */ -export const reloadProject = (store: InstanceStore.Interface, projectID: string): Effect.Effect => +/** + * Reload every loaded instance that belongs to a project. + * + * Failures for the directory the request came from propagate so callers still + * see a failed reboot. Failures for sibling instances are logged and skipped. + */ +export const reloadProject = ( + store: InstanceStore.Interface, + projectID: string, + requestDirectory: string, +): Effect.Effect => Effect.gen(function* () { for (const ctx of yield* store.list()) { if (String(ctx.project.id) !== projectID) continue - yield* store - .reload({ directory: ctx.directory, worktree: ctx.worktree, project: ctx.project }) - .pipe( - Effect.catchCause((cause) => - Effect.logWarning("project instance reload failed", { directory: ctx.directory, cause }), - ), - ) + const reload = store.reload({ directory: ctx.directory, worktree: ctx.worktree, project: ctx.project }) + if (ctx.directory === requestDirectory) { + yield* reload + continue + } + yield* reload.pipe( + Effect.catchCause((cause) => + Effect.logWarning("project instance reload failed", { directory: ctx.directory, cause }), + ), + ) } }) diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts index ca77f7c4db1..e092f23e14f 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/instance-reload.ts @@ -29,7 +29,7 @@ export const instanceReloadHandlers = HttpApiBuilder.group(InstanceHttpApi, "ins }), ) } - yield* reloadProject(store, String(ctx.project.id)) + yield* reloadProject(store, String(ctx.project.id), ctx.directory) return true }) diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 330b450292c..3927f55d9a2 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -228,9 +228,18 @@ const layer: Layer.Layer => - Effect.forEach([...cache.values()], (entry) => Deferred.await(entry.deferred).pipe(Effect.exit)).pipe( - Effect.map((exits) => exits.filter(Exit.isSuccess).map((exit) => exit.value)), - ) + Effect.forEach([...cache.values()], (entry) => + Deferred.isDone(entry.deferred).pipe( + Effect.flatMap((done) => + done + ? Deferred.await(entry.deferred).pipe( + Effect.exit, + Effect.map((exit) => (Exit.isSuccess(exit) ? exit.value : undefined)), + ) + : Effect.succeed(undefined), + ), + ), + ).pipe(Effect.map((contexts) => contexts.filter((ctx): ctx is InstanceContext => ctx !== undefined))) // kilocode_change end yield* Effect.addFinalizer(() => disposeAll().pipe(Effect.ignore)) diff --git a/packages/opencode/test/kilocode/instance-store-project-reload.test.ts b/packages/opencode/test/kilocode/instance-store-project-reload.test.ts index 03cde0f0ccf..f2501ce571d 100644 --- a/packages/opencode/test/kilocode/instance-store-project-reload.test.ts +++ b/packages/opencode/test/kilocode/instance-store-project-reload.test.ts @@ -3,8 +3,9 @@ import { mkdtemp, realpath, rm } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { ProjectV2 } from "@opencode-ai/core/project" -import { Effect } from "effect" +import { Effect, Exit } from "effect" import { reloadProject } from "@/kilocode/project/reload" +import type { InstanceContext } from "@/project/instance-context" import type { Project } from "@/project/project" import { InstanceStore } from "@/project/instance-store" import { testEffect } from "../lib/effect" @@ -24,6 +25,15 @@ const makeProject = (id: string, worktree: string): Project.Info => ({ time: { created: 0, updated: 0 }, }) +const context = (dir: string, id: string): InstanceContext => ({ + directory: dir, + worktree: dir, + project: makeProject(id, dir), +}) + +const stub = (contexts: InstanceContext[], reload: InstanceStore.Interface["reload"]) => + ({ list: () => Effect.succeed(contexts), reload }) as unknown as InstanceStore.Interface + describe("reloadProject", () => { it.live("reloads every loaded instance of the project and leaves other projects alone", () => Effect.gen(function* () { @@ -36,7 +46,7 @@ describe("reloadProject", () => { const b = yield* store.load({ directory: dirB, worktree: dirB, project: makeProject("proj_reload_shared", dirB) }) const c = yield* store.load({ directory: dirC, worktree: dirC, project: makeProject("proj_reload_other", dirC) }) - yield* reloadProject(store, "proj_reload_shared") + yield* reloadProject(store, "proj_reload_shared", dirA) expect(yield* store.load({ directory: dirA })).not.toBe(a) expect(yield* store.load({ directory: dirB })).not.toBe(b) @@ -51,9 +61,39 @@ describe("reloadProject", () => { const loaded = yield* store.load({ directory: dir, worktree: dir, project: makeProject("proj_reload_solo", dir) }) - yield* reloadProject(store, "proj_reload_missing") + yield* reloadProject(store, "proj_reload_missing", dir) expect(yield* store.load({ directory: dir })).toBe(loaded) }), ) + + it.live("propagates a failure for the request directory", () => + Effect.gen(function* () { + const dirA = "/tmp/reload-request-a" + const dirB = "/tmp/reload-request-b" + const contexts = [context(dirA, "proj_reload_stub"), context(dirB, "proj_reload_stub")] + const store = stub(contexts, (input) => + input.directory === dirA ? Effect.die(new Error("request reload failed")) : Effect.succeed(contexts[1]!), + ) + + const exit = yield* reloadProject(store, "proj_reload_stub", dirA).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + }), + ) + + it.live("skips a failure for a sibling directory", () => + Effect.gen(function* () { + const dirA = "/tmp/reload-sibling-a" + const dirB = "/tmp/reload-sibling-b" + const contexts = [context(dirA, "proj_reload_stub"), context(dirB, "proj_reload_stub")] + const store = stub(contexts, (input) => + input.directory === dirB ? Effect.die(new Error("sibling reload failed")) : Effect.succeed(contexts[0]!), + ) + + const exit = yield* reloadProject(store, "proj_reload_stub", dirA).pipe(Effect.exit) + + expect(Exit.isSuccess(exit)).toBe(true) + }), + ) })