feat: add /reload action to reboot the instance from disk

Reboots the per-directory instance, reloading config, skills, agents,
commands, and MCP prompts changed on disk without restarting the
server. Sessions and history are preserved; only the per-directory
instance caches are torn down and rebuilt.

Server: POST /instance/reload wraps the existing atomic
InstanceStore.reload path (the same one project.git.init uses). The
rebuild completes before the 200 response, so clients can refetch with
no race. Returns 409 ConflictError while a session is actively
running. Emits the existing server.instance.disposed SSE event, which
the TUI and extension already use to auto-refetch.

CLI: /reload palette command calls the endpoint; the TUI already
bootstraps on server.instance.disposed.

Extension: /reload slash command, a reload button in the task header
and settings panel, and a Kilo Code: Reload Config and Skills command
palette entry. The handler clears the command cache and reuses
reloadAfterAuthChange to re-fetch config, providers, agents, skills,
and commands. Reload targets the current session's directory so Agent
Manager worktree sessions reload their own worktree instance rather
than the workspace root.

SDK: regenerate so client.instance.reload is available to external
integrations.
This commit is contained in:
marius-kilocode
2026-07-07 12:38:12 +02:00
parent 75b994330f
commit cef3dc7ae8
19 changed files with 405 additions and 0 deletions
@@ -15,6 +15,7 @@ import KiloSidebarPr from "@/kilocode/plugins/sidebar-pr"
import KiloSidebarUsage from "@/kilocode/plugins/sidebar-usage"
import KiloSandbox from "@/kilocode/plugins/sandbox"
import KiloRemote from "@/kilocode/plugins/remote"
import KiloReload from "@/kilocode/plugins/reload"
// kilocode_change end
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
@@ -54,6 +55,7 @@ export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalE
KiloSidebarUsage, // kilocode_change
KiloSandbox, // kilocode_change
KiloRemote, // kilocode_change
KiloReload, // kilocode_change
HomeFooter,
HomeTips,
SidebarContext,
@@ -0,0 +1,30 @@
import type { TuiPlugin, TuiPluginModule } from "@kilocode/plugin/tui"
const id = "internal:reload"
const tui: TuiPlugin = async (api) => {
api.keymap.registerLayer({
commands: [
{
namespace: "palette",
name: "app.reload",
title: "Reload",
desc: "Reload config, skills, agents, and commands from disk",
category: "System",
slashName: "reload",
async run() {
try {
await api.client.instance.reload({}, { throwOnError: true })
api.ui.toast({ message: "Reloaded", variant: "success" })
} catch (err) {
api.ui.toast({ message: String(err), variant: "error", duration: 5000 })
}
},
},
],
})
}
const plugin: TuiPluginModule & { id: string } = { id, tui }
export default plugin
@@ -0,0 +1,49 @@
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
import { ConflictError } from "@/server/routes/instance/httpapi/errors"
import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
export const ReloadPaths = {
reload: "/instance/reload",
} as const
export const InstanceReloadApi = HttpApi.make("instance-reload")
.add(
HttpApiGroup.make("instance-reload")
.add(
HttpApiEndpoint.post("reload", ReloadPaths.reload, {
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Instance reloaded"),
error: ConflictError,
}).annotateMerge(
OpenApi.annotations({
identifier: "instance.reload",
summary: "Reload instance",
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.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "instance-reload",
description: "Kilo instance reload route.",
}),
)
.middleware(InstanceContextMiddleware)
.middleware(WorkspaceRoutingMiddleware)
.middleware(Authorization),
)
.annotateMerge(
OpenApi.annotations({
title: "kilo HttpApi",
version: "0.0.1",
description: "Kilo HttpApi surface.",
}),
)
@@ -0,0 +1,40 @@
import * as InstanceState from "@/effect/instance-state"
import { SessionStatus } from "@/session/status"
import { ConflictError } from "@/server/routes/instance/httpapi/errors"
import { markInstanceForReload } from "@/server/routes/instance/httpapi/lifecycle"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import type { SessionID } from "@/session/schema"
export function hasBusySession(statuses: Map<SessionID, SessionStatus.Info>): boolean {
for (const info of statuses.values()) {
if (info.type === "busy") return true
}
return false
}
export const instanceReloadHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance-reload", (handlers) =>
Effect.gen(function* () {
const status = yield* SessionStatus.Service
const reload = Effect.fn("InstanceReloadHttpApi.reload")(function* () {
const ctx = yield* InstanceState.context
if (hasBusySession(yield* status.list())) {
return yield* Effect.fail(
new ConflictError({
message: "Cannot reload while a session is running. Wait for it to finish or abort it first.",
}),
)
}
yield* markInstanceForReload(ctx, {
directory: ctx.directory,
worktree: ctx.worktree,
project: ctx.project,
})
return true
})
return handlers.handle("reload", reload)
}),
)
@@ -15,6 +15,7 @@ import { commitMessageHandlers } from "./handlers/commit-message"
import { configConsoleHandlers } from "./handlers/config-console"
import { enhancePromptHandlers } from "./handlers/enhance-prompt"
import { indexingHandlers } from "./handlers/indexing"
import { instanceReloadHandlers } from "./handlers/instance-reload"
import { interactiveTerminalHandlers } from "./handlers/interactive-terminal"
import { kiloGatewayHandlers } from "./handlers/kilo-gateway"
import { kilocodeHandlers } from "./handlers/kilocode"
@@ -35,6 +36,7 @@ export const provide = Layer.provide([
configConsoleHandlers,
enhancePromptHandlers,
indexingHandlers,
instanceReloadHandlers,
interactiveTerminalHandlers,
kiloGatewayHandlers,
kilocodeHandlers,
@@ -28,6 +28,7 @@ import { BackgroundProcessApi } from "@/kilocode/server/httpapi/groups/backgroun
import { ConfigConsoleApi } from "@/kilocode/server/httpapi/groups/config-console"
import { EnhancePromptApi } from "@/kilocode/server/httpapi/groups/enhance-prompt"
import { IndexingApi } from "@/kilocode/server/httpapi/groups/indexing"
import { InstanceReloadApi } from "@/kilocode/server/httpapi/groups/instance-reload"
import { InteractiveTerminalApi } from "@/kilocode/server/httpapi/groups/interactive-terminal"
import { KiloGatewayApi } from "@/kilocode/server/httpapi/groups/kilo-gateway"
import { KilocodeApi } from "@/kilocode/server/httpapi/groups/kilocode"
@@ -76,6 +77,7 @@ export const InstanceHttpApi = HttpApi.make("opencode-instance")
.addHttpApi(ConfigConsoleApi)
.addHttpApi(EnhancePromptApi)
.addHttpApi(IndexingApi)
.addHttpApi(InstanceReloadApi)
.addHttpApi(InteractiveTerminalApi)
.addHttpApi(KiloGatewayApi)
.addHttpApi(KilocodeApi)
@@ -0,0 +1,43 @@
import { describe, expect, it } from "bun:test"
import { hasBusySession } from "@/kilocode/server/httpapi/handlers/instance-reload"
import type { SessionStatus } from "@/session/status"
import { SessionID } from "@/session/schema"
const entries = (items: [string, SessionStatus.Info][]) =>
items.map(([k, v]) => [k as SessionID, v] as [SessionID, SessionStatus.Info])
describe("instance-reload hasBusySession", () => {
it("returns false for an empty map", () => {
expect(hasBusySession(new Map(entries([])))).toBe(false)
})
it("returns false when all sessions are idle", () => {
expect(
hasBusySession(
new Map(
entries([
["s1", { type: "idle" }],
["s2", { type: "idle" }],
]),
),
),
).toBe(false)
})
it("returns true when any session is busy", () => {
expect(
hasBusySession(
new Map(
entries([
["s1", { type: "idle" }],
["s2", { type: "busy" }],
]),
),
),
).toBe(true)
})
it("returns true when the only session is busy", () => {
expect(hasBusySession(new Map(entries([["s1", { type: "busy" }]])))).toBe(true)
})
})