diff --git a/src/core/config/loader.ts b/src/core/config/loader.ts
index 5cd953b33..38efab53b 100644
--- a/src/core/config/loader.ts
+++ b/src/core/config/loader.ts
@@ -8,6 +8,7 @@ import { getCwd } from '@utils/state'
import { safeParseJSON } from '@utils/text/json'
import { ConfigParseError } from '@utils/text/errors'
import { debug as debugLogger } from '@utils/log/debugLogger'
+import { logStartupProfileDuration } from '@utils/config/startupProfile'
import {
DEFAULT_GLOBAL_CONFIG,
DEFAULT_PROJECT_CONFIG,
@@ -91,7 +92,7 @@ function findMatchingProjectKey(
export function checkHasTrustDialogAccepted(): boolean {
let currentPath = getCwd()
- const config = getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG)
+ const config = getLoadedGlobalConfig()
while (true) {
const projectKey = findMatchingProjectKey(config.projects, currentPath)
@@ -117,6 +118,36 @@ const TEST_PROJECT_CONFIG_FOR_TESTING: ProjectConfig = {
...DEFAULT_PROJECT_CONFIG,
}
+let globalConfigCache: GlobalConfig | null = null
+let globalConfigCachePath: string | null = null
+
+function cacheGlobalConfig(file: string, config: GlobalConfig): GlobalConfig {
+ const migrated = migrateModelProfilesRemoveId(config)
+ globalConfigCachePath = file
+ globalConfigCache = cloneDeep(migrated)
+ return cloneDeep(migrated)
+}
+
+function getCachedGlobalConfig(file: string): GlobalConfig | null {
+ if (globalConfigCachePath !== file || !globalConfigCache) return null
+ return cloneDeep(globalConfigCache)
+}
+
+function getLoadedGlobalConfig(throwOnInvalid?: boolean): GlobalConfig {
+ const file = getGlobalConfigFilePath()
+ const cached = getCachedGlobalConfig(file)
+ if (cached) return cached
+ return cacheGlobalConfig(
+ file,
+ getConfig(file, DEFAULT_GLOBAL_CONFIG, throwOnInvalid),
+ )
+}
+
+export function clearConfigCacheForTesting(): void {
+ globalConfigCache = null
+ globalConfigCachePath = null
+}
+
export function saveGlobalConfig(config: GlobalConfig): void {
if (process.env.NODE_ENV === 'test') {
for (const key in config) {
@@ -125,23 +156,24 @@ export function saveGlobalConfig(config: GlobalConfig): void {
return
}
- saveConfig(
- getGlobalConfigFilePath(),
- {
- ...config,
- projects: getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG)
- .projects,
- },
- DEFAULT_GLOBAL_CONFIG,
- )
+ const file = getGlobalConfigFilePath()
+ const existingConfig =
+ getCachedGlobalConfig(file) ?? getConfig(file, DEFAULT_GLOBAL_CONFIG)
+ const nextConfig = {
+ ...config,
+ projects: existingConfig.projects,
+ }
+
+ if (saveConfig(file, nextConfig, DEFAULT_GLOBAL_CONFIG)) {
+ cacheGlobalConfig(file, nextConfig)
+ }
}
export function getGlobalConfig(): GlobalConfig {
if (process.env.NODE_ENV === 'test') {
return TEST_GLOBAL_CONFIG_FOR_TESTING
}
- const config = getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG)
- return migrateModelProfilesRemoveId(config)
+ return getLoadedGlobalConfig()
}
export function normalizeApiKeyForConfig(apiKey: string): string {
@@ -165,7 +197,8 @@ function saveConfig(
file: string,
config: A,
defaultConfig: A,
-): void {
+): boolean {
+ const startedAt = Date.now()
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(
([key, value]) =>
@@ -174,6 +207,8 @@ function saveConfig(
)
try {
writeFileSync(file, JSON.stringify(filteredConfig, null, 2), 'utf-8')
+ logStartupProfileDuration('config_write', Date.now() - startedAt, { file })
+ return true
} catch (error) {
const err = error as NodeJS.ErrnoException
if (
@@ -185,7 +220,7 @@ function saveConfig(
file,
reason: String(err.code),
})
- return
+ return false
}
throw error
}
@@ -195,7 +230,7 @@ let configReadingAllowed = false
export function enableConfigs(): void {
configReadingAllowed = true
- getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG, true)
+ getLoadedGlobalConfig(true)
}
function getConfig(
@@ -203,6 +238,7 @@ function getConfig(
defaultConfig: A,
throwOnInvalid?: boolean,
): A {
+ const startedAt = Date.now()
void configReadingAllowed
debugLogger.state('CONFIG_LOAD_START', {
@@ -217,7 +253,12 @@ function getConfig(
reason: 'file_not_exists',
defaultConfigKeys: Object.keys(defaultConfig as object).join(', '),
})
- return cloneDeep(defaultConfig)
+ const config = cloneDeep(defaultConfig)
+ logStartupProfileDuration('config_read', Date.now() - startedAt, {
+ file,
+ source: 'default',
+ })
+ return config
}
try {
@@ -246,6 +287,10 @@ function getConfig(
finalConfigKeys: Object.keys(finalConfig as object).join(', '),
})
+ logStartupProfileDuration('config_read', Date.now() - startedAt, {
+ file,
+ source: 'file',
+ })
return finalConfig
} catch (error) {
const errorMessage =
@@ -278,7 +323,12 @@ function getConfig(
action: 'using_default_config',
})
- return cloneDeep(defaultConfig)
+ const config = cloneDeep(defaultConfig)
+ logStartupProfileDuration('config_read', Date.now() - startedAt, {
+ file,
+ source: 'fallback',
+ })
+ return config
}
}
@@ -288,7 +338,7 @@ export function getCurrentProjectConfig(): ProjectConfig {
}
const absolutePath = resolve(getCwd())
- const config = getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG)
+ const config = getLoadedGlobalConfig()
if (!config.projects) {
return defaultConfigForProject(absolutePath)
@@ -321,22 +371,22 @@ export function saveCurrentProjectConfig(projectConfig: ProjectConfig): void {
}
return
}
- const config = getConfig(getGlobalConfigFilePath(), DEFAULT_GLOBAL_CONFIG)
+ const config = getLoadedGlobalConfig()
const resolvedCwd = resolve(getCwd())
const existingKey = findMatchingProjectKey(config.projects, resolvedCwd)
const storageKey = existingKey ?? resolvedCwd
-
- saveConfig(
- getGlobalConfigFilePath(),
- {
- ...config,
- projects: {
- ...config.projects,
- [storageKey]: projectConfig,
- },
+ const nextConfig = {
+ ...config,
+ projects: {
+ ...config.projects,
+ [storageKey]: projectConfig,
},
- DEFAULT_GLOBAL_CONFIG,
- )
+ }
+
+ const file = getGlobalConfigFilePath()
+ if (saveConfig(file, nextConfig, DEFAULT_GLOBAL_CONFIG)) {
+ cacheGlobalConfig(file, nextConfig)
+ }
}
export async function isAutoUpdaterDisabled(): Promise {
diff --git a/src/entrypoints/cli/runCli.tsx b/src/entrypoints/cli/runCli.tsx
index c36d3ccd0..6757439d9 100644
--- a/src/entrypoints/cli/runCli.tsx
+++ b/src/entrypoints/cli/runCli.tsx
@@ -23,7 +23,6 @@ import {
getConfigForCLI,
listConfigForCLI,
enableConfigs,
- validateAndRepairAllGPT5Profiles,
} from '@utils/config'
import { cwd } from 'process'
import { dateToFilename, logError, parseLogFilename } from '@utils/log'
@@ -99,14 +98,6 @@ export async function runCli() {
try {
enableConfigs()
-
- queueMicrotask(() => {
- try {
- validateAndRepairAllGPT5Profiles()
- } catch (repairError) {
- logError(`GPT-5 configuration validation failed: ${repairError}`)
- }
- })
} catch (error: unknown) {
if (error instanceof ConfigParseError) {
await showInvalidConfigDialog({ error })
@@ -600,34 +591,15 @@ async function parseArgs(
process.exit(1)
}
- const updateInfo = await (async () => {
- try {
- const [
- { getLatestVersion, getUpdateCommandSuggestions },
- semverMod,
- ] = await Promise.all([
- import('@utils/session/autoUpdater'),
- import('semver'),
- ])
- const semver: any = (semverMod as any)?.default ?? semverMod
- const gt = semver?.gt
- if (typeof gt !== 'function')
- return {
- version: null as string | null,
- commands: null as string[] | null,
- }
+ // Start update check early, outside REPL render critical path
+ const updateCheckPromise = import('@utils/session/autoUpdater')
+ .then(({ getUpdateBannerInfo }) => getUpdateBannerInfo())
+ .catch(() => ({ version: null, commands: null }))
- const latest = await getLatestVersion()
- if (latest && gt(latest, MACRO.VERSION)) {
- const cmds = await getUpdateCommandSuggestions()
- return { version: latest as string, commands: cmds as string[] }
- }
- } catch {}
- return {
- version: null as string | null,
- commands: null as string[] | null,
- }
- })()
+ const updateInfo = {
+ version: null as string | null,
+ commands: null as string[] | null,
+ }
if (needsResumeSelector) {
const sessions = listKodeAgentSessions({ cwd })
@@ -656,6 +628,7 @@ async function parseArgs(
forkSessionId={sessionId ? String(sessionId) : null}
initialUpdateVersion={updateInfo.version}
initialUpdateCommands={updateInfo.commands}
+ updateCheckPromise={updateCheckPromise}
/>,
renderContextWithExitOnCtrlC,
)
@@ -685,6 +658,7 @@ async function parseArgs(
initialUpdateVersion={updateInfo.version}
initialUpdateCommands={updateInfo.commands}
initialMessages={initialMessages}
+ updateCheckPromise={updateCheckPromise}
/>,
renderContext,
)
diff --git a/src/tools/system/BashTool/BashTool.tsx b/src/tools/system/BashTool/BashTool.tsx
index eed042470..ee07af5f3 100644
--- a/src/tools/system/BashTool/BashTool.tsx
+++ b/src/tools/system/BashTool/BashTool.tsx
@@ -9,6 +9,7 @@ import { Tool, ValidationResult, ToolUseContext } from '@tool'
import { splitCommand } from '@utils/commands'
import { isInDirectory } from '@utils/fs/file'
import { logError } from '@utils/log'
+import { logStartupProfileDuration } from '@utils/config/startupProfile'
import { createAssistantMessage } from '@utils/messages'
import { BunShell } from '@utils/bun/shell'
import { getBunShellSandboxPlan } from '@utils/sandbox/bunShellSandboxPlan'
@@ -700,6 +701,7 @@ export const BashTool = {
}
if (race.kind === 'done') {
+ const finalizeStartedAt = Date.now()
const result = race.r
stdout += (result.stdout || '').trim() + EOL
@@ -743,6 +745,10 @@ export const BashTool = {
interrupted: result.interrupted,
}
+ logStartupProfileDuration(
+ 'bash_result_finalize',
+ Date.now() - finalizeStartedAt,
+ )
yield {
type: 'result',
resultForAssistant: this.renderResultForAssistant(data),
diff --git a/src/ui/components/Logo.tsx b/src/ui/components/Logo.tsx
index cb03bc42f..aa1d74500 100644
--- a/src/ui/components/Logo.tsx
+++ b/src/ui/components/Logo.tsx
@@ -16,6 +16,32 @@ const DEFAULT_UPDATE_COMMANDS = [
'npm install -g @shareai-lab/kode@latest',
] as const
+export function UpdateBanner({
+ version,
+ commands,
+}: {
+ version: string
+ commands?: string[] | null
+}): React.ReactNode {
+ return (
+
+
+ New version available: {version} (current: {MACRO.VERSION})
+
+ Run the following command to update:
+
+ {' '}
+ {commands?.[1] ?? DEFAULT_UPDATE_COMMANDS[1]}
+
+ {process.platform !== 'win32' && (
+
+ Note: you may need to prefix with "sudo" on macOS/Linux.
+
+ )}
+
+ )
+}
+
export function Logo({
mcpClients,
isDefaultModel = false,
@@ -52,22 +78,10 @@ export function Logo({
width={width}
>
{updateBannerVersion ? (
-
-
- New version available: {updateBannerVersion} (current:{' '}
- {MACRO.VERSION})
-
- Run the following command to update:
-
- {' '}
- {updateBannerCommands?.[1] ?? DEFAULT_UPDATE_COMMANDS[1]}
-
- {process.platform !== 'win32' && (
-
- Note: you may need to prefix with "sudo" on macOS/Linux.
-
- )}
-
+
) : null}
✻ Welcome to{' '}
diff --git a/src/ui/components/PromptInput.tsx b/src/ui/components/PromptInput.tsx
index e587881cf..506c7d581 100644
--- a/src/ui/components/PromptInput.tsx
+++ b/src/ui/components/PromptInput.tsx
@@ -16,8 +16,7 @@ import type { SetToolJSXFn, Tool } from '@tool'
import { TokenWarning, WARNING_THRESHOLD } from './TokenWarning'
import { useTerminalSize } from '@hooks/useTerminalSize'
import { getTheme } from '@utils/theme'
-import { getModelManager, reloadModelManager } from '@utils/model'
-import { saveGlobalConfig } from '@utils/config'
+import { getModelManager } from '@utils/model'
import { setTerminalTitle } from '@utils/terminal'
import { launchExternalEditor } from '@utils/system/externalEditor'
import {
@@ -67,6 +66,22 @@ async function interpretHashCommand(input: string): Promise {
}
}
+let didScheduleGPT5ProfileRepair = false
+
+function scheduleDeferredGPT5ProfileRepair(): void {
+ if (didScheduleGPT5ProfileRepair) return
+ didScheduleGPT5ProfileRepair = true
+ setTimeout(() => {
+ void import('@utils/config')
+ .then(({ validateAndRepairAllGPT5Profiles }) => {
+ validateAndRepairAllGPT5Profiles()
+ })
+ .catch(error => {
+ logError(`GPT-5 configuration validation failed: ${error}`)
+ })
+ }, 0)
+}
+
type Props = {
commands: Command[]
forkNumber: number
@@ -137,6 +152,7 @@ function PromptInput({
useEffect(() => {
if (!isDisabled && !isLoading) {
logStartupProfile('prompt_ready')
+ scheduleDeferredGPT5ProfileRepair()
}
}, [isDisabled, isLoading])
diff --git a/src/ui/screens/REPL.tsx b/src/ui/screens/REPL.tsx
index 6263b5434..b5cf981b1 100644
--- a/src/ui/screens/REPL.tsx
+++ b/src/ui/screens/REPL.tsx
@@ -7,7 +7,7 @@ import { CostThresholdDialog } from '@components/CostThresholdDialog'
import * as React from 'react'
import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { Command } from '@commands'
-import { Logo } from '@components/Logo'
+import { Logo, UpdateBanner } from '@components/Logo'
import { Message } from '@components/Message'
import { MessageResponse } from '@components/MessageResponse'
import { MessageSelector } from '@components/MessageSelector'
@@ -63,7 +63,7 @@ import {
createAssistantMessage,
} from '@utils/messages'
import { getReplStaticPrefixLength } from '@utils/terminal/replStaticSplit'
-import { getModelManager, ModelManager } from '@utils/model'
+import { getModelManager } from '@utils/model'
import { clearTerminal, updateTerminalTitle } from '@utils/terminal'
import { BinaryFeedback } from '@components/binary-feedback/BinaryFeedback'
import { getMaxThinkingTokens } from '@utils/model/thinking'
@@ -71,6 +71,7 @@ import { getOriginalCwd } from '@utils/state'
import { handleHashCommand } from '@utils/commands/hashCommand'
import { debug as debugLogger } from '@utils/log/debugLogger'
import { getToolPermissionContextForConversationKey } from '@utils/permissions/toolPermissionContextState'
+import { logStartupProfileDuration } from '@utils/config/startupProfile'
type Props = {
commands: Command[]
@@ -88,6 +89,10 @@ type Props = {
isDefaultModel?: boolean
initialUpdateVersion?: string | null
initialUpdateCommands?: string[] | null
+ updateCheckPromise?: Promise<{
+ version: string | null
+ commands: string[] | null
+ }>
}
export type BinaryFeedbackContext = {
@@ -112,6 +117,7 @@ export function REPL({
isDefaultModel = true,
initialUpdateVersion,
initialUpdateCommands,
+ updateCheckPromise,
}: Props): React.ReactNode {
const [verboseConfig] = useState(
() => verboseFromCLI ?? getGlobalConfig().verbose,
@@ -153,8 +159,18 @@ export function REPL({
const [binaryFeedbackContext, setBinaryFeedbackContext] =
useState(null)
- const updateAvailableVersion = initialUpdateVersion ?? null
- const updateCommands = initialUpdateCommands ?? null
+ const initialUpdateAvailableVersion = initialUpdateVersion ?? null
+ const initialUpdateCommandList = initialUpdateCommands ?? null
+ const [asyncUpdateInfo, setAsyncUpdateInfo] = useState<{
+ version: string | null
+ commands: string[] | null
+ }>({
+ version: null,
+ commands: null,
+ })
+ const updateAvailableVersion =
+ initialUpdateAvailableVersion ?? asyncUpdateInfo.version
+ const updateCommands = initialUpdateCommandList ?? asyncUpdateInfo.commands
const getBinaryFeedbackResponse = useCallback(
(
@@ -228,7 +244,6 @@ export function REPL({
const newAbortController = new AbortController()
setAbortController(newAbortController)
- const model = new ModelManager(getGlobalConfig()).getModelName('main')
const newMessages = await processUserInput(
initialPrompt,
'prompt',
@@ -270,13 +285,41 @@ export function REPL({
return
}
- const [systemPrompt, context, model, maxThinkingTokens] =
- await Promise.all([
- getSystemPrompt({ disableSlashCommands }),
- getContext(),
- new ModelManager(getGlobalConfig()).getModelName('main'),
- getMaxThinkingTokens([...messages, ...newMessages]),
- ])
+ const queryPrepStartedAt = Date.now()
+ const systemPromptPromise = (async () => {
+ const startedAt = Date.now()
+ try {
+ return await getSystemPrompt({ disableSlashCommands })
+ } finally {
+ logStartupProfileDuration(
+ 'query_prep_system_prompt',
+ Date.now() - startedAt,
+ )
+ }
+ })()
+ const contextPromise = (async () => {
+ const startedAt = Date.now()
+ try {
+ return await getContext()
+ } finally {
+ logStartupProfileDuration(
+ 'query_prep_context',
+ Date.now() - startedAt,
+ )
+ }
+ })()
+ const modelStartedAt = Date.now()
+ void getModelManager().getModelName('main')
+ logStartupProfileDuration('query_prep_model', Date.now() - modelStartedAt)
+ const [systemPrompt, context, maxThinkingTokens] = await Promise.all([
+ systemPromptPromise,
+ contextPromise,
+ getMaxThinkingTokens([...messages, ...newMessages]),
+ ])
+ logStartupProfileDuration(
+ 'query_prep_total',
+ Date.now() - queryPrepStartedAt,
+ )
for await (const message of query(
[...messages, ...newMessages],
@@ -351,13 +394,37 @@ export function REPL({
return
}
- const [systemPrompt, context, model, maxThinkingTokens] = await Promise.all(
- [
- getSystemPrompt({ disableSlashCommands }),
- getContext(),
- new ModelManager(getGlobalConfig()).getModelName('main'),
- getMaxThinkingTokens([...messages, lastMessage]),
- ],
+ const queryPrepStartedAt = Date.now()
+ const systemPromptPromise = (async () => {
+ const startedAt = Date.now()
+ try {
+ return await getSystemPrompt({ disableSlashCommands })
+ } finally {
+ logStartupProfileDuration(
+ 'query_prep_system_prompt',
+ Date.now() - startedAt,
+ )
+ }
+ })()
+ const contextPromise = (async () => {
+ const startedAt = Date.now()
+ try {
+ return await getContext()
+ } finally {
+ logStartupProfileDuration('query_prep_context', Date.now() - startedAt)
+ }
+ })()
+ const modelStartedAt = Date.now()
+ void getModelManager().getModelName('main')
+ logStartupProfileDuration('query_prep_model', Date.now() - modelStartedAt)
+ const [systemPrompt, context, maxThinkingTokens] = await Promise.all([
+ systemPromptPromise,
+ contextPromise,
+ getMaxThinkingTokens([...messages, lastMessage]),
+ ])
+ logStartupProfileDuration(
+ 'query_prep_total',
+ Date.now() - queryPrepStartedAt,
)
let lastAssistantMessage: MessageType | null = null
@@ -437,6 +504,22 @@ export function REPL({
})
}, [])
+ useEffect(() => {
+ if (initialUpdateAvailableVersion || !updateCheckPromise) return
+ let cancelled = false
+ updateCheckPromise
+ .then(updateInfo => {
+ if (cancelled || !updateInfo.version) return
+ setAsyncUpdateInfo(updateInfo)
+ })
+ .catch(error => {
+ logError(`update-banner: ${error}`)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [initialUpdateAvailableVersion, updateCheckPromise])
+
useLogMessages(messages, messageLogName, forkNumber)
useLogStartupTime()
@@ -601,8 +684,8 @@ export function REPL({
@@ -616,8 +699,8 @@ export function REPL({
replStaticPrefixLength,
mcpClients,
isDefaultModel,
- updateAvailableVersion,
- updateCommands,
+ initialUpdateAvailableVersion,
+ initialUpdateCommandList,
],
)
@@ -639,6 +722,12 @@ export function REPL({
item.jsx} />
+ {!initialUpdateAvailableVersion && updateAvailableVersion ? (
+
+ ) : null}
{transientItems.map(_ => _.jsx)}
setUiRefreshCounter(prev => prev + 1)}
setIsLoading={setIsLoading}
setAbortController={setAbortController}
uiRefreshCounter={uiRefreshCounter}
diff --git a/src/ui/screens/ResumeConversation.tsx b/src/ui/screens/ResumeConversation.tsx
index 7e8f2e428..e079fe893 100644
--- a/src/ui/screens/ResumeConversation.tsx
+++ b/src/ui/screens/ResumeConversation.tsx
@@ -29,6 +29,10 @@ type Props = {
forkSessionId?: string | null
initialUpdateVersion?: string | null
initialUpdateCommands?: string[] | null
+ updateCheckPromise?: Promise<{
+ version: string | null
+ commands: string[] | null
+ }>
}
export function ResumeConversation({
@@ -47,6 +51,7 @@ export function ResumeConversation({
forkSessionId,
initialUpdateVersion,
initialUpdateCommands,
+ updateCheckPromise,
}: Props): React.ReactNode {
async function onSelect(index: number) {
try {
@@ -82,6 +87,7 @@ export function ResumeConversation({
isDefaultModel={isDefaultModel}
initialUpdateVersion={initialUpdateVersion}
initialUpdateCommands={initialUpdateCommands}
+ updateCheckPromise={updateCheckPromise}
/>,
{
exitOnCtrlC: false,
diff --git a/src/utils/bun/shell.ts b/src/utils/bun/shell.ts
index f982640e9..e7ca83187 100644
--- a/src/utils/bun/shell.ts
+++ b/src/utils/bun/shell.ts
@@ -12,6 +12,14 @@ import {
} from '@utils/log/taskOutputStore'
type ShellChildProcess = ChildProcess & { exited: Promise }
+type ShellStdio = ['ignore', 'pipe' | 'overlapped', 'pipe' | 'overlapped']
+
+export function getShellStdioForPlatform(
+ platform: NodeJS.Platform = process.platform,
+): ShellStdio {
+ const output = platform === 'win32' ? 'overlapped' : 'pipe'
+ return ['ignore', output, output]
+}
function whichSync(bin: string): string | null {
try {
@@ -33,7 +41,7 @@ function spawnWithExited(options: {
const child = spawn(options.cmd[0], options.cmd.slice(1), {
cwd: options.cwd,
env: options.env ?? process.env,
- stdio: ['inherit', 'pipe', 'pipe'],
+ stdio: getShellStdioForPlatform(),
windowsHide: true,
}) as ShellChildProcess
diff --git a/src/utils/config/startupProfile.ts b/src/utils/config/startupProfile.ts
index e4d31be46..106268804 100644
--- a/src/utils/config/startupProfile.ts
+++ b/src/utils/config/startupProfile.ts
@@ -19,3 +19,23 @@ export function logStartupProfile(event: StartupEvent): void {
const ms = Math.round(process.uptime() * 1000)
process.stderr.write(`[startup] ${event}=${ms}ms\n`)
}
+
+export function logStartupProfileDuration(
+ event: string,
+ durationMs: number,
+ details?: Record,
+): void {
+ if (!isEnabled()) return
+
+ const suffix = details
+ ? Object.entries(details)
+ .filter((entry): entry is [string, string | number | boolean] => {
+ return entry[1] !== undefined
+ })
+ .map(([key, value]) => `${key}=${String(value)}`)
+ .join(' ')
+ : ''
+ process.stderr.write(
+ `[startup] ${event}=${Math.round(durationMs)}ms${suffix ? ` ${suffix}` : ''}\n`,
+ )
+}
diff --git a/src/utils/model/index.ts b/src/utils/model/index.ts
index 0c9338de4..29dcbf8ef 100644
--- a/src/utils/model/index.ts
+++ b/src/utils/model/index.ts
@@ -2,6 +2,7 @@ import { memoize } from 'lodash-es'
import { logError } from '@utils/log'
import { debug as debugLogger } from '@utils/log/debugLogger'
+import { logStartupProfileDuration } from '@utils/config/startupProfile'
import {
getGlobalConfig,
ModelProfile,
@@ -640,11 +641,13 @@ export class ModelManager {
}
private saveConfig(): void {
+ const startedAt = Date.now()
const updatedConfig = {
...this.config,
modelProfiles: this.modelProfiles,
}
saveGlobalConfig(updatedConfig)
+ logStartupProfileDuration('model_config_save', Date.now() - startedAt)
}
async getFallbackModel(): Promise {
diff --git a/src/utils/session/autoUpdater.ts b/src/utils/session/autoUpdater.ts
index 7875e4e15..1f58241d3 100644
--- a/src/utils/session/autoUpdater.ts
+++ b/src/utils/session/autoUpdater.ts
@@ -1,9 +1,15 @@
import { execFileNoThrow } from '@utils/system/execFileNoThrow'
import { logError } from '@utils/log'
+import { logStartupProfileDuration } from '@utils/config/startupProfile'
import { MACRO } from '@constants/macros'
import { PRODUCT_NAME } from '@constants/product'
+export type UpdateBannerInfo = {
+ version: string | null
+ commands: string[] | null
+}
+
async function getSemver() {
const mod: any = await import('semver')
return (mod?.default ?? mod) as {
@@ -83,6 +89,26 @@ export async function getUpdateCommandSuggestions(): Promise {
]
}
+export async function getUpdateBannerInfo(): Promise {
+ const startedAt = Date.now()
+ try {
+ if (process.env.NODE_ENV === 'test') {
+ return { version: null, commands: null }
+ }
+ const semver = await getSemver()
+ const latest = await getLatestVersion()
+ if (latest && semver.gt(latest, MACRO.VERSION)) {
+ const commands = await getUpdateCommandSuggestions()
+ return { version: latest, commands }
+ }
+ } catch {
+ } finally {
+ logStartupProfileDuration('update_check', Date.now() - startedAt)
+ }
+
+ return { version: null, commands: null }
+}
+
export async function checkAndNotifyUpdate(): Promise {
try {
if (process.env.NODE_ENV === 'test') return
diff --git a/tests/unit/config-loader-cache.test.ts b/tests/unit/config-loader-cache.test.ts
new file mode 100644
index 000000000..26fe13d2b
--- /dev/null
+++ b/tests/unit/config-loader-cache.test.ts
@@ -0,0 +1,125 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
+import {
+ existsSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import {
+ clearConfigCacheForTesting,
+ enableConfigs,
+ getGlobalConfig,
+ saveGlobalConfig,
+} from '../../src/core/config/loader'
+
+describe('config loader cache', () => {
+ const originalNodeEnv = process.env.NODE_ENV
+ const originalKodeConfigDir = process.env.KODE_CONFIG_DIR
+ const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
+ let configDir = ''
+ let configFile = ''
+
+ beforeEach(() => {
+ configDir = mkdtempSync(join(tmpdir(), 'kode-config-cache-'))
+ configFile = join(configDir, 'config.json')
+ process.env.NODE_ENV = 'development'
+ process.env.KODE_CONFIG_DIR = configDir
+ delete process.env.CLAUDE_CONFIG_DIR
+ clearConfigCacheForTesting()
+ })
+
+ afterEach(() => {
+ clearConfigCacheForTesting()
+ if (configDir && existsSync(configDir)) {
+ rmSync(configDir, { recursive: true, force: true })
+ }
+ if (originalNodeEnv === undefined) {
+ delete process.env.NODE_ENV
+ } else {
+ process.env.NODE_ENV = originalNodeEnv
+ }
+ if (originalKodeConfigDir === undefined) {
+ delete process.env.KODE_CONFIG_DIR
+ } else {
+ process.env.KODE_CONFIG_DIR = originalKodeConfigDir
+ }
+ if (originalClaudeConfigDir === undefined) {
+ delete process.env.CLAUDE_CONFIG_DIR
+ } else {
+ process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
+ }
+ })
+
+ test('enableConfigs caches subsequent global config reads', () => {
+ writeFileSync(configFile, JSON.stringify({ numStartups: 7 }), 'utf-8')
+
+ enableConfigs()
+ writeFileSync(configFile, JSON.stringify({ numStartups: 99 }), 'utf-8')
+
+ expect(getGlobalConfig().numStartups).toBe(7)
+ })
+
+ test('saveGlobalConfig preserves existing projects without using caller projects', () => {
+ const projectPath = join(configDir, 'project')
+ writeFileSync(
+ configFile,
+ JSON.stringify({
+ numStartups: 1,
+ projects: {
+ [projectPath]: {
+ allowedTools: ['Bash'],
+ },
+ },
+ }),
+ 'utf-8',
+ )
+
+ enableConfigs()
+ saveGlobalConfig({
+ ...(getGlobalConfig() as any),
+ numStartups: 2,
+ projects: {
+ shouldNotBeSaved: {
+ allowedTools: ['Edit'],
+ },
+ },
+ } as any)
+
+ const saved = JSON.parse(readFileSync(configFile, 'utf-8'))
+ expect(saved.numStartups).toBe(2)
+ expect(saved.projects).toEqual({
+ [projectPath]: {
+ allowedTools: ['Bash'],
+ },
+ })
+ })
+
+ test('saveGlobalConfig updates the in-memory cache after writing', () => {
+ writeFileSync(configFile, JSON.stringify({ numStartups: 3 }), 'utf-8')
+
+ enableConfigs()
+ saveGlobalConfig({ ...(getGlobalConfig() as any), numStartups: 4 } as any)
+ writeFileSync(configFile, JSON.stringify({ numStartups: 99 }), 'utf-8')
+
+ expect(getGlobalConfig().numStartups).toBe(4)
+ })
+
+ test('NODE_ENV=test continues to use the test config object', () => {
+ process.env.NODE_ENV = 'test'
+ clearConfigCacheForTesting()
+ const original = { ...(getGlobalConfig() as any) }
+
+ try {
+ saveGlobalConfig({
+ ...(getGlobalConfig() as any),
+ numStartups: 123,
+ } as any)
+ expect(getGlobalConfig().numStartups).toBe(123)
+ } finally {
+ saveGlobalConfig(original as any)
+ }
+ })
+})
diff --git a/tests/unit/file-permission-engine.test.ts b/tests/unit/file-permission-engine.test.ts
index 17431e058..a8947c093 100644
--- a/tests/unit/file-permission-engine.test.ts
+++ b/tests/unit/file-permission-engine.test.ts
@@ -193,22 +193,26 @@ describe('Reference CLI parity: filesystem permission engine', () => {
}
})
- test('asks for UNC paths and does not provide suggestions', async () => {
- const toolPermissionContext = createDefaultToolPermissionContext({
- isBypassPermissionsModeAvailable: true,
- })
- const ctx = makeContext({ toolPermissionContext })
+ test(
+ 'asks for UNC paths and does not provide suggestions',
+ async () => {
+ const toolPermissionContext = createDefaultToolPermissionContext({
+ isBypassPermissionsModeAvailable: true,
+ })
+ const ctx = makeContext({ toolPermissionContext })
- const result = await hasPermissionsToUseTool(
- FileReadTool as any,
- { file_path: '//server/share/file.txt' },
- ctx as any,
- {} as any,
- )
+ const result = await hasPermissionsToUseTool(
+ FileReadTool as any,
+ { file_path: '//server/share/file.txt' },
+ ctx as any,
+ {} as any,
+ )
- expect(result.result).toBe(false)
- expect((result as any).suggestions).toBeUndefined()
- })
+ expect(result.result).toBe(false)
+ expect((result as any).suggestions).toBeUndefined()
+ },
+ { timeout: 15_000 },
+ )
test('asks for suspicious Windows path patterns and does not provide suggestions', async () => {
const toolPermissionContext = createDefaultToolPermissionContext({
diff --git a/tests/unit/lsp-tool.test.ts b/tests/unit/lsp-tool.test.ts
index 205006883..7a336b54d 100644
--- a/tests/unit/lsp-tool.test.ts
+++ b/tests/unit/lsp-tool.test.ts
@@ -94,25 +94,30 @@ describe('LSP tool (TypeScript backend)', () => {
}
})
- test('goToDefinition returns formatted location + counts', async () => {
- const ctx = makeContext()
- const input = {
- operation: 'goToDefinition',
- filePath,
- line: 2,
- character: 32,
- } as const
-
- const events: any[] = []
- for await (const evt of (LspTool as any).call(input, ctx)) events.push(evt)
- expect(events).toHaveLength(1)
-
- const out = events[0].data
- expect(out.operation).toBe('goToDefinition')
- expect(out.result).toContain('Defined in')
- expect(out.resultCount).toBeGreaterThan(0)
- expect(out.fileCount).toBeGreaterThan(0)
- })
+ test(
+ 'goToDefinition returns formatted location + counts',
+ async () => {
+ const ctx = makeContext()
+ const input = {
+ operation: 'goToDefinition',
+ filePath,
+ line: 2,
+ character: 32,
+ } as const
+
+ const events: any[] = []
+ for await (const evt of (LspTool as any).call(input, ctx))
+ events.push(evt)
+ expect(events).toHaveLength(1)
+
+ const out = events[0].data
+ expect(out.operation).toBe('goToDefinition')
+ expect(out.result).toContain('Defined in')
+ expect(out.resultCount).toBeGreaterThan(0)
+ expect(out.fileCount).toBeGreaterThan(0)
+ },
+ { timeout: 15_000 },
+ )
test('findReferences returns formatted grouped locations + counts', async () => {
const ctx = makeContext()
@@ -176,39 +181,45 @@ describe('LSP tool (TypeScript backend)', () => {
expect(out.fileCount).toBe(1)
})
- test('documentSymbol reflects on-disk file edits (mtime-based versions)', async () => {
- const ctx = makeContext()
- const input = {
- operation: 'documentSymbol',
- filePath,
- line: 1,
- character: 1,
- } as const
-
- const events1: any[] = []
- for await (const evt of (LspTool as any).call(input, ctx)) events1.push(evt)
- expect(events1).toHaveLength(1)
- const out1 = events1[0].data
- expect(out1.result).toContain('foo')
- expect(out1.result).not.toContain('baz')
-
- const beforeMtime = statSync(filePath).mtimeMs
- const updated = [
- 'export function foo() { return 1 }',
- 'export function bar() { return foo() }',
- 'export function baz() { return bar() }',
- 'foo()',
- '',
- ].join('\n')
- writeFileSync(filePath, updated, 'utf8')
- utimesSync(filePath, new Date(), new Date(beforeMtime + 1000))
-
- expect(statSync(filePath).mtimeMs).toBeGreaterThan(beforeMtime)
-
- const events2: any[] = []
- for await (const evt of (LspTool as any).call(input, ctx)) events2.push(evt)
- expect(events2).toHaveLength(1)
- const out2 = events2[0].data
- expect(out2.result).toContain('baz')
- })
+ test(
+ 'documentSymbol reflects on-disk file edits (mtime-based versions)',
+ async () => {
+ const ctx = makeContext()
+ const input = {
+ operation: 'documentSymbol',
+ filePath,
+ line: 1,
+ character: 1,
+ } as const
+
+ const events1: any[] = []
+ for await (const evt of (LspTool as any).call(input, ctx))
+ events1.push(evt)
+ expect(events1).toHaveLength(1)
+ const out1 = events1[0].data
+ expect(out1.result).toContain('foo')
+ expect(out1.result).not.toContain('baz')
+
+ const beforeMtime = statSync(filePath).mtimeMs
+ const updated = [
+ 'export function foo() { return 1 }',
+ 'export function bar() { return foo() }',
+ 'export function baz() { return bar() }',
+ 'foo()',
+ '',
+ ].join('\n')
+ writeFileSync(filePath, updated, 'utf8')
+ utimesSync(filePath, new Date(), new Date(beforeMtime + 1000))
+
+ expect(statSync(filePath).mtimeMs).toBeGreaterThan(beforeMtime)
+
+ const events2: any[] = []
+ for await (const evt of (LspTool as any).call(input, ctx))
+ events2.push(evt)
+ expect(events2).toHaveLength(1)
+ const out2 = events2[0].data
+ expect(out2.result).toContain('baz')
+ },
+ { timeout: 15_000 },
+ )
})
diff --git a/tests/unit/shell-cmd-selection.test.ts b/tests/unit/shell-cmd-selection.test.ts
index bbd46b56c..5565f16a8 100644
--- a/tests/unit/shell-cmd-selection.test.ts
+++ b/tests/unit/shell-cmd-selection.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
-import { BunShell } from '@utils/bun/shell'
+import { BunShell, getShellStdioForPlatform } from '@utils/bun/shell'
describe('shell command selection', () => {
test('win32 uses ComSpec when provided', () => {
@@ -20,4 +20,20 @@ describe('shell command selection', () => {
expect(cmd[1]).toBe('-c')
expect(cmd[2]).toBe('echo hi')
})
+
+ test('non-Windows shell stdio ignores stdin and pipes output', () => {
+ expect(getShellStdioForPlatform('linux')).toEqual([
+ 'ignore',
+ 'pipe',
+ 'pipe',
+ ])
+ })
+
+ test('Windows shell stdio ignores stdin and uses overlapped output pipes', () => {
+ expect(getShellStdioForPlatform('win32')).toEqual([
+ 'ignore',
+ 'overlapped',
+ 'overlapped',
+ ])
+ })
})