Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cli-startup-lazy-loading.md
Original file line number Diff line number Diff line change
@@ -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
89 changes: 89 additions & 0 deletions packages/kilo-telemetry/src/__tests__/identity.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
21 changes: 21 additions & 0 deletions packages/kilo-telemetry/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.()
}
}
69 changes: 69 additions & 0 deletions packages/kilo-telemetry/src/identity.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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<Cache | null> {
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<void> {
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<void> {
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<void> {
organizationId = accountId || null

Expand All @@ -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) {
Comment thread
marius-kilocode marked this conversation as resolved.
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() {
Expand Down
6 changes: 6 additions & 0 deletions packages/kilo-telemetry/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 6 additions & 2 deletions packages/opencode/src/cli/cmd/attach.ts
Original file line number Diff line number Diff line change
@@ -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 <url>",
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand Down
9 changes: 6 additions & 3 deletions packages/opencode/src/cli/cmd/config.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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) {
Expand Down
7 changes: 5 additions & 2 deletions packages/opencode/src/cli/cmd/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 5 additions & 2 deletions packages/opencode/src/cli/cmd/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Hooks["auth"]>

Expand Down Expand Up @@ -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")
}),
})
13 changes: 8 additions & 5 deletions packages/opencode/src/cli/cmd/remote.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
21 changes: 13 additions & 8 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<KiloClient["session"]["prompt"]>[0]["model"]

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading