From 4ca951c88508ab259ebf5f02990145524dce06d9 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Tue, 25 Aug 2026 13:05:02 +0200 Subject: [PATCH] perf(cli): optimize startup time --- .changeset/quick-clis-rest.md | 5 + packages/opencode/src/index.ts | 48 ++--- .../src/kilo-sessions/kilo-sessions.ts | 7 + .../src/kilocode/cli/bootstrap-runtime.ts | 30 +++ .../src/kilocode/cli/lazy-commands.ts | 172 ++++++++++++++++++ .../src/kilocode/cli/lazy-kilo-commands.ts | 67 +++++++ packages/opencode/src/kilocode/cli/setup.ts | 79 ++++---- .../src/kilocode/config/default-plugins.ts | 7 +- .../opencode/src/kilocode/indexing-feature.ts | 20 -- .../src/kilocode/storage/json-migration.ts | 5 +- .../test/kilocode/cli-shutdown.test.ts | 23 +-- .../kilocode/cli/bootstrap-runtime.test.ts | 14 ++ .../test/kilocode/cli/lazy-commands.test.ts | 49 +++++ .../test/kilocode/cli/lazy-completion.test.ts | 14 ++ .../test/kilocode/indexing-feature.test.ts | 10 - .../ingest-shutdown-lifecycle.test.ts | 59 ++++++ .../kilocode/storage/json-migration.test.ts | 16 ++ 17 files changed, 513 insertions(+), 112 deletions(-) create mode 100644 .changeset/quick-clis-rest.md create mode 100644 packages/opencode/src/kilocode/cli/bootstrap-runtime.ts create mode 100644 packages/opencode/src/kilocode/cli/lazy-commands.ts create mode 100644 packages/opencode/src/kilocode/cli/lazy-kilo-commands.ts create mode 100644 packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts create mode 100644 packages/opencode/test/kilocode/cli/lazy-commands.test.ts create mode 100644 packages/opencode/test/kilocode/cli/lazy-completion.test.ts create mode 100644 packages/opencode/test/kilocode/sessions/ingest-shutdown-lifecycle.test.ts diff --git a/.changeset/quick-clis-rest.md b/.changeset/quick-clis-rest.md new file mode 100644 index 0000000000..d7b3d48509 --- /dev/null +++ b/.changeset/quick-clis-rest.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Improve CLI cold and warm startup time. diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index f23744f03f..6fc98fe18f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,37 +1,42 @@ import yargs from "yargs" import { hideBin } from "yargs/helpers" -import { RunCommand } from "./cli/cmd/run" -import { GenerateCommand } from "./cli/cmd/generate" // kilocode_change - upstream account console intentionally omitted; KiloCli registers `kilo console` for local settings -import { ProvidersCommand } from "./cli/cmd/providers" -import { AgentCommand } from "./cli/cmd/agent" -import { UpgradeCommand } from "./cli/cmd/upgrade" -import { UninstallCommand } from "./cli/cmd/uninstall" -import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" +import { TuiThreadCommand } from "./cli/cmd/tui" // kilocode_change - yargs requires the default command builder eagerly import { InstallationVersion } from "@opencode-ai/core/installation/version" import { FormatError } from "./cli/error" -import { ServeCommand } from "./cli/cmd/serve" -import { DebugCommand } from "./cli/cmd/debug" -import { StatsCommand } from "./cli/cmd/stats" -import { McpCommand } from "./cli/cmd/mcp" -import { GithubCommand } from "./cli/cmd/github" -import { ExportCommand } from "./cli/cmd/export" -import { ImportCommand } from "./cli/cmd/import" -import { AttachCommand } from "./cli/cmd/attach" -import { TuiThreadCommand } from "./cli/cmd/tui" -import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" // kilocode_change - upstream web command intentionally omitted; Kilo does not ship an embedded web UI -import { PrCommand } from "./cli/cmd/pr" -import { SessionCommand } from "./cli/cmd/session" -import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" -import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { KiloCli } from "@/kilocode/cli/setup" // kilocode_change import * as Log from "@opencode-ai/core/util/log" // kilocode_change import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" // kilocode_change +// kilocode_change start - defer heavy command implementations until yargs selects them +import { + AcpCommand, + AgentCommand, + AttachCommand, + DbCommand, + DebugCommand, + ExportCommand, + GenerateCommand, + GithubCommand, + ImportCommand, + McpCommand, + ModelsCommand, + PluginCommand, + PrCommand, + ProvidersCommand, + RunCommand, + ServeCommand, + SessionCommand, + StatsCommand, + UninstallCommand, + UpgradeCommand, + waitForLazyCommands, +} from "@/kilocode/cli/lazy-commands" +// kilocode_change end const args = hideBin(process.argv) const metadata = ensureProcessMetadata("main") // kilocode_change - correlate logs across the CLI and TUI worker @@ -119,6 +124,7 @@ let cli = yargs(args) // kilocode_change // kilocode_change start - register Kilo-specific commands after the upstream chain cli = KiloCli.register(cli) +await waitForLazyCommands() // kilocode_change - yargs completion invokes builders synchronously cli = cli // kilocode_change end .fail((msg, err) => { diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 571c8ead89..c7ebc30282 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -47,6 +47,7 @@ import { Snapshot } from "@/snapshot" import { cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change +import { KiloShutdown } from "@/kilocode/cli/shutdown" async function provide(input: { directory: string; fn: () => R }): Promise { const { provide } = await import("@/kilocode/instance") @@ -257,11 +258,17 @@ export namespace KiloSessions { () => ingest.drain(), (err) => log.warn("ingest drain failed", { err }), ) + KiloShutdown.register(drainIngest) export async function drainIngestForShutdown() { await drainIngest() } + /** @internal - lifecycle regression coverage */ + export function _queueIngestForTest(sessionId: string) { + return ingest.sync(sessionId, [{ type: "session_status", data: { status: "idle" } }]) + } + const remoteEnabled = process.env["KILO_REMOTE"] === "1" let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined let enabling: Promise | undefined diff --git a/packages/opencode/src/kilocode/cli/bootstrap-runtime.ts b/packages/opencode/src/kilocode/cli/bootstrap-runtime.ts new file mode 100644 index 0000000000..2fb98f93fe --- /dev/null +++ b/packages/opencode/src/kilocode/cli/bootstrap-runtime.ts @@ -0,0 +1,30 @@ +import { Account } from "@/account/account" +import { Auth } from "@/auth" +import { Config } from "@/config/config" +import { makeRuntime } from "@/effect/run-service" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Effect, Layer, Option } from "effect" + +const account = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), +}) +const config = makeRuntime(Config.Service, AppNodeBuilder.build(Config.node, [[Account.node, account]])) +const auth = makeRuntime(Auth.Service, Auth.defaultLayer) + +export namespace KiloCliBootstrapRuntime { + export function getGlobal() { + return config.runPromise((service) => service.getGlobal()) + } + + export function getAuth() { + return auth.runPromise((service) => service.get("kilo")) + } + + export function setAuth(info: Auth.Info) { + return auth.runPromise((service) => service.set("kilo", info)) + } + + export async function dispose() { + await Promise.all([config.dispose(), auth.dispose()]) + } +} diff --git a/packages/opencode/src/kilocode/cli/lazy-commands.ts b/packages/opencode/src/kilocode/cli/lazy-commands.ts new file mode 100644 index 0000000000..ee49d9d5b6 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/lazy-commands.ts @@ -0,0 +1,172 @@ +import type { Argv, ArgumentsCamelCase, CommandModule } from "yargs" + +type Load = () => Promise> +const completion = process.argv.includes("--get-yargs-completions") +const tasks: Promise[] = [] +let selected = false + +function build(command: CommandModule, args: Argv) { + if (!command.builder) return args as unknown as Argv + if (typeof command.builder === "function") return command.builder(args) + return args.options(command.builder) as unknown as Argv +} + +export function waitForLazyCommands() { + return Promise.all(tasks) +} + +export function hasLazyCommandSelection() { + return selected +} + +export function lazy(input: { + command: string | readonly string[] + aliases?: string | readonly string[] + describe?: string | false + load: Load +}): CommandModule { + const state: { command?: CommandModule; task?: Promise> } = {} + const load = () => (state.task ??= input.load()) + if (completion) { + tasks.push( + load().then((command) => { + state.command = command + }), + ) + } + return { + command: input.command, + aliases: input.aliases, + describe: input.describe, + builder: ((args: Argv) => { + selected = true + if (state.command) return build(state.command, args) + return load().then((command) => build(command, args)) + }) as never, + async handler(args: ArgumentsCamelCase) { + const command = await load() + await command.handler(args) + }, + } +} + +export const AcpCommand = lazy({ + command: "acp", + describe: "start ACP (Agent Client Protocol) server", + load: async () => (await import("@/cli/cmd/acp")).AcpCommand, +}) + +export const AttachCommand = lazy({ + command: "attach ", + describe: "attach to a running kilo server", + load: async () => (await import("@/cli/cmd/attach")).AttachCommand, +}) + +export const RunCommand = lazy({ + command: "run [message..]", + describe: "run kilo with a message", + load: async () => (await import("@/cli/cmd/run")).RunCommand, +}) + +export const GenerateCommand = lazy({ + command: "generate", + load: async () => (await import("@/cli/cmd/generate")).GenerateCommand, +}) + +export const McpCommand = lazy({ + command: "mcp", + describe: "manage MCP (Model Context Protocol) servers", + load: async () => (await import("@/cli/cmd/mcp")).McpCommand, +}) + +export const DebugCommand = lazy({ + command: "debug", + describe: "debugging and troubleshooting tools", + load: async () => (await import("@/cli/cmd/debug")).DebugCommand, +}) + +export const ProvidersCommand = lazy({ + command: "auth", + aliases: ["providers"], + describe: "manage AI providers and credentials", + load: async () => (await import("@/cli/cmd/providers")).ProvidersCommand, +}) + +export const AgentCommand = lazy({ + command: "agent", + describe: "manage agents", + load: async () => (await import("@/cli/cmd/agent")).AgentCommand, +}) + +export const UpgradeCommand = lazy({ + command: "upgrade [target]", + describe: "upgrade kilo to the latest or a specific version", + load: async () => (await import("@/cli/cmd/upgrade")).UpgradeCommand, +}) + +export const UninstallCommand = lazy({ + command: "uninstall", + describe: "uninstall kilo and remove all related files", + load: async () => (await import("@/cli/cmd/uninstall")).UninstallCommand, +}) + +export const ServeCommand = lazy({ + command: "serve", + describe: "starts a headless kilo server", + load: async () => (await import("@/cli/cmd/serve")).ServeCommand, +}) + +export const ModelsCommand = lazy({ + command: "models [provider]", + describe: "list all available models", + load: async () => (await import("@/cli/cmd/models")).ModelsCommand, +}) + +export const StatsCommand = lazy({ + command: "stats", + describe: "show token usage and cost statistics", + load: async () => (await import("@/cli/cmd/stats")).StatsCommand, +}) + +export const ExportCommand = lazy({ + command: "export [sessionID]", + describe: "export session data as JSON", + load: async () => (await import("@/cli/cmd/export")).ExportCommand, +}) + +export const ImportCommand = lazy({ + command: "import ", + describe: "import session data from JSON file or URL", + load: async () => (await import("@/cli/cmd/import")).ImportCommand, +}) + +export const SessionCommand = lazy({ + command: "session", + describe: "manage sessions", + load: async () => (await import("@/cli/cmd/session")).SessionCommand, +}) + +export const GithubCommand = lazy({ + command: "github", + describe: "manage GitHub agent", + load: async () => (await import("@/cli/cmd/github")).GithubCommand, +}) + +export const PrCommand = lazy({ + command: "pr", + describe: "manage pull requests", + load: async () => (await import("@/cli/cmd/pr")).PrCommand, +}) + +export const PluginCommand = lazy({ + command: "plugin ", + aliases: ["plug"], + describe: "install plugin and update config", + load: async () => (await import("@/cli/cmd/plug")).PluginCommand, +}) + +export const DbCommand = lazy({ + command: "db", + describe: "database tools", + load: async () => (await import("@/cli/cmd/db")).DbCommand, +}) diff --git a/packages/opencode/src/kilocode/cli/lazy-kilo-commands.ts b/packages/opencode/src/kilocode/cli/lazy-kilo-commands.ts new file mode 100644 index 0000000000..d8e4ab4bc8 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/lazy-kilo-commands.ts @@ -0,0 +1,67 @@ +import { lazy } from "@/kilocode/cli/lazy-commands" + +export const KiloConsoleCommand = lazy({ + command: "console", + describe: "open or stop the local Kilo Console (deprecated)", + load: async () => (await import("@/kilocode/cli/cmd/console")).KiloConsoleCommand, +}) + +export const CloudCommand = lazy({ + command: "cloud", + describe: "run Cloud Agent tasks", + load: async () => (await import("@/kilocode/cli/cmd/cloud")).CloudCommand, +}) + +export const RollCallCommand = lazy({ + command: "roll-call ", + describe: "batch-test text models matching a filter for connectivity and latency", + load: async () => (await import("@/kilocode/cli/cmd/roll-call")).RollCallCommand, +}) + +export const ProfileCommand = lazy({ + command: "profile", + describe: "show Kilo account profile", + load: async () => (await import("@/kilocode/cli/cmd/profile")).ProfileCommand, +}) + +export const RemoteCommand = lazy({ + command: "remote", + describe: "enable remote connection for real-time session relay", + load: async () => (await import("@/cli/cmd/remote")).RemoteCommand, +}) + +export const DaemonCommand = lazy({ + command: "daemon", + describe: "manage the local kilo daemon", + load: async () => (await import("@/kilocode/cli/cmd/daemon")).DaemonCommand, +}) + +export const ConfigCLICommand = lazy({ + command: "config", + describe: "configuration tools", + load: async () => (await import("@/cli/cmd/config")).ConfigCommand, +}) + +export const WorktreeCommand = lazy({ + command: "worktree", + describe: "manage git worktrees", + load: async () => (await import("@/kilocode/cli/cmd/worktree")).WorktreeCommand, +}) + +export const PtySmokeCommand = lazy({ + command: "__pty-smoke", + describe: false, + load: async () => (await import("@/kilocode/cli/cmd/pty-smoke")).PtySmokeCommand, +}) + +export const DevSetupCommand = lazy({ + command: "dev-setup", + describe: "install a `kilodev` shell alias for this checkout", + load: async () => (await import("@/kilocode/cli/dev-setup")).DevSetupCommand, +}) + +export const DevAliasCommand = lazy({ + command: "dev-alias [shell]", + describe: false, + load: async () => (await import("@/kilocode/cli/dev-setup")).DevAliasCommand, +}) diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index c434ab60ee..7557d3c88a 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -1,39 +1,26 @@ import type { Argv } from "yargs" +import type { Auth } from "@/auth" import * as Log from "@opencode-ai/core/util/log" import { InstallationBuildKind, InstallationVersion } from "@opencode-ai/core/installation/version" import { KiloShutdown } from "@/kilocode/cli/shutdown" import { createHelpCommand } from "@/kilocode/help-command" -import { KiloConsoleCommand } from "@/kilocode/cli/cmd/console" -import { CloudCommand } from "@/kilocode/cli/cmd/cloud" -import { RollCallCommand } from "@/kilocode/cli/cmd/roll-call" -import { ProfileCommand } from "@/kilocode/cli/cmd/profile" -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" -import { PtySmokeCommand } from "@/kilocode/cli/cmd/pty-smoke" +import { hasLazyCommandSelection } from "@/kilocode/cli/lazy-commands" +import { + CloudCommand, + ConfigCLICommand, + DaemonCommand, + DevAliasCommand, + DevSetupCommand, + KiloConsoleCommand, + ProfileCommand, + PtySmokeCommand, + RemoteCommand, + RollCallCommand, + WorktreeCommand, +} from "@/kilocode/cli/lazy-kilo-commands" const log = Log.create({ service: "kilocode.cli" }) -// Process-level ingest drain for non-TUI commands (`kilo run`, etc.). -// KiloCli.shutdown() runs KiloShutdown before disposeAllInstances — preserve that order. -// Registered at setup load time (not inside shutdown()) so the task is always present. -// Dynamic import keeps setup.ts's own static import graph unchanged: consumers that load -// setup.ts under partial module mocks (e.g. cli-shutdown tests whose @/auth mock omits -// OAUTH_DUMMY_KEY) would otherwise fail to link the provider/plugin chain. Dynamic import -// returns the same in-process module singleton, so the drained queue is the one that -// received events. Task try/catch covers dynamic-import failure outside the shared drain -// guard; the drain itself never rejects. -KiloShutdown.register(async () => { - try { - const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions") - await KiloSessions.drainIngestForShutdown() - } catch (err) { - log.warn("ingest drain failed", { err }) - } -}) - // All Kilo-specific CLI customization lives here so the shared upstream entrypoint // (src/index.ts) only needs a handful of thin call-sites behind kilocode_change markers. // This keeps index.ts close to upstream and reduces merge conflicts on every sync. @@ -46,6 +33,11 @@ KiloShutdown.register(async () => { // top level, with implementation imports inside their handlers. export namespace KiloCli { let info = false + let narrow = false + + export function workerTui(opts: { [key: string]: unknown }) { + return !hasLazyCommandSelection() && opts.mini !== true && !opts.worktree + } // Register only the Kilo-specific commands. Upstream commands stay in index.ts's chain so // upstream merges that add or remove commands keep working without touching this file. @@ -78,6 +70,7 @@ export namespace KiloCli { export async function bootstrap(opts: { [key: string]: unknown }): Promise { info = opts.help === true || opts.version === true if (info) return + narrow = workerTui(opts) const { KiloLog } = await import("@/kilocode/log") await KiloLog.init() @@ -93,9 +86,11 @@ export namespace KiloCli { const { JsonMigration } = await import("@/kilocode/storage/json-migration") await JsonMigration.bootstrap() - const { AppRuntime } = await import("@/effect/app-runtime") - const { Config } = await import("@/config/config") - const cfg = await AppRuntime.runPromise(Config.Service.use((c) => c.getGlobal())) + const runtime = narrow ? await import("@/kilocode/cli/bootstrap-runtime") : undefined + const app = narrow ? undefined : await import("@/effect/app-runtime") + const cfg = runtime + ? await runtime.KiloCliBootstrapRuntime.getGlobal() + : await app!.AppRuntime.runPromise((await import("@/config/config")).Config.Service.use((c) => c.getGlobal())) const { Global } = await import("@opencode-ai/core/global") const { Telemetry } = await import("@kilocode/kilo-telemetry") @@ -105,16 +100,25 @@ export namespace KiloCli { enabled: cfg.experimental?.openTelemetry !== false, }) - const { Auth } = await import("@/auth") const { migrateLegacyKiloAuth } = gateway + const getAuth = async () => { + if (runtime) return runtime.KiloCliBootstrapRuntime.getAuth() + const { Auth } = await import("@/auth") + return app!.AppRuntime.runPromise(Auth.Service.use((s) => s.get("kilo"))) + } + const setAuth = async (auth: Auth.Info) => { + if (runtime) return runtime.KiloCliBootstrapRuntime.setAuth(auth) + const { Auth } = await import("@/auth") + return app!.AppRuntime.runPromise(Auth.Service.use((s) => s.set("kilo", auth))) + } // Migrate legacy Kilo CLI auth (~/.kilocode/cli/config.json) into auth.json if present. await migrateLegacyKiloAuth( - async () => (await AppRuntime.runPromise(Auth.Service.use((s) => s.get("kilo")))) !== undefined, - async (auth) => AppRuntime.runPromise(Auth.Service.use((s) => s.set("kilo", auth))), + async () => (await getAuth()) !== undefined, + setAuth, ) - const auth = await AppRuntime.runPromise(Auth.Service.use((s) => s.get("kilo"))) + const auth = await getAuth() if (auth) { const token = auth.type === "oauth" ? auth.access : auth.key const account = auth.type === "oauth" ? auth.accountId : undefined @@ -146,6 +150,11 @@ export namespace KiloCli { } } finally { await KiloShutdown.run() + if (narrow) { + const { KiloCliBootstrapRuntime } = await import("@/kilocode/cli/bootstrap-runtime") + await KiloCliBootstrapRuntime.dispose() + return + } const { InstanceRuntime } = await import("@/project/instance-runtime") await InstanceRuntime.disposeAllInstances() // safety net (no-op if already disposed) } diff --git a/packages/opencode/src/kilocode/config/default-plugins.ts b/packages/opencode/src/kilocode/config/default-plugins.ts index 1b4740b9d6..146246d449 100644 --- a/packages/opencode/src/kilocode/config/default-plugins.ts +++ b/packages/opencode/src/kilocode/config/default-plugins.ts @@ -1,16 +1,13 @@ -import { createRequire } from "module" import { ConfigPlugin } from "@/config/plugin" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect" import { ensureAtomicChatPlugin, isAtomicChatPlugin } from "@/kilocode/atomic-chat-feature" -import { ensureIndexingPlugin, resolveIndexingPlugin } from "@/kilocode/indexing-feature" +import { ensureIndexingPlugin, INDEXING_PLUGIN } from "@/kilocode/indexing-feature" type Log = { debug: (msg: string, data?: Record) => void } -const req = createRequire(import.meta.url) - export namespace KilocodeDefaultPlugins { export function apply( cfg: T, @@ -19,7 +16,7 @@ export namespace KilocodeDefaultPlugins { let plugins = cfg.plugin ?? [] if (!opts.disabled) { - plugins = ensureIndexingPlugin(plugins, resolveIndexingPlugin(req, opts.log)) + plugins = ensureIndexingPlugin(plugins, INDEXING_PLUGIN) plugins = ensureAtomicChatPlugin(plugins) } diff --git a/packages/opencode/src/kilocode/indexing-feature.ts b/packages/opencode/src/kilocode/indexing-feature.ts index 3cb2e6042c..2dc74e2ba2 100644 --- a/packages/opencode/src/kilocode/indexing-feature.ts +++ b/packages/opencode/src/kilocode/indexing-feature.ts @@ -1,4 +1,3 @@ -import { pathToFileURL } from "url" import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" export const INDEXING_PLUGIN = "@kilocode/kilo-indexing" @@ -11,29 +10,10 @@ type ConfigLike = { plugin?: readonly PluginSpec[] | null } -type Req = { - resolve: (id: string) => string -} - -type LogLike = { - debug: (msg: string, data?: Record) => void -} - export function indexingEnabled(config?: ConfigLike | null): boolean { return hasIndexingPlugin(config?.plugin ?? []) } -export function resolveIndexingPlugin(req: Req, log?: LogLike): string { - try { - const file = req.resolve(INDEXING_PLUGIN) - return pathToFileURL(file).href - } catch (err) { - const error = err instanceof Error ? err.message : String(err) - log?.debug("failed to resolve indexing plugin package, using package marker", { error }) - return INDEXING_PLUGIN - } -} - export function ensureIndexingPlugin(items: readonly PluginSpec[], plugin?: string): PluginSpec[] { const plugins = [...items] if (!plugin) return plugins diff --git a/packages/opencode/src/kilocode/storage/json-migration.ts b/packages/opencode/src/kilocode/storage/json-migration.ts index 431c13caf5..cbc5aa3986 100644 --- a/packages/opencode/src/kilocode/storage/json-migration.ts +++ b/packages/opencode/src/kilocode/storage/json-migration.ts @@ -73,7 +73,10 @@ export async function bootstrap() { const marker = Database.path() if (marker === ":memory:") return const pending = marker + ".json-migration" - if ((await Filesystem.exists(marker)) && !(await Filesystem.exists(pending))) return + const [database, retry] = await Promise.all([Filesystem.exists(marker), Filesystem.exists(pending)]) + if (database && !retry) return + const storage = path.join(Global.Path.data, "storage") + if (!database && !retry && !(await Filesystem.exists(storage))) return await Filesystem.write(pending, "1") const tty = process.stderr.isTTY diff --git a/packages/opencode/test/kilocode/cli-shutdown.test.ts b/packages/opencode/test/kilocode/cli-shutdown.test.ts index 451124d154..60c61cf51c 100644 --- a/packages/opencode/test/kilocode/cli-shutdown.test.ts +++ b/packages/opencode/test/kilocode/cli-shutdown.test.ts @@ -4,7 +4,6 @@ import { KiloShutdown } from "../../src/kilocode/cli/shutdown" const calls: string[] = [] const timeouts: Array = [] let err: unknown -let drainErr: unknown let drainCalls = 0 let exit: string | number | null | undefined @@ -92,16 +91,6 @@ mock.module("@/kilocode/session-export", () => ({ }, })) -mock.module("@/kilo-sessions/kilo-sessions", () => ({ - KiloSessions: { - async drainIngestForShutdown() { - drainCalls += 1 - calls.push("drain") - if (drainErr) throw drainErr - }, - }, -})) - mock.module("@/kilocode/help-command", () => ({ createHelpCommand: () => ({ command: "help", handler() {} }), })) @@ -134,7 +123,6 @@ function registerDrain() { KiloShutdown.register(async () => { drainCalls += 1 calls.push("drain") - if (drainErr) throw drainErr }) } @@ -155,7 +143,6 @@ describe("KiloCli.shutdown", () => { calls.length = 0 timeouts.length = 0 err = undefined - drainErr = undefined drainCalls = 0 exit = process.exitCode process.exitCode = undefined @@ -165,19 +152,15 @@ describe("KiloCli.shutdown", () => { process.exitCode = exit }) - // Must stay first: setup registers the drain task once at import; KiloShutdown.run() clears it. - // Only this test pins that one-time module-scope registration (and the drain-before-dispose - // ordering it enables). Later tests call installDrain() so they do not rely on order. - test("rejects drain without blocking dispose", async () => { - drainErr = new Error("ingest drain failed") + test("does not load unused ingest shutdown work", async () => { process.exitCode = 0 const { KiloCli } = await import("../../src/kilocode/cli/setup") await expect(KiloCli.shutdown()).resolves.toBeUndefined() - expect(drainCalls).toBe(1) + expect(drainCalls).toBe(0) expect(timeouts).toEqual([2000]) - expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"]) + expect(calls).toEqual(["track:0", "session", "telemetry", "dispose"]) expect(process.exitCode).toBe(0) }) diff --git a/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts new file mode 100644 index 0000000000..4ac32559b5 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, test } from "bun:test" +import { KiloCli } from "../../../src/kilocode/cli/setup" + +describe("CLI bootstrap runtime selection", () => { + test("uses the narrow runtime for worker-backed TUI launches", () => { + expect(KiloCli.workerTui({ _: [] })).toBe(true) + expect(KiloCli.workerTui({ _: ["./project"] })).toBe(true) + }) + + test("keeps full bootstrap for explicit, mini, and worktree commands", () => { + expect(KiloCli.workerTui({ _: [], mini: true })).toBe(false) + expect(KiloCli.workerTui({ _: [], worktree: "feature" })).toBe(false) + }) +}) diff --git a/packages/opencode/test/kilocode/cli/lazy-commands.test.ts b/packages/opencode/test/kilocode/cli/lazy-commands.test.ts new file mode 100644 index 0000000000..42232f2c83 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/lazy-commands.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import yargs from "yargs" +import { hasLazyCommandSelection, lazy } from "../../../src/kilocode/cli/lazy-commands" + +describe("lazy CLI command", () => { + test("loads once after command selection", async () => { + const calls: string[] = [] + const command = lazy({ + command: "sample", + describe: "sample command", + async load() { + calls.push("load") + return { + command: "sample", + describe: "sample command", + builder: (args) => args.option("value", { type: "string", demandOption: true }), + handler: (args) => { + calls.push(String(args.value)) + }, + } + }, + }) + const cli = yargs([]).exitProcess(false).command(command) + + expect(calls).toEqual([]) + await cli.parseAsync(["sample", "--value", "ready"]) + expect(calls).toEqual(["load", "ready"]) + expect(hasLazyCommandSelection()).toBe(true) + }) + + test("preserves builder validation", async () => { + const command = lazy({ + command: "sample", + describe: "sample command", + async load() { + return { + command: "sample", + describe: "sample command", + builder: (args) => args.option("value", { type: "string", demandOption: true }), + handler() {}, + } + }, + }) + + await expect(yargs([]).exitProcess(false).command(command).parseAsync(["sample"])).rejects.toThrow( + "Missing required argument: value", + ) + }) +}) diff --git a/packages/opencode/test/kilocode/cli/lazy-completion.test.ts b/packages/opencode/test/kilocode/cli/lazy-completion.test.ts new file mode 100644 index 0000000000..095c2e837b --- /dev/null +++ b/packages/opencode/test/kilocode/cli/lazy-completion.test.ts @@ -0,0 +1,14 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { cliIt } from "../../lib/cli-process" + +cliIt.live( + "nested lazy command completion includes subcommands", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--get-yargs-completions", "auth", ""]) + opencode.expectExit(result, 0, "auth completion") + expect(result.stdout.split("\n")).toEqual(expect.arrayContaining(["list", "login", "logout"])) + }), + 60_000, +) diff --git a/packages/opencode/test/kilocode/indexing-feature.test.ts b/packages/opencode/test/kilocode/indexing-feature.test.ts index 1cfde17a0b..c997547b3b 100644 --- a/packages/opencode/test/kilocode/indexing-feature.test.ts +++ b/packages/opencode/test/kilocode/indexing-feature.test.ts @@ -3,7 +3,6 @@ import { ensureIndexingPlugin, indexingEnabled, INDEXING_PLUGIN, - resolveIndexingPlugin, } from "../../src/kilocode/indexing-feature" describe("indexing plugin helpers", () => { @@ -28,13 +27,4 @@ describe("indexing plugin helpers", () => { const list = ensureIndexingPlugin(["global-plugin"], undefined) expect(list).toEqual(["global-plugin"]) }) - - test("falls back to package marker when resolver fails", () => { - const plugin = resolveIndexingPlugin({ - resolve() { - throw new Error("missing") - }, - }) - expect(plugin).toBe(INDEXING_PLUGIN) - }) }) diff --git a/packages/opencode/test/kilocode/sessions/ingest-shutdown-lifecycle.test.ts b/packages/opencode/test/kilocode/sessions/ingest-shutdown-lifecycle.test.ts new file mode 100644 index 0000000000..818ab62912 --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/ingest-shutdown-lifecycle.test.ts @@ -0,0 +1,59 @@ +import { expect, spyOn, test } from "bun:test" +import { clearInFlightCache } from "../../../src/kilo-sessions/inflight-cache" +import { KiloShutdown } from "../../../src/kilocode/cli/shutdown" + +test("KiloSessions drains queued ingest before instance disposal", async () => { + const token = process.env.KILO_API_KEY + const base = process.env.KILO_SESSION_INGEST_URL + const calls: string[] = [] + let body: unknown + process.env.KILO_API_KEY = "shutdown-token" + process.env.KILO_SESSION_INGEST_URL = "https://ingest.test" + clearInFlightCache("kilo-sessions:token") + clearInFlightCache("kilo-sessions:client") + clearInFlightCache("kilo-sessions:token-valid:shutdown-token") + await KiloShutdown.run() + + const request = spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + return Response.json({ id: "remote-shutdown", ingestPath: "/api/ingest/shutdown" }) + } + if (url.endsWith("/api/ingest/shutdown?v=2")) { + body = init?.body ? JSON.parse(String(init.body)) : undefined + calls.push("ingest") + return new Response("{}", { status: 200 }) + } + throw new Error(`Unexpected request: ${url}`) + }, + { preconnect: globalThis.fetch.preconnect }, + ), + ) + + try { + const url = new URL("../../../src/kilo-sessions/kilo-sessions.ts", import.meta.url) + url.searchParams.set("test", crypto.randomUUID()) + const { KiloSessions } = await import(url.href) + await KiloSessions.bootstrap("session-shutdown") + expect(await KiloSessions._queueIngestForTest("session-shutdown")).toBe(true) + + await KiloShutdown.run() + calls.push("dispose") + + expect(calls).toEqual(["ingest", "dispose"]) + expect(body).toEqual({ data: [{ type: "session_status", data: { status: "idle" } }] }) + } finally { + request.mockRestore() + if (token === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = token + if (base === undefined) delete process.env.KILO_SESSION_INGEST_URL + else process.env.KILO_SESSION_INGEST_URL = base + clearInFlightCache("kilo-sessions:token") + clearInFlightCache("kilo-sessions:client") + clearInFlightCache("kilo-sessions:token-valid:shutdown-token") + await KiloShutdown.run() + } +}, 30_000) diff --git a/packages/opencode/test/kilocode/storage/json-migration.test.ts b/packages/opencode/test/kilocode/storage/json-migration.test.ts index 6b118a9a4c..e7e186623a 100644 --- a/packages/opencode/test/kilocode/storage/json-migration.test.ts +++ b/packages/opencode/test/kilocode/storage/json-migration.test.ts @@ -97,6 +97,22 @@ async function createTestDb() { return [sqlite, db, filename] as const } +test("bootstrap leaves fresh installs for the normal database initializer", async () => { + const marker = path.join(Global.Path.data, "json-migration-fresh.db") + const storage = path.join(Global.Path.data, "storage") + const previous = Flag.KILO_DB + Flag.KILO_DB = marker + await fs.rm(storage, { recursive: true, force: true }) + try { + await JsonMigration.bootstrap() + expect(await Bun.file(marker).exists()).toBe(false) + expect(await Bun.file(marker + ".json-migration").exists()).toBe(false) + } finally { + Flag.KILO_DB = previous + await Promise.all([marker, `${marker}-wal`, `${marker}-shm`].map(cleanup)) + } +}) + describe("JSON to SQLite migration", () => { let storageDir: string let sqlite: Database