diff --git a/.changeset/cli-startup-lazy-loading.md b/.changeset/cli-startup-lazy-loading.md new file mode 100644 index 00000000000..38571e58380 --- /dev/null +++ b/.changeset/cli-startup-lazy-loading.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"@kilocode/kilo-telemetry": patch +--- + +Reduce CLI startup time by deferring Kilo-specific module loading until commands actually run, caching the telemetry profile lookup across invocations, and uploading telemetry in the background so process exit is not delayed by a network round trip diff --git a/packages/kilo-telemetry/src/__tests__/identity.test.ts b/packages/kilo-telemetry/src/__tests__/identity.test.ts new file mode 100644 index 00000000000..3754c047f07 --- /dev/null +++ b/packages/kilo-telemetry/src/__tests__/identity.test.ts @@ -0,0 +1,89 @@ +import { mkdtempSync, readFileSync, existsSync, statSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test" +import { createHash } from "node:crypto" + +let profileCalls = 0 +mock.module("@kilocode/kilo-gateway", () => ({ + fetchProfile: async (token: string) => { + profileCalls++ + if (token === "bad-token") return null + return { email: `user-${token}@example.com` } + }, +})) + +const { Identity } = await import("../identity.js") + +function digest(token: string) { + return createHash("sha256").update(token).digest("hex") +} + +let dir: string + +beforeEach(() => { + profileCalls = 0 + dir = mkdtempSync(path.join(tmpdir(), "kilo-telemetry-identity-")) + Identity.reset() + Identity.setDataPath(dir) +}) + +afterEach(() => { + Identity.setDataPath("") +}) + +describe("Identity.updateFromKiloAuth profile cache", () => { + test("fetches profile and writes cache keyed by token hash", async () => { + await Identity.updateFromKiloAuth("token-a") + expect(Identity.getUserId()).toBe("user-token-a@example.com") + expect(profileCalls).toBe(1) + + const file = path.join(dir, "telemetry-profile.json") + expect(existsSync(file)).toBe(true) + const cache = JSON.parse(readFileSync(file, "utf8")) + expect(cache.token).toBe(digest("token-a")) + expect(cache.email).toBe("user-token-a@example.com") + expect(cache.token).not.toBe("token-a") + // The cache stores an email and a token verifier, so it must be owner-only. + // POSIX only: Windows reports default mode bits and enforces access via ACLs. + if (process.platform !== "win32") expect(statSync(file).mode & 0o777).toBe(0o600) + }) + + test("uses cached email without a network request on later invocations", async () => { + await Identity.updateFromKiloAuth("token-a") + expect(profileCalls).toBe(1) + + // Simulate a fresh process: identity state resets, cache file persists. + Identity.reset() + await Identity.updateFromKiloAuth("token-a") + expect(Identity.getUserId()).toBe("user-token-a@example.com") + expect(profileCalls).toBe(1) + }) + + test("refetches when the token changes", async () => { + await Identity.updateFromKiloAuth("token-a") + Identity.reset() + await Identity.updateFromKiloAuth("token-b") + expect(Identity.getUserId()).toBe("user-token-b@example.com") + expect(profileCalls).toBe(2) + }) + + test("clears identity when token is null", async () => { + await Identity.updateFromKiloAuth("token-a") + Identity.reset() + await Identity.updateFromKiloAuth(null) + expect(Identity.getUserId()).toBeNull() + expect(profileCalls).toBe(1) + }) + + test("ignores a cache file for a different token", async () => { + await Identity.updateFromKiloAuth("token-a") + const file = path.join(dir, "telemetry-profile.json") + const cache = JSON.parse(readFileSync(file, "utf8")) + expect(cache.token).toBe(digest("token-a")) + + Identity.reset() + await Identity.updateFromKiloAuth("token-b") + expect(Identity.getUserId()).toBe("user-token-b@example.com") + }) +}) diff --git a/packages/kilo-telemetry/src/client.ts b/packages/kilo-telemetry/src/client.ts index 24c902b5435..f5baa1ec54e 100644 --- a/packages/kilo-telemetry/src/client.ts +++ b/packages/kilo-telemetry/src/client.ts @@ -82,4 +82,25 @@ export namespace Client { } } } + + // Flush queued events in the background without blocking the caller. The + // flush is delayed slightly so commands that exit immediately pay only the + // single shutdown() flush instead of an in-flight flush plus a follow-up + // flush for CLI_EXIT. For commands that outlive the delay, the upload + // overlaps with execution, so by the time shutdown() runs the queue is + // usually empty (or the connection is still warm) and process exit is not + // delayed by a network round trip. The unref'd timer never keeps a process + // alive on its own. The authoritative, error-handled flush still happens in + // shutdown(); failures here are retried there, so they are only surfaced + // when debug logging is on. + export function flushInBackground(delayMs = 300): void { + if (!enabled || !client) return + const timer = setTimeout(() => { + if (!client) return + client.flush().catch((err) => { + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry background flush failed", err) + }) + }, delayMs) + timer.unref?.() + } } diff --git a/packages/kilo-telemetry/src/identity.ts b/packages/kilo-telemetry/src/identity.ts index c75fac4d29d..974a32120df 100644 --- a/packages/kilo-telemetry/src/identity.ts +++ b/packages/kilo-telemetry/src/identity.ts @@ -1,4 +1,6 @@ import * as path from "path" +import { createHash } from "crypto" +import { writeFile, chmod, rename, rm } from "fs/promises" import { fetchProfile } from "@kilocode/kilo-gateway" export namespace Identity { @@ -7,6 +9,21 @@ export namespace Identity { let organizationId: string | null = null let dataPath = "" + // Cache the email resolved from the auth token so CLI startup does not block on + // a profile request for every invocation. Keyed by token hash; refreshed when + // the token changes. Stale entries (older than a week) are still used for the + // current run and refreshed on a best-effort basis for a later run: the + // background refresh is not awaited, so short-lived invocations may exit before + // it completes and simply retry next time. + const CACHE_FILE = "telemetry-profile.json" + const CACHE_TTL = 7 * 24 * 60 * 60 * 1000 + + interface Cache { + token: string + email: string + fetchedAt: number + } + export function setDataPath(p: string) { dataPath = p } @@ -51,6 +68,45 @@ export namespace Identity { organizationId = orgId } + function digest(token: string): string { + return createHash("sha256").update(token).digest("hex") + } + + async function read(): Promise { + if (!dataPath) return null + const file = Bun.file(path.join(dataPath, CACHE_FILE)) + if (!(await file.exists())) return null + const parsed = await file.json().catch(() => null) + if (!parsed || typeof parsed.token !== "string" || typeof parsed.email !== "string") return null + if (typeof parsed.fetchedAt !== "number") return null + return parsed as Cache + } + + async function write(cache: Cache): Promise { + if (!dataPath) return + const filepath = path.join(dataPath, CACHE_FILE) + // The cache stores the user's email and a token verifier, so keep it + // readable only by the owner, including when replacing an existing file. + // Write to a temp file and rename so concurrent invocations or a mid-write + // kill cannot leave a truncated cache behind (POSIX rename is atomic). + const tmp = `${filepath}.${process.pid}.tmp` + try { + await writeFile(tmp, JSON.stringify(cache), { mode: 0o600 }) + await chmod(tmp, 0o600) + await rename(tmp, filepath) + } catch (err) { + await rm(tmp, { force: true }).catch((rmErr) => { + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache temp cleanup failed", rmErr) + }) + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile cache write failed", err) + } + } + + async function refresh(token: string, tokenHash: string): Promise { + const profile = await fetchProfile(token).catch(() => null) + if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() }) + } + export async function updateFromKiloAuth(token: string | null, accountId?: string): Promise { organizationId = accountId || null @@ -59,8 +115,21 @@ export namespace Identity { return } + const tokenHash = digest(token) + const cached = await read() + if (cached && cached.token === tokenHash) { + userId = cached.email + if (Date.now() - cached.fetchedAt > CACHE_TTL) { + refresh(token, tokenHash).catch((err) => { + if (process.env.KILO_PRINT_LOGS) console.warn("telemetry profile refresh failed", err) + }) + } + return + } + const profile = await fetchProfile(token).catch(() => null) userId = profile?.email || null + if (profile?.email) await write({ token: tokenHash, email: profile.email, fetchedAt: Date.now() }) } export function reset() { diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 4d16051c2d8..496bf1ad4c9 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -135,6 +135,12 @@ export namespace Telemetry { track(TelemetryEvent.CLI_START) } + // Upload queued events without blocking. Call after bootstrap so the flush + // overlaps with command execution and shutdown() stays fast (#10242). + export function flushInBackground() { + Client.flushInBackground() + } + export function trackCliExit(exitCode?: number) { track(TelemetryEvent.CLI_EXIT, { duration: Date.now() - startTime, diff --git a/packages/opencode/src/cli/cmd/attach.ts b/packages/opencode/src/cli/cmd/attach.ts index 2ad53976a89..3c1810be7ce 100644 --- a/packages/opencode/src/cli/cmd/attach.ts +++ b/packages/opencode/src/cli/cmd/attach.ts @@ -1,10 +1,12 @@ import { cmd } from "./cmd" import { UI } from "@/cli/ui" -import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change -import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change import { errorMessage } from "@opencode-ai/tui/util/error" import { validateSession } from "../tui/validate-session" import { ServerAuth } from "@/server/auth" +// kilocode_change start - Kilo implementations (sdk client, cloud-session) are +// dynamically imported inside the handler so other CLI commands don't pay their +// module cost at startup. +// kilocode_change end export const AttachCommand = cmd({ command: "attach ", @@ -57,6 +59,7 @@ export const AttachCommand = cmd({ } // kilocode_change start + const { importCloudSession, validateCloudFork } = await import("@/kilocode/cloud-session") const cloudForkError = validateCloudFork(args) if (cloudForkError) { UI.error(cloudForkError) @@ -79,6 +82,7 @@ export const AttachCommand = cmd({ // kilocode_change start - import cloud session before TUI renders if (args.cloudFork && args.session) { UI.println("Importing session from cloud...") + const { createKiloClient } = await import("@kilocode/sdk/v2") const sdk = createKiloClient({ baseUrl: args.url, directory, diff --git a/packages/opencode/src/cli/cmd/config.ts b/packages/opencode/src/cli/cmd/config.ts index 5ff58ff2efe..3cade4a388c 100644 --- a/packages/opencode/src/cli/cmd/config.ts +++ b/packages/opencode/src/cli/cmd/config.ts @@ -1,11 +1,11 @@ // kilocode_change - new file import { EOL } from "os" -import { Config } from "../../config/config" -import { AppRuntime } from "../../effect/app-runtime" -import { bootstrap } from "../bootstrap" import { cmd } from "./cmd" import { UI } from "../ui" +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies are imported inside the handler (same +// deferral pattern as upstream opencode#30453). export const ConfigCommand = cmd({ command: "config", describe: "configuration tools", @@ -15,6 +15,9 @@ export const ConfigCommand = cmd({ command: "check", describe: "check configuration for warnings and errors", async handler() { + const { bootstrap } = await import("../bootstrap") + const { AppRuntime } = await import("../../effect/app-runtime") + const { Config } = await import("../../config/config") await bootstrap(process.cwd(), async () => { const list = await AppRuntime.runPromise(Config.Service.use((svc) => svc.warnings())) if (list.length === 0) { diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index fb3fb492f71..02d1fea5878 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -18,7 +18,7 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "path" import { Global } from "@opencode-ai/core/global" import { modify, applyEdits } from "jsonc-parser" -import { KilocodeMcpConfig } from "@/kilocode/cli/cmd/mcp" // kilocode_change +// kilocode_change - KilocodeMcpConfig is dynamically imported in addMcpToConfig to keep startup fast import { Filesystem } from "@/util/filesystem" import { EventV2Bridge } from "@/event-v2-bridge" import { EventV2 } from "@opencode-ai/core/event" @@ -447,7 +447,10 @@ async function addMcpToConfig(name: string, mcpConfig: ConfigMCPV1.Info, configP const edits = modify(text, ["mcp", name], mcpConfig, { formattingOptions: { tabSize: 2, insertSpaces: true }, }) - const result = KilocodeMcpConfig.format(configPath, applyEdits(text, edits)) // kilocode_change + // kilocode_change start - lazy import keeps the CLI startup graph light + const { KilocodeMcpConfig } = await import("@/kilocode/cli/cmd/mcp") + const result = KilocodeMcpConfig.format(configPath, applyEdits(text, edits)) + // kilocode_change end await Filesystem.write(configPath, result) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 6575ac9be7a..e8b2c4bae44 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -17,7 +17,7 @@ import { Process } from "@/util/process" import { errorMessage } from "@/util/error" import { text } from "node:stream/consumers" import { Effect, Option } from "effect" -import { remove as removeAuth } from "@/kilocode/auth/remove" // kilocode_change +// kilocode_change - @/kilocode/auth/remove is dynamically imported in the logout handler to keep startup fast type PluginAuth = NonNullable @@ -538,7 +538,10 @@ export const ProvidersLogoutCommand = effectCmd({ }), ) if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) - yield* removeAuth(provider) // kilocode_change + // kilocode_change start - lazy import keeps the CLI startup graph light + const { remove: removeAuth } = yield* Effect.promise(() => import("@/kilocode/auth/remove")) + yield* removeAuth(provider) + // kilocode_change end yield* Prompt.outro("Logout successful") }), }) diff --git a/packages/opencode/src/cli/cmd/remote.ts b/packages/opencode/src/cli/cmd/remote.ts index 1cc7ed520b8..5198a00e4fa 100644 --- a/packages/opencode/src/cli/cmd/remote.ts +++ b/packages/opencode/src/cli/cmd/remote.ts @@ -1,20 +1,23 @@ // kilocode_change - new file import { cmd } from "./cmd" -import { bootstrap } from "../bootstrap" -import { KiloSessions } from "@/kilo-sessions/kilo-sessions" import { buildInstanceAdvertisement } from "@/kilo-sessions/instance-advertisement" -import { context } from "@/project/instance-context" -import { InstanceRuntime } from "@/project/instance-runtime" -import { Instance } from "@/kilocode/instance" // Re-export so existing unit tests that import from this module keep working. export { buildInstanceAdvertisement } +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies are imported inside the handler (same +// deferral pattern as upstream opencode#30453). export const RemoteCommand = cmd({ command: "remote", describe: "enable remote connection for real-time session relay", builder: (yargs) => yargs, handler: async () => { + const { bootstrap } = await import("../bootstrap") + const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions") + const { context } = await import("@/project/instance-context") + const { InstanceRuntime } = await import("@/project/instance-runtime") + const { Instance } = await import("@/kilocode/instance") await bootstrap(process.cwd(), async () => { // kilocode_change - K1 W1: advertise this instance on the relay // heartbeat so the cloud side can show it as a spawn-capable instance. diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index bdf726cf0ab..28061f49ea4 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -20,18 +20,15 @@ import { pathToFileURL } from "url" import { Effect } from "effect" import { UI } from "../ui" import { effectCmd } from "../effect-cmd" -import { buildRunMessage } from "@/kilocode/cli/cmd/run-message" // kilocode_change import { EOL } from "os" import { Filesystem } from "@/util/filesystem" -import { createKiloClient, type KiloClient, type Session, type ToolPart } from "@kilocode/sdk/v2" -import { Agent } from "@/agent/agent" -import { RuntimeFlags } from "@/effect/runtime-flags" +import type { KiloClient, Session, ToolPart } from "@kilocode/sdk/v2" import { FormatError, FormatUnknownError } from "../error" import { INTERACTIVE_INPUT_ERROR, resolveInteractiveStdin } from "./run/runtime.stdin" -import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change -import { KiloRunAuto } from "@/kilocode/cli/run-auto" // kilocode_change -import { KiloHeadless } from "@/kilocode/permission/headless" // kilocode_change -import { KiloRun, KiloRunDaemon } from "@/kilocode/cli/cmd/run" // kilocode_change +// kilocode_change start - Kilo implementations (createKiloClient, run-message, +// cloud-session, run-auto, headless, KiloRun) are dynamically imported inside the +// handler so other CLI commands don't pay their module cost at startup. +// kilocode_change end type ModelInput = Parameters[0]["model"] @@ -266,6 +263,14 @@ export const RunCommand = effectCmd({ const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags")) const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref")) const { ServerAuth } = yield* Effect.promise(() => import("@/server/auth")) + // kilocode_change start - lazy Kilo implementations (see top-of-file note) + const { createKiloClient } = yield* Effect.promise(() => import("@kilocode/sdk/v2")) + const { buildRunMessage } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run-message")) + const { importCloudSession, validateCloudFork } = yield* Effect.promise(() => import("@/kilocode/cloud-session")) + const { KiloRunAuto } = yield* Effect.promise(() => import("@/kilocode/cli/run-auto")) + const { KiloHeadless } = yield* Effect.promise(() => import("@/kilocode/permission/headless")) + const { KiloRun, KiloRunDaemon } = yield* Effect.promise(() => import("@/kilocode/cli/cmd/run")) + // kilocode_change end const agentSvc = yield* Agent.Service const flags = yield* RuntimeFlags.Service const localInstance = yield* InstanceRef diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index cbb65e4e979..f6200bc2bbd 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -2,9 +2,6 @@ import { Effect } from "effect" import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change -import { startParentWatchdog } from "../../kilocode/parent-watchdog" // kilocode_change -import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change export const ServeCommand = effectCmd({ command: "serve", @@ -31,6 +28,9 @@ export const ServeCommand = effectCmd({ // kilocode_change start - graceful signal shutdown // yield* Effect.never + const { InstanceRuntime } = yield* Effect.promise(() => import("../../project/instance-runtime")) + const { startParentWatchdog } = yield* Effect.promise(() => import("../../kilocode/parent-watchdog")) + const { KiloSessions } = yield* Effect.promise(() => import("@/kilo-sessions/kilo-sessions")) yield* Effect.promise( () => new Promise((resolve) => { diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 0bd54ed5840..a02a468b470 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -11,11 +11,8 @@ import { withNetworkOptions, resolveNetworkOptionsNoConfig } from "@/cli/network import { Filesystem } from "@/util/filesystem" import type { GlobalEvent } from "@kilocode/sdk/v2" import type { EventSource } from "@opencode-ai/tui/context/sdk" -import { importCloudSession, localSessionID, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change -import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change import { writeHeapSnapshot } from "v8" -import { KiloTuiThreadDaemon, type StartInput } from "@/kilocode/cli/cmd/tui/thread" // kilocode_change -import { preload } from "@/kilocode/cli/cmd/tui" // kilocode_change +import type { StartInput } from "@/kilocode/cli/cmd/tui/thread" // kilocode_change - runtime imports deferred into handlers import { win32InstallCtrlCGuard } from "@opencode-ai/tui/terminal-win32" import { validateSession } from "../tui/validate-session" // kilocode_change start - correlate the TUI worker with its parent process @@ -26,7 +23,7 @@ import { sanitizedProcessEnv, } from "@opencode-ai/core/util/opencode-process" // kilocode_change end -import { createParentRemoteExitBridge, type RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change +import type { RemoteExitBridgeClient } from "@/kilocode/cli/cmd/tui/remote-exit-bridge" // kilocode_change - runtime import deferred import type { Exit } from "@opencode-ai/tui/context/exit" // kilocode_change declare global { @@ -46,6 +43,7 @@ export async function runEmbeddedRemoteExitBridge(input: { done: Promise timeoutMs?: number }) { + const { createParentRemoteExitBridge } = await import("@/kilocode/cli/cmd/tui/remote-exit-bridge") const timeoutMs = input.timeoutMs ?? 5_000 const bridge = createParentRemoteExitBridge(input.client, input.exit) let ready = false @@ -179,6 +177,12 @@ export const TuiThreadCommand = cmd({ describe: "agent to use", }), handler: async (args) => { + // kilocode_change start - lazy Kilo implementations so other CLI commands + // don't pay their module cost at startup + 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") + // kilocode_change end const unguard = win32InstallCtrlCGuard() const shutdown = { pending: undefined as Promise | undefined, @@ -205,9 +209,8 @@ export const TuiThreadCommand = cmd({ const next = resolveThreadDirectory(args.project) const file = await target() // kilocode_change start - const preloads = preload( - typeof KILO_WORKER_PATH !== "undefined", - () => import.meta.resolve("@opentui/solid/preload"), + const preloads = preload(typeof KILO_WORKER_PATH !== "undefined", () => + import.meta.resolve("@opentui/solid/preload"), ) // kilocode_change end try { @@ -354,6 +357,7 @@ export const TuiThreadCommand = cmd({ // kilocode_change start - import cloud session before TUI renders if (args.cloudFork && args.session) { UI.println("Importing session from cloud...") + const { createKiloClient } = await import("@kilocode/sdk/v2") const sdk = createKiloClient({ baseUrl: transport.url, fetch: transport.fetch, diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 9b37ddf09c7..36df40c8c93 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -3,7 +3,6 @@ import { UI } from "../ui" import { effectCmd } from "../effect-cmd" import { withNetworkOptions, resolveNetworkOptions } from "../network" import { Flag } from "@opencode-ai/core/flag/flag" -import { InstanceRuntime } from "../../project/instance-runtime" // kilocode_change import open from "open" export const WebCommand = effectCmd({ @@ -33,17 +32,14 @@ export const WebCommand = effectCmd({ } if (opts.mdns) { - UI.println( - UI.Style.TEXT_INFO_BOLD + " mDNS: ", - UI.Style.TEXT_NORMAL, - `${opts.mdnsDomain}:${server.port}`, - ) + UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, `${opts.mdnsDomain}:${server.port}`) } open(urls.local).catch(() => {}) // kilocode_change end // kilocode_change start - graceful signal shutdown + const { InstanceRuntime } = yield* Effect.promise(() => import("../../project/instance-runtime")) yield* Effect.promise( () => new Promise((resolve) => { diff --git a/packages/opencode/src/kilocode/cli/cmd/cloud.ts b/packages/opencode/src/kilocode/cli/cmd/cloud.ts index 61b4e75bae1..a9b1ad957b8 100644 --- a/packages/opencode/src/kilocode/cli/cmd/cloud.ts +++ b/packages/opencode/src/kilocode/cli/cmd/cloud.ts @@ -2,7 +2,11 @@ import type { Argv } from "yargs" import { Effect } from "effect" import { cmd } from "@/cli/cmd/cmd" import { effectCmd } from "@/cli/effect-cmd" -import { CloudCommands } from "@/kilocode/cloud/commands" + +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so the cloud implementation is imported inside handlers (same deferral +// pattern as upstream opencode#30453). +const cloud = Effect.promise(() => import("@/kilocode/cloud/commands").then((m) => m.CloudCommands)) export const CloudStartCommand = effectCmd({ command: "start", @@ -44,6 +48,7 @@ export const CloudStartCommand = effectCmd({ describe: "connect to the WebSocket stream and print events as JSONL", }), handler: Effect.fn("Cli.cloud.start")(function* (args) { + const CloudCommands = yield* cloud yield* CloudCommands.start({ prompt: args.prompt, ...(args.repo === undefined ? {} : { repo: args.repo }), @@ -74,6 +79,7 @@ export const CloudSendCommand = effectCmd({ describe: "follow-up prompt for the Cloud Agent", }), handler: Effect.fn("Cli.cloud.send")(function* (args) { + const CloudCommands = yield* cloud yield* CloudCommands.send({ sessionID: args.sessionId, prompt: args.prompt }) }), }) @@ -95,6 +101,7 @@ export const CloudStatusCommand = effectCmd({ describe: "Cloud Agent message ID", }), handler: Effect.fn("Cli.cloud.status")(function* (args) { + const CloudCommands = yield* cloud yield* CloudCommands.status({ sessionID: args.sessionId, messageID: args.messageId }) }), }) @@ -116,6 +123,7 @@ export const CloudResultCommand = effectCmd({ describe: "Cloud Agent message ID", }), handler: Effect.fn("Cli.cloud.result")(function* (args) { + const CloudCommands = yield* cloud yield* CloudCommands.result({ sessionID: args.sessionId, messageID: args.messageId }) }), }) diff --git a/packages/opencode/src/kilocode/cli/cmd/console.ts b/packages/opencode/src/kilocode/cli/cmd/console.ts index ae0b9cf1c52..019ea290502 100644 --- a/packages/opencode/src/kilocode/cli/cmd/console.ts +++ b/packages/opencode/src/kilocode/cli/cmd/console.ts @@ -1,14 +1,14 @@ -import open from "open" import type { Argv } from "yargs" +import type { Daemon } from "@/kilocode/daemon/daemon" import { cmd } from "@/cli/cmd/cmd" -import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network" +import { explicitNetworkOptions, withNetworkOptions } from "@/cli/network" import { serverUrls } from "@/kilocode/cli/server-urls" -import { AppRuntime } from "@/effect/app-runtime" -import { Daemon } from "@/kilocode/daemon/daemon" -import { warnPort } from "@/kilocode/cli/port-warning" import { hasDisplay } from "@/kilocode/cli/cmd/tui/util/display" import { StopCommand } from "@/kilocode/cli/cmd/daemon" +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies are imported inside handlers (same +// deferral pattern as upstream opencode#30453). function withCredentials(base: string, state: Daemon.State) { const url = new URL("/console", base) url.username = state.username @@ -17,6 +17,7 @@ function withCredentials(base: string, state: Daemon.State) { } async function launch(url: string) { + const { default: open } = await import("open") const child = await open(url) await new Promise((resolve, reject) => { const timer = setTimeout(resolve, 500) @@ -46,9 +47,10 @@ const OpenCommand = cmd({ type: "boolean", }), handler: async (args) => { + const { Daemon } = await import("@/kilocode/daemon/daemon") + const { warnedNetworkOptions } = await import("@/kilocode/cli/port-warning") const run = async (signal?: AbortSignal) => { - const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) - warnPort(opts.port) + const opts = await warnedNetworkOptions(args) const daemon = await Daemon.ensure(opts, explicitNetworkOptions()) const state = daemon.result.state if (!state) throw new Error("Kilo daemon did not provide connection state") diff --git a/packages/opencode/src/kilocode/cli/cmd/daemon.ts b/packages/opencode/src/kilocode/cli/cmd/daemon.ts index 42f1165f508..e3a0deb4ece 100644 --- a/packages/opencode/src/kilocode/cli/cmd/daemon.ts +++ b/packages/opencode/src/kilocode/cli/cmd/daemon.ts @@ -1,10 +1,12 @@ import type { Argv } from "yargs" +import type { Daemon } from "@/kilocode/daemon/daemon" +import type { resolveNetworkOptions } from "@/cli/network" import { cmd } from "@/cli/cmd/cmd" -import { explicitNetworkOptions, withNetworkOptions, resolveNetworkOptions } from "@/cli/network" -import { AppRuntime } from "@/effect/app-runtime" -import { Daemon } from "@/kilocode/daemon/daemon" -import { warnPort } from "@/kilocode/cli/port-warning" +import { explicitNetworkOptions, withNetworkOptions } from "@/cli/network" +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies are imported inside handlers (same +// deferral pattern as upstream opencode#30453). function withJson(yargs: Argv) { return yargs.option("json", { describe: "print daemon details as JSON", @@ -75,6 +77,7 @@ async function hold(enabled: boolean, json: boolean, run: (signal?: AbortSignal) await run() return } + const { Daemon } = await import("@/kilocode/daemon/daemon") await Daemon.foreground(async (signal) => { const state = await run(signal) if (!signal.aborted && !json) console.log("Press Ctrl+C to stop the Kilo daemon.") @@ -82,6 +85,11 @@ async function hold(enabled: boolean, json: boolean, run: (signal?: AbortSignal) }) } +async function network(args: { [key: string]: unknown }) { + const { warnedNetworkOptions } = await import("@/kilocode/cli/port-warning") + return warnedNetworkOptions(args as Parameters[0]) +} + function start(command: string) { return cmd({ command, @@ -89,8 +97,8 @@ function start(command: string) { builder: (yargs) => withForeground(withJson(withNetworkOptions(yargs))), handler: async (args) => { await hold(Boolean(args.foreground), Boolean(args.json), async (signal) => { - const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) - warnPort(opts.port) + const opts = await network(args) + const { Daemon } = await import("@/kilocode/daemon/daemon") const daemon = await Daemon.ensure(opts, explicitNetworkOptions()) const result = daemon.result const state = result.state @@ -121,6 +129,7 @@ const StatusCommand = cmd({ describe: "show local kilo daemon status", builder: (yargs) => withJson(yargs), handler: async (args) => { + const { Daemon } = await import("@/kilocode/daemon/daemon") print(await Daemon.status(), Boolean(args.json)) }, }) @@ -130,6 +139,7 @@ export const StopCommand = cmd({ describe: "stop the local kilo daemon", builder: (yargs) => withJson(yargs), handler: async (args) => { + const { Daemon } = await import("@/kilocode/daemon/daemon") const result = await Daemon.stop() if (args.json) { print(result, true) @@ -145,8 +155,8 @@ const RestartCommand = cmd({ builder: (yargs) => withForeground(withJson(withNetworkOptions(yargs))), handler: async (args) => { await hold(Boolean(args.foreground), Boolean(args.json), async (signal) => { - const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) - warnPort(opts.port) + const opts = await network(args) + const { Daemon } = await import("@/kilocode/daemon/daemon") const result = await Daemon.restart(opts) const state = result.state if (!state) throw new Error("Kilo daemon did not provide process state") diff --git a/packages/opencode/src/kilocode/cli/cmd/profile.ts b/packages/opencode/src/kilocode/cli/cmd/profile.ts index 7119395c2f9..924d4fde0fa 100644 --- a/packages/opencode/src/kilocode/cli/cmd/profile.ts +++ b/packages/opencode/src/kilocode/cli/cmd/profile.ts @@ -1,11 +1,23 @@ import type { Argv } from "yargs" +import type { Info as AuthInfo } from "../../../auth" +import type { KilocodeBalance, KilocodeProfile } from "@kilocode/kilo-gateway" import { cmd } from "../../../cli/cmd/cmd" import { UI } from "../../../cli/ui" -import { Auth, type Info as AuthInfo } from "../../../auth" -import { makeRuntime } from "../../../effect/run-service" -import { fetchBalance, fetchProfile, type KilocodeBalance, type KilocodeProfile } from "@kilocode/kilo-gateway" -const runtime = makeRuntime(Auth.Service, Auth.defaultLayer) +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so the auth runtime and gateway fetches happen inside the handler (same +// deferral pattern as upstream opencode#30453). The runtime is created lazily on +// first use rather than at module load. +let runtime: Awaited> | undefined +async function load() { + const { Auth } = await import("../../../auth") + const { makeRuntime } = await import("../../../effect/run-service") + return makeRuntime(Auth.Service, Auth.defaultLayer) +} +async function stored(providerID: string) { + runtime ??= await load() + return runtime.runPromise((svc) => svc.get(providerID)) +} interface Info { name: string | null @@ -64,7 +76,7 @@ export const ProfileCommand = cmd({ }) export async function handle(args: Args) { - const get = args.getAuth ?? ((id: string) => runtime.runPromise((svc) => svc.get(id))) + const get = args.getAuth ?? stored const auth = await get("kilo") const error = args.error ?? UI.error const exit = args.exit ?? ((code: number) => (process.exitCode = code)) @@ -75,6 +87,7 @@ export async function handle(args: Args) { return } + const { fetchBalance, fetchProfile } = await import("@kilocode/kilo-gateway") const org = auth.accountId ?? null const result = await (async () => { try { diff --git a/packages/opencode/src/kilocode/cli/cmd/roll-call.ts b/packages/opencode/src/kilocode/cli/cmd/roll-call.ts index 2c932bfde81..37b18716530 100644 --- a/packages/opencode/src/kilocode/cli/cmd/roll-call.ts +++ b/packages/opencode/src/kilocode/cli/cmd/roll-call.ts @@ -1,14 +1,35 @@ import type { Argv } from "yargs" -import { provide } from "../../instance" -import { Provider } from "../../../provider/provider" +import type { Provider } from "../../../provider/provider" +import type { generateText } from "ai" import { ProviderTransform } from "../../../provider/transform" import { cmd } from "../../../cli/cmd/cmd" import { UI } from "../../../cli/ui" -import { AppRuntime } from "../../../effect/app-runtime" -import { RuntimeFlags } from "../../../effect/runtime-flags" -import { generateText } from "ai" import { randomUUID } from "crypto" +// Keep the top-level import graph light: this module is registered eagerly at CLI +// startup, so implementation dependencies (provider service, AppRuntime, the `ai` +// SDK) are imported lazily (same deferral pattern as upstream opencode#30453). +// Resolved once per process: roll-call's per-model path must not re-await import +// resolution for every model. +let cache: ReturnType | undefined +function loadDeps() { + return Promise.all([ + import("../../../effect/app-runtime"), + import("../../../provider/provider"), + import("../../../effect/runtime-flags"), + import("ai"), + ]).then(([runtime, provider, flags, ai]) => ({ + AppRuntime: runtime.AppRuntime, + Provider: provider.Provider, + RuntimeFlags: flags.RuntimeFlags, + generateText: ai.generateText, + })) +} +function deps() { + cache ??= loadDeps() + return cache +} + const HEADERS = ["Model", "Access", "Snippet", "Latency"] const PADDING = 9 @@ -127,11 +148,13 @@ interface Result { errorMessage: string | null } -function list() { +async function list() { + const { AppRuntime, Provider } = await deps() return AppRuntime.runPromise(Provider.Service.use((svc) => svc.list())) } -function lang(model: Provider.Model) { +async function lang(model: Provider.Model) { + const { AppRuntime, Provider } = await deps() return AppRuntime.runPromise(Provider.Service.use((svc) => svc.getLanguage(model))) } @@ -140,6 +163,7 @@ export function outputLimit(model: Provider.Model, outputTokenMax?: number) { } export async function handle(args: ArgumentsCamelCase) { + const { provide } = await import("../../instance") const load = args.list ?? list if (args.parallel < 1) { @@ -286,6 +310,7 @@ async function call( start: number, ): Promise> { try { + const { AppRuntime, RuntimeFlags, generateText } = await deps() const language = await lang(model) const sessionID = randomUUID() const options = ProviderTransform.options({ model, sessionID }) diff --git a/packages/opencode/src/kilocode/cli/port-warning.ts b/packages/opencode/src/kilocode/cli/port-warning.ts index afba3dd5632..ff441e3912d 100644 --- a/packages/opencode/src/kilocode/cli/port-warning.ts +++ b/packages/opencode/src/kilocode/cli/port-warning.ts @@ -1,4 +1,5 @@ import { Daemon } from "@/kilocode/daemon/daemon" +import type { resolveNetworkOptions } from "@/cli/network" export function warnPort(port: number) { if (port === 0) return @@ -9,3 +10,14 @@ export function warnPort(port: number) { ) } } + +// Shared resolve-and-warn used by the daemon and console commands so their +// network option handling cannot drift apart. Imported lazily by callers, so +// the AppRuntime chain is only loaded when one of those commands runs. +export async function warnedNetworkOptions(args: Parameters[0]) { + const { AppRuntime } = await import("@/effect/app-runtime") + const { resolveNetworkOptions } = await import("@/cli/network") + const opts = await AppRuntime.runPromise(resolveNetworkOptions(args)) + warnPort(opts.port) + return opts +} diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index 561e5c54062..e3c54c3065b 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -1,14 +1,6 @@ import type { Argv } from "yargs" import * as Log from "@opencode-ai/core/util/log" -import { Global } from "@opencode-ai/core/global" import { InstallationBuildKind, InstallationVersion } from "@opencode-ai/core/installation/version" -import { Telemetry } from "@kilocode/kilo-telemetry" -import { migrateLegacyKiloAuth, ENV_FEATURE, ENV_VERSION } from "@kilocode/kilo-gateway" -import { AppRuntime } from "@/effect/app-runtime" -import { Config } from "@/config/config" -import { Auth } from "@/auth" -import { InstanceRuntime } from "@/project/instance-runtime" -import { SessionExport } from "@/kilocode/session-export" import { KiloShutdown } from "@/kilocode/cli/shutdown" import { createHelpCommand } from "@/kilocode/help-command" import { KiloConsoleCommand } from "@/kilocode/cli/cmd/console" @@ -19,8 +11,6 @@ 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 { JsonMigration } from "@/kilocode/storage/json-migration" -import { KiloLog } from "@/kilocode/log" const log = Log.create({ service: "kilocode.cli" }) @@ -45,6 +35,13 @@ KiloShutdown.register(async () => { // 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. +// +// Startup cost note: this module is imported eagerly from src/index.ts, so its static +// import graph must stay light. Heavy dependencies (telemetry, gateway auth migration, +// AppRuntime, config, auth, session-export, JSON migration) are dynamically imported +// inside the function that needs them, following the deferral pattern upstream applied +// in opencode#30453. The registered command modules must follow the same rule: a light +// top level, with implementation imports inside their handlers. export namespace KiloCli { // 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. @@ -73,22 +70,35 @@ export namespace KiloCli { // Runs from the upstream `.middleware`, before any command handler. Env tagging is additive so // it never has to modify upstream's own env assignments. export async function bootstrap(): Promise { + const { KiloLog } = await import("@/kilocode/log") await KiloLog.init() - if (!process.env[ENV_FEATURE]) process.env[ENV_FEATURE] = process.argv.includes("serve") ? "unknown" : "cli" - if (!process.env[ENV_VERSION]) process.env[ENV_VERSION] = InstallationVersion + + const gateway = await import("@kilocode/kilo-gateway") + if (!process.env[gateway.ENV_FEATURE]) + process.env[gateway.ENV_FEATURE] = process.argv.includes("serve") ? "unknown" : "cli" + if (!process.env[gateway.ENV_VERSION]) process.env[gateway.ENV_VERSION] = InstallationVersion process.env.KILO = "1" // Must run before AppRuntime initializes the SQLite database, or the marker // exists before legacy JSON can be imported. + 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 { Global } = await import("@opencode-ai/core/global") + const { Telemetry } = await import("@kilocode/kilo-telemetry") await Telemetry.init({ dataPath: Global.Path.data, version: InstallationVersion, enabled: cfg.experimental?.openTelemetry !== false, }) + const { Auth } = await import("@/auth") + const { migrateLegacyKiloAuth } = gateway + // 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, @@ -103,12 +113,17 @@ export namespace KiloCli { } Telemetry.trackCliStart() + // Overlap the event upload with command execution so exit is not delayed by + // a network round trip (#10242). + Telemetry.flushInBackground() } // Runs from the `finally` block on every exit path. export async function shutdown(): Promise { + const { Telemetry } = await import("@kilocode/kilo-telemetry") const code = typeof process.exitCode === "number" ? process.exitCode : undefined Telemetry.trackCliExit(code) + const { SessionExport } = await import("@/kilocode/session-export") try { await SessionExport.shutdown() // Bound telemetry shutdown so an unreachable endpoint (offline, firewall, @@ -121,6 +136,7 @@ export namespace KiloCli { } } finally { await KiloShutdown.run() + const { InstanceRuntime } = await import("@/project/instance-runtime") await InstanceRuntime.disposeAllInstances() // safety net (no-op if already disposed) } }