Files
kilocode/packages/kilo-vscode/src/KiloProvider.ts
T

3246 lines
123 KiB
TypeScript

import * as path from "path"
import * as vscode from "vscode"
import { z } from "zod"
import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "./image-preview"
import { isAbsolutePath } from "./path-utils"
import type {
KiloClient,
Session,
SessionStatus,
Event,
TextPartInput,
FilePartInput,
Config,
} from "@kilocode/sdk/v2/client"
import { type KiloConnectionService, type KilocodeNotification, ServerStartupError } from "./services/cli-backend"
import type { EditorContext } from "./services/cli-backend/types"
import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController"
import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete"
import { buildWebviewHtml } from "./utils"
import { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry"
import {
sessionToWebview,
indexProvidersById,
filterVisibleAgents,
buildSettingPath,
mapSSEEventToWebviewMessage,
getErrorMessage,
isEventFromForeignProject,
loadSessions as loadSessionsUtil,
flushPendingSessionRefresh as flushPendingSessionRefreshUtil,
resolveContextDirectory,
resolveWorkspaceDirectory,
type SessionRefreshContext,
} from "./kilo-provider-utils"
import { GitOps } from "./agent-manager/GitOps"
import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller"
import { getWorkspaceRoot } from "./review-utils"
import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace"
import { resolveProjectDirectory } from "./project-directory"
import { getBusySessionCount, seedSessionStatuses } from "./session-status"
import { retry } from "./services/cli-backend/retry"
import { slimPart, slimParts } from "./kilo-provider/slim-metadata"
import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session"
import { retryable, backoff, MAX_RETRIES } from "./util/retry"
// legacy-migration start
import {
checkAndShowMigrationWizard,
handleRequestLegacyMigrationData,
handleStartLegacyMigration,
handleFinalizeLegacyMigration,
handleSkipLegacyMigration,
handleClearLegacyData,
type MigrationContext,
} from "./kilo-provider/handlers/migration"
// legacy-migration end
import {
handleLogin,
handleLogout,
handleSetOrganization,
handleRefreshProfile,
type AuthContext,
} from "./kilo-provider/handlers/auth"
import {
handleRequestCloudSessions,
handleRequestCloudSessionData,
handleImportAndSend,
type CloudSessionContext,
} from "./kilo-provider/handlers/cloud-session"
import {
handlePermissionResponse,
fetchAndSendPendingPermissions,
type PermissionContext,
} from "./kilo-provider/handlers/permission-handler"
import {
handleQuestionReply,
handleQuestionReject,
fetchAndSendPendingQuestions,
} from "./kilo-provider/handlers/question"
import {
buildActionContext,
computeDefaultSelection,
fetchProviderData,
validateRecents,
validateFavorites,
connectProvider as connectProviderAction,
authorizeProviderOAuth as authorizeOAuthAction,
completeProviderOAuth as completeOAuthAction,
disconnectProvider as disconnectProviderAction,
saveCustomProvider as saveCustomProviderAction,
} from "./provider-actions"
import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models"
import type { Agent } from "@kilocode/sdk/v2/client"
type KiloProviderOptions = {
projectDirectory?: string | null
slimEditMetadata?: boolean
}
// Helper to map agent data to the subset of fields sent to the webview
const mapAgent = (a: Agent) => ({
name: a.name,
displayName: a.displayName,
description: a.description,
mode: a.mode,
native: a.native,
hidden: a.hidden,
color: a.color,
deprecated: a.deprecated,
permission: a.permission,
})
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
public static readonly viewType = "kilo-code.SidebarProvider"
private webview: vscode.Webview | null = null
private currentSession: Session | null = null
/** Remembers the last selected session so /new can stay in the same worktree after clearSession. */
private contextSessionID: string | undefined
private connectionState: "connecting" | "connected" | "disconnected" | "error" = "connecting"
private loginAttempt = 0
private isWebviewReady = false
private readonly extensionVersion =
vscode.extensions.getExtension("kilocode.kilo-code")?.packageJSON?.version ?? "unknown"
/** Cached providersLoaded payload so requestProviders can be served before client is ready */
private cachedProvidersMessage: unknown = null
/** Coalesce provider refreshes — at most one follow-up rerun when a request lands mid-flight. */
private providersRefresh: Promise<void> | null = null
private providersQueued = false
private providersGeneration = 0
/** Cached agentsLoaded payload so requestAgents can be served before client is ready */
private cachedAgentsMessage: unknown = null
/** Cached skillsLoaded payload so requestSkills can be served before client is ready */
private cachedSkillsMessage: unknown = null
/** Cached commandsLoaded payload so requestCommands can be served before client is ready */
private cachedCommandsMessage: unknown = null
/** Cached configLoaded payload so requestConfig can be served before client is ready */
private cachedConfigMessage: unknown = null
/** Cached mcpStatusLoaded payload so requestMcpStatus can be served before client is ready */
private cachedMcpStatusMessage: unknown = null
/** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */
private pending = 0
private configWarningsShown = false
/** Cached notificationsLoaded payload */
private cachedNotificationsMessage: unknown = null
private pendingReviewComments: { comments: unknown[]; autoSend: boolean }[] = []
private readyResolvers: (() => void)[] = []
private trackedSessionIds: Set<string> = new Set()
private syncedChildSessions: Set<string> = new Set()
/** Tracks the latest status for each session, used to warn before destructive config operations. */
private sessionStatusMap = new Map<string, SessionStatus["type"]>()
/** Per-session directory overrides (e.g., worktree paths registered by AgentManagerProvider). */
private sessionDirectories = new Map<string, string>()
/** Project ID for the current workspace, used to filter out sessions from other repositories. */
private projectID: string | undefined
/** Abort controller for the current loadMessages request; aborted when a new session is selected. */
private loadMessagesAbort: AbortController | null = null
/** Set when refreshSessions() is called before the client is ready.
* Cleared and retried once the connection transitions to "connected". */
private pendingSessionRefresh = false
private unsubscribeEvent: (() => void) | null = null
private unsubscribeState: (() => void) | null = null
/** Cached legacy migration data so migrate() doesn't re-read from disk/SecretStorage. */ // legacy-migration
private cachedLegacyData: import("./legacy-migration/legacy-types").LegacyMigrationData | null = null // legacy-migration
/** Guard to prevent checkAndShowMigrationWizard running concurrently. */ // legacy-migration
private migrationCheckInFlight = false // legacy-migration
private unsubscribeNotificationDismiss: (() => void) | null = null
private unsubscribeLanguageChange: (() => void) | null = null
private unsubscribeProfileChange: (() => void) | null = null
private unsubscribeFavoritesChange: (() => void) | null = null
private unsubscribeMigrationComplete: (() => void) | null = null // legacy-migration
private unsubscribeClearPendingPrompts: (() => void) | null = null
private unsubscribeDirectoryProvider: (() => void) | null = null
private initConnectionPromise: Promise<void> | null = null
private webviewMessageDisposable: vscode.Disposable | null = null
/** Lazily initialized ignore controller for .kilocodeignore filtering */
private ignoreController: FileIgnoreController | null = null
private ignoreControllerDir: string | null = null
private marketplace: MarketplaceService | null = null
private chatAutocomplete: ChatTextAreaAutocomplete | null = null
private projectDirectory: string | null | undefined
private slimEditMetadata = true
private pendingFollowup: Followup | null = null
/** Worktree diff stats poller for the sidebar badge — reuses GitStatsPoller (local stats only) */
private statsPoller: GitStatsPoller | null = null
private statsGitOps: GitOps | null = null
private cachedStats: unknown = null
/** Optional interceptor called before the standard message handler.
* Return null to consume the message, or return a (possibly transformed) message. */
private onBeforeMessage: ((msg: Record<string, unknown>) => Promise<Record<string, unknown> | null>) | null = null
/** Handler for "Continue in Worktree" — set by extension.ts to delegate to AgentManagerProvider. */
private continueInWorktreeHandler:
| ((sessionId: string, progress: (status: string, detail?: string, error?: string) => void) => Promise<void>)
| null = null
constructor(
private readonly extensionUri: vscode.Uri,
private readonly connectionService: KiloConnectionService,
private readonly extensionContext?: vscode.ExtensionContext,
options?: KiloProviderOptions,
) {
this.projectDirectory = options?.projectDirectory
this.slimEditMetadata = options?.slimEditMetadata ?? true
TelemetryProxy.getInstance().setProvider(this)
}
public setProjectDirectory(directory: string | null): void {
if (this.projectDirectory === directory) return
this.projectDirectory = directory
this.postMessage({ type: "workspaceDirectoryChanged", directory: directory ?? "" })
}
getTelemetryProperties(): Record<string, unknown> {
return {
appName: "kilo-code",
appVersion: this.extensionVersion,
platform: "vscode",
editorName: vscode.env.appName,
vscodeVersion: vscode.version,
machineId: vscode.env.machineId,
vscodeIsTelemetryEnabled: vscode.env.isTelemetryEnabled,
}
}
/**
* Convenience getter that returns the shared SDK KiloClient or null if not yet connected.
* Preserves the existing null-check pattern used throughout handler methods.
*/
private get client(): KiloClient | null {
try {
return this.connectionService.getClient()
} catch {
return null
}
}
// Edit tool parts carry full file contents in metadata.filediff.before/after.
// A session with many edits can produce multi-MB payloads serialized through
// postMessage on every session switch. Stripping those strings down to just
// file path + addition/deletion counts eliminates the dominant cost.
// Logic extracted to kilo-provider/slim-metadata.ts
private slimPart<T>(part: T): T {
if (!this.slimEditMetadata) return part
return slimPart(part)
}
private slimParts<T>(parts: T[]) {
if (!this.slimEditMetadata) return parts
return slimParts(parts)
}
/**
* Synchronize current extension-side state to the webview.
* This is primarily used after a webview refresh where early postMessage calls
* may have been dropped before the webview registered its message listeners.
*/
private async syncWebviewState(reason: string): Promise<void> {
const serverInfo = this.connectionService.getServerInfo()
console.log("[Kilo New] KiloProvider: 🔄 syncWebviewState()", {
reason,
isWebviewReady: this.isWebviewReady,
connectionState: this.connectionState,
hasClient: !!this.client,
hasServerInfo: !!serverInfo,
})
if (!this.isWebviewReady) {
console.log("[Kilo New] KiloProvider: ⏭️ syncWebviewState skipped (webview not ready)")
return
}
// Always push connection state first so the UI can render appropriately.
this.postMessage({
type: "connectionState",
state: this.connectionState,
})
// Re-send ready so the webview can recover after refresh.
if (serverInfo) {
const langConfig = vscode.workspace.getConfiguration("kilo-code.new")
this.postMessage({
type: "ready",
serverInfo,
extensionVersion: this.extensionVersion,
vscodeLanguage: vscode.env.language,
languageOverride: langConfig.get<string>("language"),
workspaceDirectory: this.getProjectDirectory(this.currentSession?.id),
})
}
// Always attempt to fetch+push profile when connected.
// Profile returns 401 when user isn't logged into Kilo Gateway — that's expected.
// Use fire-and-forget (no throwOnError) to match old getProfile() which returned null on error.
if (this.connectionState === "connected" && this.client) {
console.log("[Kilo New] KiloProvider: 👤 syncWebviewState fetching profile...")
const profileResult = await retry(() => this.client!.kilo.profile())
const profileData = profileResult.data ?? null
console.log("[Kilo New] KiloProvider: 👤 syncWebviewState profile:", profileData ? "received" : "null")
this.postMessage({
type: "profileData",
data: profileData,
})
// Re-send cached worktree stats so the badge renders immediately after webview reload.
if (this.cachedStats) this.postMessage(this.cachedStats)
// Seed session status map so the Settings panel knows about already-running sessions.
// Must run after webview is ready (postMessage is a no-op before that).
// Only reconcile (reset missing busy→idle) when the map is empty, i.e.
// on the very first seed before any real-time SSE events have arrived.
// On SSE reconnects or webview recreations the live SSE data is
// authoritative and reconciliation risks race-resetting busy sessions.
const reconcile = this.sessionStatusMap.size === 0
void this.seedSessionStatusMap(reconcile)
}
// legacy-migration start
// Show the migration wizard once the CLI connection is established.
// Three triggers cover all timing scenarios:
// "webviewReady" + connected — webview loaded after SSE was already up
// "sse-connected" — SSE connected after webview was ready
// "initializeConnection" — sidebar path where connect() resolves before
// onStateChange is subscribed, so sse-connected never fires
if (this.connectionState === "connected") {
void checkAndShowMigrationWizard(this.migrationCtx)
}
// legacy-migration end
}
public resolveWebviewView(
webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
) {
// Store the webview references
this.isWebviewReady = false
this.webview = webviewView.webview
// Set up webview options
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this.extensionUri],
}
// Set HTML content
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview)
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(webviewView.webview)
// Track sidebar visibility for keybinding when-clauses and stats polling
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
webviewView.onDidChangeVisibility(() => {
vscode.commands.executeCommand("setContext", "kilo-code.new.sidebarVisible", webviewView.visible)
this.statsPoller?.setEnabled(webviewView.visible)
})
// Initialize connection to CLI backend
this.initializeConnection()
}
/**
* Resolve a WebviewPanel for displaying the Kilo webview in an editor tab.
*/
public resolveWebviewPanel(panel: vscode.WebviewPanel): void {
// WebviewPanel can be restored/reloaded; ensure we don't treat it as ready prematurely.
this.isWebviewReady = false
this.webview = panel.webview
panel.webview.options = {
enableScripts: true,
localResourceRoots: [this.extensionUri],
}
panel.webview.html = this._getHtmlForWebview(panel.webview)
// Handle messages from webview (shared handler)
this.setupWebviewMessageHandler(panel.webview)
this.initializeConnection()
}
/**
* Register a session created externally (e.g., worktree sessions from AgentManagerProvider).
* Sets currentSession, adds to trackedSessionIds, and notifies the webview.
*/
public registerSession(session: Session): void {
this.currentSession = session
this.contextSessionID = session.id
this.trackedSessionIds.add(session.id)
this.postMessage({
type: "sessionCreated",
session: this.sessionToWebview(session),
})
}
/**
* Add a session ID to the tracked set without changing currentSession.
* Used to re-register worktree sessions after clearSession wipes the set.
*/
public trackSession(sessionId: string): void {
this.trackedSessionIds.add(sessionId)
}
/**
* Register a directory override for a session (e.g., worktree path).
* When set, all operations for this session use this directory instead of the workspace root.
*/
public setSessionDirectory(sessionId: string, directory: string): void {
this.sessionDirectories.set(sessionId, directory)
}
public clearSessionDirectory(sessionId: string): void {
this.sessionDirectories.delete(sessionId)
}
/** Exposes the session→directory map so callers outside the webview can resolve worktree paths. */
public getSessionDirectories(): ReadonlyMap<string, string> {
return this.sessionDirectories
}
/** Return the currently active session ID, if any. */
public getCurrentSessionId(): string | undefined {
return this.currentSession?.id ?? undefined
}
/**
* Re-fetch and send the full session list to the webview.
* Called by AgentManagerProvider after worktree recovery completes.
*/
public refreshSessions(): void {
void this.handleLoadSessions()
}
public openCloudSession(sessionId: string): void {
this.postMessage({ type: "openCloudSession", sessionId })
}
/** Register the handler for "Continue in Worktree" messages from the sidebar. */
public setContinueInWorktreeHandler(
handler: (sessionId: string, progress: (status: string, detail?: string, error?: string) => void) => Promise<void>,
): void {
this.continueInWorktreeHandler = handler
}
/**
* Attach to a webview that already has its own HTML set.
* Sets up message handling and connection without overriding HTML content.
*
* @param options.onBeforeMessage - Optional interceptor called before the standard handler.
* Return null to consume the message (stop propagation), or return the message
* (possibly transformed) to continue with standard handling.
*/
public attachToWebview(
webview: vscode.Webview,
options?: { onBeforeMessage?: (msg: Record<string, unknown>) => Promise<Record<string, unknown> | null> },
): void {
this.isWebviewReady = false
this.webview = webview
this.onBeforeMessage = options?.onBeforeMessage ?? null
this.setupWebviewMessageHandler(webview)
this.initializeConnection()
}
/**
* Set up the shared message handler for both sidebar and tab webviews.
* Handles ALL message types so tabs have full functionality.
*/
private setupWebviewMessageHandler(webview: vscode.Webview): void {
this.webviewMessageDisposable?.dispose()
this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => {
// Run interceptor if attached (e.g., AgentManagerProvider worktree logic)
if (this.onBeforeMessage) {
try {
const result = await this.onBeforeMessage(message)
if (result === null) return // consumed by interceptor
message = result
} catch (error) {
console.error("[Kilo New] KiloProvider: interceptor error:", error)
return
}
}
switch (message.type) {
case "webviewReady":
console.log("[Kilo New] KiloProvider: ✅ webviewReady received")
this.isWebviewReady = true
await this.syncWebviewState("webviewReady")
this.flushPendingReviewComments()
this.readyResolvers.splice(0).forEach((r) => r())
break
case "sendMessage": {
const files = z
.array(
z.object({
mime: z.string(),
url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")),
filename: z.string().optional(),
}),
)
.optional()
.catch(undefined)
.parse(message.files)
await this.handleSendMessage(
message.text,
typeof message.messageID === "string" ? message.messageID : undefined,
message.sessionID,
typeof message.draftID === "string" ? message.draftID : undefined,
message.providerID,
message.modelID,
message.agent,
message.variant,
files,
)
break
}
case "sendCommand": {
const files = z
.array(
z.object({
mime: z.string(),
url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")),
filename: z.string().optional(),
}),
)
.optional()
.catch(undefined)
.parse(message.files)
await this.handleSendCommand(
message.command,
message.arguments,
typeof message.messageID === "string" ? message.messageID : undefined,
message.sessionID,
typeof message.draftID === "string" ? message.draftID : undefined,
message.providerID,
message.modelID,
message.agent,
message.variant,
files,
)
break
}
case "abort":
this.cancelRetry(message.sessionID ?? "")
await this.handleAbort(message.sessionID)
break
case "revertSession":
this.handleRevertSession(message.sessionID, message.messageID).catch((e) =>
console.error("[Kilo New] handleRevertSession failed:", e),
)
break
case "unrevertSession":
this.handleUnrevertSession(message.sessionID).catch((e) =>
console.error("[Kilo New] handleUnrevertSession failed:", e),
)
break
case "permissionResponse":
await handlePermissionResponse(
this.permissionCtx,
message.permissionId,
message.sessionID,
message.response,
message.approvedAlways,
message.deniedAlways,
)
break
case "createSession":
await this.handleCreateSession()
break
case "clearSession":
this.contextSessionID = this.currentSession?.id ?? this.contextSessionID
this.currentSession = null
break
case "loadMessages":
// Don't await: allow parallel loads so rapid session switching
// isn't blocked by slow responses for earlier sessions.
void this.handleLoadMessages(message.sessionID)
break
case "syncSession":
this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) =>
console.error("[Kilo New] handleSyncSession failed:", e),
)
break
case "loadSessions":
this.handleLoadSessions().catch((e) => console.error("[Kilo New] handleLoadSessions failed:", e))
break
case "login": {
const attempt = ++this.loginAttempt
await handleLogin(this.authCtx, attempt, () => this.loginAttempt)
break
}
case "cancelLogin":
this.loginAttempt++
this.postMessage({ type: "deviceAuthCancelled" })
break
case "logout":
await handleLogout(this.authCtx)
break
case "setOrganization":
if (typeof message.organizationId === "string" || message.organizationId === null) {
await handleSetOrganization(this.authCtx, message.organizationId)
}
break
case "refreshProfile":
await handleRefreshProfile(this.authCtx)
break
case "openExternal":
if (message.url) {
vscode.env.openExternal(vscode.Uri.parse(message.url))
}
break
case "openSettingsPanel":
vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", message.tab)
break
case "openVSCodeSettings":
vscode.commands.executeCommand("workbench.action.openSettings", message.query)
break
case "openMarketplacePanel":
vscode.commands.executeCommand("kilo-code.new.marketplaceButtonClicked", this.projectDirectory)
break
case "openChanges":
vscode.commands.executeCommand("kilo-code.new.showChanges")
break
case "continueInWorktree":
if (message.sessionId && this.continueInWorktreeHandler) {
this.continueInWorktreeHandler(message.sessionId, (status: string, detail?: string, error?: string) => {
this.postMessage({ type: "continueInWorktreeProgress", status, detail, error })
}).catch((err: unknown) => {
console.error("[Kilo New] continueInWorktree failed:", err)
this.postMessage({
type: "continueInWorktreeProgress",
status: "error",
error: err instanceof Error ? err.message : String(err),
})
})
} else if (message.sessionId) {
console.error("[Kilo New] continueInWorktree: no handler registered")
this.postMessage({
type: "continueInWorktreeProgress",
status: "error",
error: "Continue in Worktree is not available",
})
}
break
case "retryConnection":
console.log("[Kilo New] KiloProvider: 🔄 Retrying connection...")
this.initializeConnection().catch((e) =>
console.error("[Kilo New] KiloProvider: ❌ Retry connection failed:", e),
)
break
case "openSubAgentViewer":
vscode.commands.executeCommand("kilo-code.new.openSubAgentViewer", message.sessionID, message.title)
break
case "previewImage":
this.handlePreviewImage(message.dataUrl, message.filename)
break
case "openFile":
if (message.filePath) {
this.handleOpenFile(message.filePath, message.line, message.column)
}
break
case "requestProviders":
this.fetchAndSendProviders().catch((e) => console.error("[Kilo New] fetchAndSendProviders failed:", e))
break
case "connectProvider":
case "authorizeProviderOAuth":
case "completeProviderOAuth":
case "disconnectProvider":
case "saveCustomProvider":
await this.handleProviderAction(message)
break
case "fetchCustomProviderModels":
this.handleFetchCustomProviderModels(message).catch((e) =>
console.error("[Kilo New] fetchCustomProviderModels failed:", e),
)
break
case "compact":
await this.handleCompact(message.sessionID, message.providerID, message.modelID)
break
case "requestAgents":
this.fetchAndSendAgents().catch((e) => console.error("[Kilo New] fetchAndSendAgents failed:", e))
break
case "requestSkills":
this.fetchAndSendSkills().catch((e) => console.error("[Kilo New] fetchAndSendSkills failed:", e))
break
case "requestCommands":
this.fetchAndSendCommands().catch((e) => console.error("[Kilo New] fetchAndSendCommands failed:", e))
break
case "removeSkill":
this.removeSkillViaCli(message.location).catch((e: unknown) =>
console.error("[Kilo New] removeSkill failed:", e),
)
break
case "removeMode":
this.handleRemoveMode(message.name).catch((e) => console.error("[Kilo New] handleRemoveMode failed:", e))
break
case "removeMcp":
this.handleRemoveMcp(message.name).catch((e) => console.error("[Kilo New] handleRemoveMcp failed:", e))
break
case "requestMcpStatus":
this.fetchAndSendMcpStatus().catch((e) => console.error("[Kilo New] fetchAndSendMcpStatus failed:", e))
break
case "connectMcp":
this.handleConnectMcp(message.name).catch((e) => console.error("[Kilo New] handleConnectMcp failed:", e))
break
case "disconnectMcp":
this.handleDisconnectMcp(message.name).catch((e) =>
console.error("[Kilo New] handleDisconnectMcp failed:", e),
)
break
case "questionReply":
this.noteFollowup(message.answers, message.sessionID)
if (!(await handleQuestionReply(this.questionCtx, message.requestID, message.answers, message.sessionID))) {
this.pendingFollowup = null
}
break
case "questionReject":
this.pendingFollowup = null
await handleQuestionReject(this.questionCtx, message.requestID, message.sessionID)
break
case "requestConfig":
this.fetchAndSendConfig().catch((e) => console.error("[Kilo New] fetchAndSendConfig failed:", e))
break
case "requestGlobalConfig":
this.fetchAndSendGlobalConfig().catch((e) => console.error("[Kilo New] fetchAndSendGlobalConfig failed:", e))
break
case "updateConfig":
await this.handleUpdateConfig(message.config)
break
case "setLanguage":
await vscode.workspace
.getConfiguration("kilo-code.new")
.update("language", message.locale || undefined, vscode.ConfigurationTarget.Global)
this.connectionService.notifyLanguageChanged(message.locale as string)
break
case "requestAutocompleteSettings":
this.sendAutocompleteSettings()
break
case "updateAutocompleteSetting": {
const allowedKeys = new Set([
"enableAutoTrigger",
"enableSmartInlineTaskKeybinding",
"enableChatAutocomplete",
])
if (allowedKeys.has(message.key)) {
await vscode.workspace
.getConfiguration("kilo-code.new.autocomplete")
.update(message.key, message.value, vscode.ConfigurationTarget.Global)
this.sendAutocompleteSettings()
}
break
}
case "requestChatCompletion": {
if (!this.chatAutocomplete) {
this.chatAutocomplete = new ChatTextAreaAutocomplete(this.connectionService)
}
void this.chatAutocomplete.handle(
{ type: "requestChatCompletion", text: message.text, requestId: message.requestId },
{
postMessage: (msg: { type: "chatCompletionResult"; text: string; requestId: string }) =>
this.postMessage(msg),
},
)
break
}
case "requestFileSearch": {
const sdkClient = this.client
if (sdkClient) {
const dir = this.getWorkspaceDirectory(this.currentSession?.id)
const openPaths = dir ? await this.getOpenTabPaths(dir) : new Set<string>()
void sdkClient.find
.files({ query: message.query, directory: dir }, { throwOnError: true })
.then(({ data: paths }) => {
// Prioritize open files: open tabs first, then the rest
const open = paths.filter((p) => openPaths.has(p))
const rest = paths.filter((p) => !openPaths.has(p))
this.postMessage({
type: "fileSearchResult",
paths: [...open, ...rest],
dir,
requestId: message.requestId,
})
})
.catch((error: unknown) => {
console.error("[Kilo New] File search failed:", error)
this.postMessage({ type: "fileSearchResult", paths: [], dir, requestId: message.requestId })
})
} else {
this.postMessage({ type: "fileSearchResult", paths: [], dir: "", requestId: message.requestId })
}
break
}
case "chatCompletionAccepted":
this.chatAutocomplete?.telemetry.captureAcceptSuggestion(message.suggestionLength)
break
case "deleteSession":
await this.handleDeleteSession(message.sessionID)
break
case "renameSession":
await this.handleRenameSession(message.sessionID, message.title)
break
case "updateSetting":
await this.handleUpdateSetting(message.key, message.value)
break
case "requestBrowserSettings":
this.sendBrowserSettings()
break
case "requestClaudeCompatSetting":
this.sendClaudeCompatSetting()
break
case "requestNotificationSettings":
this.sendNotificationSettings()
break
case "requestTimelineSetting":
this.sendTimelineSetting()
break
case "requestNotifications":
this.fetchAndSendNotifications().catch((e) =>
console.error("[Kilo New] fetchAndSendNotifications failed:", e),
)
break
case "requestCloudSessions":
await handleRequestCloudSessions(this.cloudSessionCtx, message)
break
case "requestGitRemoteUrl":
void this.getGitRemoteUrl().then((url) => {
this.postMessage({ type: "gitRemoteUrlLoaded", gitUrl: url ?? null })
})
break
case "requestCloudSessionData":
void handleRequestCloudSessionData(this.cloudSessionCtx, message.sessionId)
break
case "importAndSend": {
const files = z
.array(
z.object({
mime: z.string(),
url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")),
}),
)
.optional()
.catch(undefined)
.parse(message.files)
void handleImportAndSend(
this.cloudSessionCtx,
message.cloudSessionId,
message.text,
typeof message.messageID === "string" ? message.messageID : undefined,
message.providerID,
message.modelID,
message.agent,
message.variant,
files,
typeof message.command === "string" ? message.command : undefined,
typeof message.commandArgs === "string" ? message.commandArgs : undefined,
)
break
}
case "dismissNotification":
await this.handleDismissNotification(message.notificationId)
break
case "resetAllSettings":
await this.handleResetAllSettings()
break
case "telemetry":
TelemetryProxy.capture(message.event, message.properties)
break
case "persistVariant": {
const stored = this.extensionContext?.globalState.get<Record<string, string>>("variantSelections") ?? {}
stored[message.key] = message.value
await this.extensionContext?.globalState.update("variantSelections", stored)
break
}
case "requestVariants": {
const variants = this.extensionContext?.globalState.get<Record<string, string>>("variantSelections") ?? {}
this.postMessage({ type: "variantsLoaded", variants })
break
}
case "persistRecents":
await this.extensionContext?.globalState.update("recentModels", validateRecents(message.recents))
break
case "requestRecents": {
const recents = validateRecents(this.extensionContext?.globalState.get("recentModels"))
this.postMessage({ type: "recentsLoaded", recents })
break
}
case "toggleFavorite": {
const current = validateFavorites(this.extensionContext?.globalState.get("favoriteModels"))
const key = `${message.providerID}/${message.modelID}`
const exists = current.some((f) => `${f.providerID}/${f.modelID}` === key)
const favorites =
message.action === "add" && !exists
? [...current, { providerID: message.providerID, modelID: message.modelID }]
: message.action === "remove" && exists
? current.filter((f) => `${f.providerID}/${f.modelID}` !== key)
: current
await this.extensionContext?.globalState.update("favoriteModels", favorites)
this.connectionService.notifyFavoritesChanged(favorites)
break
}
case "requestFavorites": {
const favorites = validateFavorites(this.extensionContext?.globalState.get("favoriteModels"))
this.postMessage({ type: "favoritesLoaded", favorites })
break
}
// legacy-migration start
case "requestLegacyMigrationData":
void handleRequestLegacyMigrationData(this.migrationCtx)
break
case "startLegacyMigration":
void handleStartLegacyMigration(this.migrationCtx, message.selections)
break
case "skipLegacyMigration":
void handleSkipLegacyMigration(this.migrationCtx)
break
case "clearLegacyData":
void handleClearLegacyData(this.migrationCtx)
break
case "finalizeLegacyMigration":
void handleFinalizeLegacyMigration(this.migrationCtx)
break
// legacy-migration end
case "enhancePrompt": {
const sdkClient = this.client
if (!sdkClient) {
this.postMessage({
type: "enhancePromptError",
error: "Not connected to CLI backend",
requestId: message.requestId,
})
break
}
void sdkClient.enhancePrompt
.enhance({ text: message.text }, { throwOnError: true })
.then(({ data }) => {
this.postMessage({ type: "enhancePromptResult", text: data.text, requestId: message.requestId })
})
.catch((err: unknown) => {
const msg = getErrorMessage(err) || "Failed to enhance prompt"
console.error("[Kilo New] KiloProvider: Failed to enhance prompt:", err)
vscode.window.showErrorMessage(`Enhance prompt failed: ${msg}`)
this.postMessage({
type: "enhancePromptError",
error: msg,
requestId: message.requestId,
})
})
break
}
case "fetchMarketplaceData": {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
// Fetch skills from CLI backend (authoritative source) so the
// marketplace doesn't need to duplicate the CLI's skill scanning.
const skills = await this.fetchCliSkills()
const data = await mp.fetchData(workspace, skills)
this.postMessage({ type: "marketplaceData", ...data })
break
}
case "filterMarketplaceItems": {
// Client-side filtering — no server action needed
break
}
case "installMarketplaceItem": {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const scope = message.mpInstallOptions?.target ?? "project"
const result = await this.getMarketplace().install(message.mpItem, message.mpInstallOptions, workspace)
if (result.success) {
await this.invalidateAfterMarketplaceChange(scope)
}
this.postMessage({
type: "marketplaceInstallResult",
success: result.success,
slug: result.slug,
error: result.error,
})
break
}
case "removeInstalledMarketplaceItem": {
const scope = message.mpInstallOptions?.target ?? "project"
const result = await this.removeMarketplaceItem(message.mpItem, scope)
this.postMessage({
type: "marketplaceRemoveResult",
success: result.success,
slug: result.slug,
error: result.error,
})
break
}
}
})
}
/**
* Initialize connection to the CLI backend server.
* Subscribes to the shared KiloConnectionService.
*/
private initializeConnection(): Promise<void> {
if (this.initConnectionPromise) {
return this.initConnectionPromise
}
this.initConnectionPromise = this.doInitializeConnection().finally(() => {
this.initConnectionPromise = null
})
return this.initConnectionPromise
}
private async doInitializeConnection(): Promise<void> {
console.log("[Kilo New] KiloProvider: 🔧 Starting initializeConnection...")
this.connectionState = "connecting"
this.postMessage({ type: "connectionState", state: "connecting" })
// Clean up any existing subscriptions (e.g., sidebar re-shown)
this.unsubscribeEvent?.()
this.unsubscribeState?.()
this.unsubscribeNotificationDismiss?.()
this.unsubscribeLanguageChange?.()
this.unsubscribeProfileChange?.()
this.unsubscribeFavoritesChange?.()
this.unsubscribeClearPendingPrompts?.()
this.unsubscribeDirectoryProvider?.()
try {
const workspaceDir = this.getWorkspaceDirectory()
// Connect the shared service (no-op if already connected)
await this.connectionService.connect(workspaceDir)
// Subscribe to SSE events for this webview (filtered by tracked sessions)
this.unsubscribeEvent = this.connectionService.onEventFiltered(
(event) => {
const sessionId = this.connectionService.resolveEventSessionId(event)
// message.part.updated and message.part.delta are always session-scoped; drop if session unknown.
if (!sessionId) {
return event.type !== "message.part.updated" && event.type !== "message.part.delta"
}
if (event.type === "session.created" && this.matchesPendingFollowup(event.properties.info)) {
return true
}
// session.status must always pass through — even for sessions not tracked by this
// KiloProvider instance. The Settings panel is a separate provider with no tracked
// sessions, but it needs session.status to populate sessionStatusMap and allStatusMap
// for the busy-session warning on Save.
if (event.type === "session.status") return true
return this.trackedSessionIds.has(sessionId)
},
(event) => {
this.handleEvent(event)
},
)
// Subscribe to connection state changes
this.unsubscribeState = this.connectionService.onStateChange(async (state) => {
this.connectionState = state
this.postMessage({ type: "connectionState", state })
if (state === "connected") {
// Fire config warnings independently so a failure in the
// sequential await chain doesn't prevent warnings from being shown
void this.checkConfigWarnings("state")
try {
// Profile fetch is best-effort — returns 401 when user isn't logged into gateway.
const sdkClient = this.client
if (sdkClient) {
const profileResult = await sdkClient.kilo.profile()
this.postMessage({ type: "profileData", data: profileResult.data ?? null })
}
await this.syncWebviewState("sse-connected")
await this.flushPendingSessionRefresh("sse-connected")
await fetchAndSendPendingPermissions(this.permissionCtx)
await fetchAndSendPendingQuestions(this.questionCtx)
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed during connected state handling:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to sync after connecting",
})
}
}
})
// Subscribe to notification dismiss broadcast from other KiloProvider instances
this.unsubscribeNotificationDismiss = this.connectionService.onNotificationDismissed(() => {
this.fetchAndSendNotifications()
})
// Subscribe to language change broadcast from other KiloProvider instances
this.unsubscribeLanguageChange = this.connectionService.onLanguageChanged((locale) => {
this.postMessage({ type: "languageChanged", locale })
})
// Subscribe to profile change broadcast from other KiloProvider instances
this.unsubscribeProfileChange = this.connectionService.onProfileChanged((data) => {
this.postMessage({ type: "profileData", data })
})
// Subscribe to favorites change broadcast from other KiloProvider instances
this.unsubscribeFavoritesChange = this.connectionService.onFavoritesChanged((favorites) => {
this.postMessage({ type: "favoritesLoaded", favorites })
})
// legacy-migration start
// Subscribe to migration-complete broadcast from any KiloProvider instance
this.unsubscribeMigrationComplete = this.connectionService.onMigrationComplete(() => {
this.postMessage({ type: "migrationState", needed: false })
})
// legacy-migration end
// Subscribe to clear-pending-prompts broadcast (fired after config save drains prompts)
this.unsubscribeClearPendingPrompts = this.connectionService.onClearPendingPrompts(() => {
this.postMessage({ type: "clearPendingPrompts" })
})
// Register this provider's directories so drainPendingPrompts() covers all instances
this.unsubscribeDirectoryProvider = this.connectionService.registerDirectoryProvider(() => {
return [this.getWorkspaceDirectory(), ...this.sessionDirectories.values()]
})
// Get current state and push to webview
const serverInfo = this.connectionService.getServerInfo()
this.connectionState = this.connectionService.getConnectionState()
if (serverInfo) {
const langConfig = vscode.workspace.getConfiguration("kilo-code.new")
this.postMessage({
type: "ready",
serverInfo,
extensionVersion: this.extensionVersion,
vscodeLanguage: vscode.env.language,
languageOverride: langConfig.get<string>("language"),
workspaceDirectory: this.getProjectDirectory(this.currentSession?.id),
})
}
this.postMessage({ type: "connectionState", state: this.connectionState })
// connect() can resolve after SSE reaches "connected" but before this
// provider subscribes to onStateChange(). In that case the initial
// connected callback is missed, so run the warning check here too.
if (this.connectionState === "connected") {
void this.checkConfigWarnings("init")
}
await this.syncWebviewState("initializeConnection")
await this.flushPendingSessionRefresh("initializeConnection")
// Fetch providers, agents, skills, config, notifications, and session statuses in parallel
await Promise.all([
this.fetchAndSendProviders(),
this.fetchAndSendAgents(),
this.fetchAndSendSkills(),
this.fetchAndSendCommands(),
this.fetchAndSendConfig(),
this.fetchAndSendNotifications(),
this.seedSessionStatusMap(),
])
this.sendNotificationSettings()
this.sendTimelineSetting()
this.postMessage({ type: "extensionDataReady" })
// Start polling worktree diff stats for the sidebar badge
this.startStatsPolling()
console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully")
} catch (error) {
console.error("[Kilo New] KiloProvider: ❌ Failed to initialize connection:", error)
this.connectionState = "error"
this.postMessage({
type: "connectionState",
state: "error",
error: getErrorMessage(error) || "Failed to connect to CLI backend",
...(error instanceof ServerStartupError && {
userMessage: error.userMessage,
userDetails: error.userDetails,
}),
})
}
}
private sessionToWebview(session: Session) {
return sessionToWebview(session)
}
/**
* Handle creating a new session.
*/
private async handleCreateSession(): Promise<void> {
if (!this.client) {
this.postMessage({
type: "error",
message: "Not connected to CLI backend",
})
return
}
try {
const workspaceDir = this.getContextDirectory()
const { data: session } = await this.client.session.create({ directory: workspaceDir }, { throwOnError: true })
this.currentSession = session
this.contextSessionID = session.id
this.trackDirectory(session.id, workspaceDir)
this.trackedSessionIds.add(session.id)
// Notify webview of the new session
this.postMessage({
type: "sessionCreated",
session: this.sessionToWebview(this.currentSession!),
})
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to create session:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to create session",
})
}
}
/**
* Handle loading messages for a session.
*/
private async handleLoadMessages(sessionID: string): Promise<void> {
// Track the session so we receive its SSE events
this.trackedSessionIds.add(sessionID)
this.contextSessionID = sessionID
if (!this.client) {
this.postMessage({
type: "error",
message: "Not connected to CLI backend",
sessionID,
})
return
}
// Abort any previous in-flight loadMessages request so the backend
// isn't overwhelmed when the user switches sessions rapidly.
this.loadMessagesAbort?.abort()
const abort = new AbortController()
this.loadMessagesAbort = abort
try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
const { data: messagesData } = await retry(() =>
this.client!.session.messages(
{ sessionID, directory: workspaceDir },
{ throwOnError: true, signal: abort.signal },
),
)
// If this request was aborted while awaiting, skip posting stale results
if (abort.signal.aborted) return
// Update currentSession so fallback logic in handleSendMessage/handleAbort
// references the correct session after switching. loadMessages is the
// canonical "user switched to this session" signal, so always update —
// the old guard `this.currentSession.id === sessionID` prevented updates
// when switching between different sessions.
// Non-blocking: don't let a failure here prevent messages from loading.
// 404s are expected for cross-worktree sessions — use silent to suppress HTTP error logs.
this.client.session
.get({ sessionID, directory: workspaceDir })
.then((result) => {
if (result.data && !abort.signal.aborted) {
this.currentSession = result.data
this.contextSessionID = result.data.id
}
})
.catch((err: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", err))
this.postMessage({
type: "workspaceDirectoryChanged",
directory: this.getWorkspaceDirectory(sessionID),
})
// Fetch current session status so the webview has the correct busy/idle
// state after switching tabs (SSE events may have been missed).
this.client.session
.status({ directory: workspaceDir })
.then((result) => {
if (!result.data) return
for (const [sid, info] of Object.entries(result.data) as [string, SessionStatus][]) {
if (!this.trackedSessionIds.has(sid)) continue
this.postMessage({
type: "sessionStatus",
sessionID: sid,
status: info.type,
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
})
}
})
.catch((err: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", err))
const messages = messagesData.map((m) => ({
...m.info,
parts: this.slimParts(m.parts),
createdAt: new Date(m.info.time.created).toISOString(),
}))
for (const message of messages) {
this.connectionService.recordMessageSessionId(message.id, message.sessionID)
}
this.postMessage({
type: "messagesLoaded",
sessionID,
messages,
})
// Recover any permission.asked events that were missed while the webview
// was loading or during an SSE reconnection (fire-and-forget).
void fetchAndSendPendingPermissions(this.permissionCtx)
} catch (error) {
// Silently ignore aborted requests — the user switched to a different session
if (abort.signal.aborted) return
console.error("[Kilo New] KiloProvider: Failed to load messages:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to load messages",
sessionID,
})
}
}
/**
* Handle syncing a child session (e.g. spawned by the task tool).
* Tracks the session for SSE events and fetches its messages.
*/
private async handleSyncSession(sessionID: string, parentSessionID?: string): Promise<void> {
if (!this.client) return
if (this.syncedChildSessions.has(sessionID)) return
this.syncedChildSessions.add(sessionID)
this.trackedSessionIds.add(sessionID)
// Inherit the parent's worktree directory so permission responses use
// the correct backend Instance. Without this, child sessions in Agent
// Manager worktrees fall back to workspace root and fail to find the
// pending permission request.
if (!this.sessionDirectories.has(sessionID) && parentSessionID) {
const dir = this.sessionDirectories.get(parentSessionID)
if (dir) {
this.sessionDirectories.set(sessionID, dir)
}
}
try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
const { data: messagesData } = await retry(() =>
this.client!.session.messages({ sessionID, directory: workspaceDir }, { throwOnError: true }),
)
const messages = messagesData.map((m) => ({
...m.info,
parts: this.slimParts(m.parts),
createdAt: new Date(m.info.time.created).toISOString(),
}))
for (const message of messages) {
this.connectionService.recordMessageSessionId(message.id, message.sessionID)
}
this.postMessage({
type: "messagesLoaded",
sessionID,
messages,
})
// Recover any missed permission/question prompts emitted by the child before
// we started tracking it. Both run fire-and-forget after messagesLoaded so
// the webview isn't blocked.
void fetchAndSendPendingPermissions(this.permissionCtx)
void fetchAndSendPendingQuestions(this.questionCtx)
} catch (err) {
this.syncedChildSessions.delete(sessionID)
console.error("[Kilo New] KiloProvider: Failed to sync child session:", err)
}
}
/**
* Build the context object used by the extracted session-refresh helpers.
*/
private get sessionRefreshContext(): SessionRefreshContext {
const client = this.client
return {
pendingSessionRefresh: this.pendingSessionRefresh,
connectionState: this.connectionState,
listSessions: client
? (dir: string) =>
client.session.list({ directory: dir, roots: true }, { throwOnError: true }).then(({ data }) => data)
: null,
sessionDirectories: this.sessionDirectories,
workspaceDirectory: this.getWorkspaceDirectory(),
postMessage: (msg: unknown) => this.postMessage(msg),
}
}
/**
* Retry a deferred sessions refresh once the client is ready.
*/
private async flushPendingSessionRefresh(reason: string): Promise<void> {
if (!this.pendingSessionRefresh) return
console.log("[Kilo New] KiloProvider: 🔄 Flushing deferred sessions refresh", { reason })
const ctx = this.sessionRefreshContext
try {
const resolved = await flushPendingSessionRefreshUtil(ctx)
if (resolved) this.projectID = resolved
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to flush session refresh:", error)
}
this.pendingSessionRefresh = ctx.pendingSessionRefresh
}
/**
* Handle loading all sessions.
*/
private async handleLoadSessions(): Promise<void> {
const ctx = this.sessionRefreshContext
try {
const resolved = await loadSessionsUtil(ctx)
if (resolved) this.projectID = resolved
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to load sessions:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to load sessions",
})
}
this.pendingSessionRefresh = ctx.pendingSessionRefresh
}
/**
* Handle deleting a session.
*/
private async handleDeleteSession(sessionID: string): Promise<void> {
if (!this.client) {
this.postMessage({ type: "error", message: "Not connected to CLI backend" })
return
}
try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
await this.client.session.delete({ sessionID, directory: workspaceDir }, { throwOnError: true })
this.trackedSessionIds.delete(sessionID)
this.syncedChildSessions.delete(sessionID)
this.sessionDirectories.delete(sessionID)
if (this.currentSession?.id === sessionID) {
this.currentSession = null
}
this.postMessage({ type: "sessionDeleted", sessionID })
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to delete session:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to delete session",
})
}
}
/**
* Handle renaming a session.
*/
private async handleRenameSession(sessionID: string, title: string): Promise<void> {
if (!this.client) {
this.postMessage({ type: "error", message: "Not connected to CLI backend" })
return
}
try {
const workspaceDir = this.getWorkspaceDirectory(sessionID)
const { data: updated } = await this.client.session.update(
{ sessionID, directory: workspaceDir, title },
{ throwOnError: true },
)
if (this.currentSession?.id === sessionID) {
this.currentSession = updated
}
this.postMessage({ type: "sessionUpdated", session: this.sessionToWebview(updated) })
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to rename session:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to rename session",
})
}
}
/** Fetch providers and send to webview. Coalesced: at most one in-flight + one queued. */
private async fetchAndSendProviders(): Promise<void> {
const next = ++this.providersGeneration
if (this.providersRefresh) {
this.providersQueued = true
await this.providersRefresh
return
}
const task = (async () => {
let generation = next
while (true) {
this.providersQueued = false
const client = this.client
if (!client) {
if (this.cachedProvidersMessage && generation === this.providersGeneration)
this.postMessage(this.cachedProvidersMessage)
return
}
try {
const { response, authMethods, authStates } = await fetchProviderData(client, this.getWorkspaceDirectory())
if (generation !== this.providersGeneration || client !== this.client) {
if (!this.providersQueued) return
generation = this.providersGeneration
continue
}
const settings = vscode.workspace.getConfiguration("kilo-code.new.model")
const message = {
type: "providersLoaded",
providers: indexProvidersById(response.all),
connected: response.connected,
defaults: response.default,
defaultSelection: computeDefaultSelection(
this.cachedConfigMessage as { config?: { model?: string } } | null,
settings.get<string>("providerID", ""),
settings.get<string>("modelID", ""),
),
authMethods,
authStates,
}
this.cachedProvidersMessage = message
this.postMessage(message)
} catch (error) {
if (generation !== this.providersGeneration) {
if (!this.providersQueued) return
generation = this.providersGeneration
continue
}
console.error("[Kilo New] KiloProvider: Failed to fetch providers:", error)
}
if (!this.providersQueued) return
generation = this.providersGeneration
}
})()
const done = task.finally(() => {
if (this.providersRefresh === done) this.providersRefresh = null
})
this.providersRefresh = done
await done
}
private async handleProviderAction(msg: Record<string, unknown>): Promise<void> {
const rid = typeof msg.requestId === "string" ? msg.requestId : ""
const pid = typeof msg.providerID === "string" ? msg.providerID : ""
if (!rid || !pid) return
if (!this.client) {
const action =
msg.type === "disconnectProvider"
? "disconnect"
: msg.type === "authorizeProviderOAuth"
? "authorize"
: "connect"
this.postMessage({
type: "providerActionError",
requestId: rid,
providerID: pid,
action,
message: "Not connected to CLI backend",
})
return
}
const ctx = buildActionContext(
this.client,
(m) => this.postMessage(m),
getErrorMessage,
this.getWorkspaceDirectory(),
() => this.fetchAndSendProviders(),
)
const set = (m: unknown) => {
this.cachedConfigMessage = m
}
const method = typeof msg.method === "number" ? msg.method : 0
const key = typeof msg.apiKey === "string" ? msg.apiKey : undefined
const keyChanged = msg.apiKeyChanged === true
const code = typeof msg.code === "string" ? msg.code : undefined
const config = msg.config && typeof msg.config === "object" ? (msg.config as Record<string, unknown>) : undefined
if (msg.type === "connectProvider" && key) return connectProviderAction(ctx, rid, pid, key)
if (msg.type === "authorizeProviderOAuth") return authorizeOAuthAction(ctx, rid, pid, method)
if (msg.type === "completeProviderOAuth") return completeOAuthAction(ctx, rid, pid, method, code)
if (msg.type === "disconnectProvider") return disconnectProviderAction(ctx, rid, pid, this.cachedConfigMessage, set)
if (msg.type === "saveCustomProvider" && config)
return saveCustomProviderAction(ctx, rid, pid, config, key, keyChanged, this.cachedConfigMessage, set)
}
private async handleFetchCustomProviderModels(msg: Record<string, unknown>): Promise<void> {
const rid = typeof msg.requestId === "string" ? msg.requestId : ""
const url = typeof msg.baseURL === "string" ? msg.baseURL : ""
if (!rid || !url) return
const key = typeof msg.apiKey === "string" ? msg.apiKey : undefined
const headers = msg.headers && typeof msg.headers === "object" ? (msg.headers as Record<string, string>) : undefined
try {
const models = await fetchOpenAIModels({ baseURL: url, apiKey: key, headers })
this.postMessage({ type: "customProviderModelsFetched", requestId: rid, models })
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to fetch models"
const auth = err instanceof FetchModelsError && err.auth
this.postMessage({ type: "customProviderModelsFetched", requestId: rid, error: message, auth })
}
}
/**
* Fetch agents (modes) from the backend and send to webview.
*/
private async fetchAndSendAgents(): Promise<void> {
if (!this.client) {
if (this.cachedAgentsMessage) {
this.postMessage(this.cachedAgentsMessage)
}
return
}
try {
const workspaceDir = this.getWorkspaceDirectory()
const { data: agents } = await retry(() =>
this.client!.app.agents({ directory: workspaceDir }, { throwOnError: true }),
)
const { visible, defaultAgent } = filterVisibleAgents(agents)
const message = {
type: "agentsLoaded",
agents: visible.map(mapAgent),
allAgents: agents.map(mapAgent),
defaultAgent,
}
this.cachedAgentsMessage = message
this.postMessage(message)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch agents:", error)
}
}
private async fetchAndSendSkills(): Promise<void> {
if (!this.client) {
if (this.cachedSkillsMessage) {
this.postMessage(this.cachedSkillsMessage)
}
return
}
try {
const workspaceDir = this.getWorkspaceDirectory()
const { data: skills } = await retry(() =>
this.client!.app.skills({ directory: workspaceDir }, { throwOnError: true }),
)
const message = {
type: "skillsLoaded",
skills,
}
this.cachedSkillsMessage = message
this.postMessage(message)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch skills:", error)
}
}
private async fetchAndSendCommands(): Promise<void> {
if (!this.client) {
if (this.cachedCommandsMessage) {
this.postMessage(this.cachedCommandsMessage)
}
return
}
try {
const dir = this.getWorkspaceDirectory()
const { data: commands } = await retry(() =>
this.client!.command.list({ directory: dir }, { throwOnError: true }),
)
const message = {
type: "commandsLoaded",
commands: commands.map((c) => ({
name: c.name,
description: c.description,
source: c.source,
hints: c.hints,
})),
}
this.cachedCommandsMessage = message
this.postMessage(message)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch commands:", error)
}
}
private async fetchCliSkills(): Promise<Array<{ name: string; location: string }> | undefined> {
if (!this.client) return undefined
try {
const dir = this.getWorkspaceDirectory()
const { data } = await retry(() => this.client!.app.skills({ directory: dir }, { throwOnError: true }))
return data
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch CLI skills for marketplace:", error)
return undefined
}
}
/**
* Remove a skill via the CLI backend (deletes from disk + clears cache), then refresh.
* Returns true on success, false on failure.
* On failure, re-fetches skills so the webview reverts to the authoritative state.
*/
private async removeSkillViaCli(location: string): Promise<boolean> {
if (!this.client) return false
try {
const dir = this.getWorkspaceDirectory()
const result = await this.client.kilocode.removeSkill({ location, directory: dir })
if (result.error) {
console.error("[Kilo New] removeSkill returned error:", result.error)
this.cachedSkillsMessage = null
this.cachedCommandsMessage = null
await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()])
return false
}
} catch (error) {
console.error("[Kilo New] Failed to remove skill:", error)
this.cachedSkillsMessage = null
this.cachedCommandsMessage = null
await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()])
return false
}
this.cachedSkillsMessage = null
this.cachedCommandsMessage = null
await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()])
return true
}
/**
* Remove a custom mode via the CLI backend (deletes from disk + refreshes state).
* The webview optimistically removes the mode from its list before this runs.
* On failure, re-fetches agents so the webview reverts to the authoritative state.
*/
private async handleRemoveMode(name: string): Promise<void> {
if (!this.client) return
// 1. Try CLI removal (handles .md files and legacy .kilocodemodes)
try {
const dir = this.getWorkspaceDirectory()
const result = await this.client.kilocode.removeAgent({ name, directory: dir })
if (!result.error) {
this.cachedAgentsMessage = null
await this.fetchAndSendAgents()
return
}
} catch {
// CLI removal failed — agent may be in kilo.json instead
}
// 2. Try removing from kilo.json (handles marketplace-installed modes)
const stub = { id: name, type: "mode" as const, name, description: "", content: "" }
const removed = await this.removeMarketplaceItemFromAllScopes(stub)
if (!removed) {
console.error("[Kilo New] KiloProvider: Failed to remove mode:", name)
}
}
private async handleRemoveMcp(name: string): Promise<void> {
// Remove from legacy files first so that the subsequent invalidation
// causes the CLI to re-read config without the legacy entry.
await this.removeLegacyMcp(name)
const stub = { id: name, type: "mcp" as const, name, description: "", url: "", content: "" }
const removed = await this.removeMarketplaceItemFromAllScopes(stub)
if (!removed) {
console.error("[Kilo New] KiloProvider: Failed to remove MCP server:", name)
}
}
/**
* Remove an MCP server from legacy config files (.kilo/mcp.json, .kilocode/mcp.json,
* and the VS Code global storage mcp_settings.json). These files are read by the
* CLI-side McpMigrator and merged into config at the lowest precedence level.
* Returns true if the entry was found and removed from at least one file.
*/
private async removeLegacyMcp(name: string): Promise<boolean> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const files: vscode.Uri[] = []
// Project-level legacy files
if (workspace) {
files.push(vscode.Uri.file(path.join(workspace, ".kilo", "mcp.json")))
files.push(vscode.Uri.file(path.join(workspace, ".kilocode", "mcp.json")))
}
// Global legacy file (VS Code extension global storage)
const storage = this.extensionContext?.globalStorageUri
if (storage) {
files.push(vscode.Uri.joinPath(storage, "settings", "mcp_settings.json"))
}
let removed = false
for (const uri of files) {
const bytes = await vscode.workspace.fs.readFile(uri).then(
(b) => b,
() => null,
)
if (!bytes) continue
try {
const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as Record<string, unknown>
const servers = parsed.mcpServers as Record<string, unknown> | undefined
if (!servers?.[name]) continue
delete servers[name]
const content = Buffer.from(JSON.stringify(parsed, null, 2), "utf8")
await vscode.workspace.fs.writeFile(uri, content)
removed = true
} catch (err) {
console.warn("[Kilo New] KiloProvider: Failed to remove legacy MCP from", uri.fsPath, err)
}
}
return removed
}
private async fetchAndSendMcpStatus(): Promise<void> {
if (!this.client) {
if (this.cachedMcpStatusMessage) {
this.postMessage(this.cachedMcpStatusMessage)
}
return
}
try {
const directory = this.getWorkspaceDirectory()
const { data } = await retry(() => this.client!.mcp.status({ directory }))
if (data) {
const message = { type: "mcpStatusLoaded", status: data }
this.cachedMcpStatusMessage = message
this.postMessage(message)
}
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch MCP status:", error)
}
}
private async handleConnectMcp(name: string): Promise<void> {
if (!this.client) return
try {
const directory = this.getWorkspaceDirectory()
await this.client.mcp.connect({ name, directory })
await this.fetchAndSendMcpStatus()
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to connect MCP:", name, error)
await this.fetchAndSendMcpStatus()
}
}
private async handleDisconnectMcp(name: string): Promise<void> {
if (!this.client) return
try {
const directory = this.getWorkspaceDirectory()
await this.client.mcp.disconnect({ name, directory })
await this.fetchAndSendMcpStatus()
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to disconnect MCP:", name, error)
await this.fetchAndSendMcpStatus()
}
}
/**
* Remove a marketplace item from a single scope and invalidate CLI caches.
*/
private async removeMarketplaceItem(item: MarketplaceItem, scope: "project" | "global"): Promise<RemoveResult> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const result = await this.getMarketplace().remove(item, scope, workspace)
if (result.success) {
await this.invalidateAfterMarketplaceChange(scope)
}
return result
}
/**
* Remove a marketplace item from both project and global scopes.
* mp.remove returns success even when the entry doesn't exist (no-op),
* so we must attempt both scopes to cover dual-scope installations.
* Returns true if at least one scope removal succeeded.
*/
private async removeMarketplaceItemFromAllScopes(item: MarketplaceItem): Promise<boolean> {
const workspace = this.getProjectDirectory(this.currentSession?.id)
const mp = this.getMarketplace()
const project = await mp.remove(item, "project", workspace)
const global = await mp.remove(item, "global", workspace)
if (project.success || global.success) {
const scope = global.success ? "global" : "project"
await this.invalidateAfterMarketplaceChange(scope)
return true
}
return false
}
/**
* Invalidate CLI caches and refresh the webview after a marketplace install/remove.
*
* For global scope: uses global.config.update with the freshly-written config file
* contents rather than global.dispose. This goes through Config.updateGlobal() which
* calls Config.global.reset() to invalidate the lazy-cached global config, ensuring
* the newly installed/removed MCP entry is visible on the next config.get call.
* (global.dispose alone is not sufficient on older CLI versions that lack the
* Config.global.reset() call in the dispose handler.)
*
* For project scope: instance.dispose is sufficient because the per-instance
* Config.state is cleared and re-reads all files (including global) on next access.
*/
private async invalidateAfterMarketplaceChange(scope: "project" | "global"): Promise<void> {
if (!this.client) return
if (scope === "global") {
// Use global.config.update with an empty config to trigger Config.updateGlobal()
// which calls Config.global.reset(). This invalidates the lazy-cached global
// config in the CLI process so it re-reads kilo.json from disk.
// An empty object merge is a no-op for the file content but resets the cache.
// (global.dispose alone is insufficient on older CLI versions that lack
// the Config.global.reset() call in the dispose handler.)
await this.client.global.config.update({ config: {} }).catch((e: unknown) => {
console.warn("[Kilo New] global.config.update after marketplace change failed:", e)
})
}
// Always dispose the per-project instance so it rebuilds state from
// the (possibly updated) global + project config on the next request.
const dir = this.getWorkspaceDirectory()
await this.client.instance.dispose({ directory: dir }).catch((e: unknown) => {
console.warn("[Kilo New] instance.dispose() after marketplace change failed:", e)
})
this.cachedAgentsMessage = null
this.cachedConfigMessage = null
await Promise.all([this.fetchAndSendAgents(), this.fetchAndSendConfig()])
}
/**
* Fetch backend config and send to webview.
*/
private async fetchAndSendConfig(): Promise<void> {
if (!this.client || this.connectionState !== "connected") {
if (this.cachedConfigMessage) {
this.postMessage(this.cachedConfigMessage)
}
return
}
// Skip if handleUpdateConfig is in flight — sending a configLoaded now
// would race with the write and potentially overwrite optimistic webview state.
if (this.pending > 0) {
return
}
try {
const workspaceDir = this.getWorkspaceDirectory()
const { data: config } = await retry(() =>
this.client!.config.get({ directory: workspaceDir }, { throwOnError: true }),
)
const message = {
type: "configLoaded",
config,
}
this.cachedConfigMessage = message
this.postMessage(message)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch config:", error)
}
}
/** Fetch global-only config (no project/managed layers) for settings export. */
private async fetchAndSendGlobalConfig(): Promise<void> {
if (!this.client || this.connectionState !== "connected") return
try {
const { data: config } = await this.client.global.config.get({ throwOnError: true })
this.postMessage({ type: "globalConfigLoaded", config })
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch global config:", error)
}
}
/**
* Seed sessionStatusMap with current session statuses on connect.
* Without this, the Settings panel (which has no tracked sessions) would see
* busyCount() = 0 for sessions that were already running before it opened.
*
* @param reconcile When true, reset locally-busy sessions absent from the
* server response to idle (crash recovery). Set to false on SSE reconnects
* to avoid a race where a brief HTTP fetch gap causes the spinner to vanish.
*/
private async seedSessionStatusMap(reconcile = true): Promise<void> {
if (!this.client || this.connectionState !== "connected") return
const dir = this.getWorkspaceDirectory()
await seedSessionStatuses(this.client, dir, this.sessionStatusMap, (msg) => this.postMessage(msg), reconcile)
}
/**
* Fetch the latest merged config and push it as configUpdated.
* Called when global.config.updated SSE fires (config changed without a full dispose).
*/
private async fetchAndSendConfigUpdated(): Promise<void> {
if (!this.client || this.connectionState !== "connected") return
try {
const dir = this.getWorkspaceDirectory()
const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
this.cachedConfigMessage = { type: "configLoaded", config }
this.postMessage({ type: "configUpdated", config })
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error)
}
}
/**
* Fetch config warnings from the server and display a single consolidated
* VS Code warning with a "Show Details" action button.
* Only shown once per provider lifecycle (flag resets on dispose/re-create, not on SSE reconnect).
*/
private async checkConfigWarnings(from: string): Promise<void> {
if (this.configWarningsShown) {
console.log("[Kilo New] KiloProvider: config warnings already shown", { from })
return
}
if (!this.client) {
console.log("[Kilo New] KiloProvider: config warnings skipped (no client)", { from })
return
}
try {
const dir = this.getWorkspaceDirectory()
console.log("[Kilo New] KiloProvider: checking config warnings", { from, dir })
const result = await this.client.config.warnings({ directory: dir })
const list = result?.data ?? []
console.log("[Kilo New] KiloProvider: config warnings fetched", { from, count: list.length })
if (list.length === 0) return
this.configWarningsShown = true
const first = list[0]!
const summary = list.length === 1 ? first.message : `${first.message} (and ${list.length - 1} more)`
console.warn("[Kilo New] KiloProvider: showing config warnings", { from, count: list.length, path: first.path })
const action = await vscode.window.showWarningMessage(`Config: ${summary}`, "Show Details")
if (action === "Show Details") {
const lines = list.map((w) => {
const base = `${w.path}\n ${w.message}`
return w.detail ? `${base}\n ${w.detail}` : base
})
const channel = vscode.window.createOutputChannel("Kilo Config Warnings")
channel.clear()
channel.appendLine(lines.join("\n\n"))
channel.show()
}
} catch (err) {
console.warn("[Kilo New] KiloProvider: checkConfigWarnings failed:", { from, err })
}
}
/**
* Fetch Kilo news/notifications and send to webview.
* Uses the cached message pattern so the webview gets data immediately on refresh.
*/
private async fetchAndSendNotifications(): Promise<void> {
if (!this.client) {
if (this.cachedNotificationsMessage) {
// Merge the latest dismissed IDs from globalState into the cached
// message so that dismissals persisted while offline are honoured.
const persisted = this.extensionContext?.globalState.get<string[]>("kilo.dismissedNotificationIds", []) ?? []
if (persisted.length > 0) {
const cached = this.cachedNotificationsMessage as {
type: string
notifications: unknown[]
dismissedIds: string[]
}
const merged = Array.from(new Set([...cached.dismissedIds, ...persisted]))
this.cachedNotificationsMessage = { ...cached, dismissedIds: merged }
}
this.postMessage(this.cachedNotificationsMessage)
}
return
}
try {
const { data: all } = await retry(() => this.client!.kilo.notifications(undefined, { throwOnError: true }))
const notifications = all.filter((n) => !n.showIn || n.showIn.includes("extension"))
const existing = this.extensionContext?.globalState.get<string[]>("kilo.dismissedNotificationIds", []) ?? []
const active = new Set(notifications.map((n) => n.id))
// Only prune stale dismissed IDs when we have a non-empty notification
// list. An empty list may mean the API returned nothing due to being
// unauthenticated (e.g. right after logout), not that all notifications
// are gone — pruning in that case would wipe the persisted dismissals.
const dismissedIds = notifications.length > 0 ? existing.filter((id) => active.has(id)) : existing
if (dismissedIds.length !== existing.length) {
await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", dismissedIds)
}
const message = { type: "notificationsLoaded", notifications, dismissedIds }
this.cachedNotificationsMessage = message
this.postMessage(message)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch notifications:", error)
}
}
// Cloud session methods extracted to kilo-provider/handlers/cloud-session.ts
/**
* Persist a dismissed notification ID in globalState and push updated lists to webview.
*/
private async handleDismissNotification(notificationId: string): Promise<void> {
if (!this.extensionContext) return
const existing = this.extensionContext.globalState.get<string[]>("kilo.dismissedNotificationIds", [])
if (!existing.includes(notificationId)) {
await this.extensionContext.globalState.update("kilo.dismissedNotificationIds", [...existing, notificationId])
}
// Update the cached message so the dismiss persists even if
// fetchAndSendNotifications() fails (e.g. no client / API error).
if (this.cachedNotificationsMessage) {
const cached = this.cachedNotificationsMessage as {
type: string
notifications: unknown[]
dismissedIds: string[]
}
if (!cached.dismissedIds.includes(notificationId)) {
this.cachedNotificationsMessage = {
...cached,
dismissedIds: [...cached.dismissedIds, notificationId],
}
}
}
await this.fetchAndSendNotifications()
this.connectionService.notifyNotificationDismissed(notificationId)
}
/**
* Read notification/sound settings from VS Code config and push to webview.
*/
private sendNotificationSettings(): void {
const notifications = vscode.workspace.getConfiguration("kilo-code.new.notifications")
const sounds = vscode.workspace.getConfiguration("kilo-code.new.sounds")
this.postMessage({
type: "notificationSettingsLoaded",
settings: {
notifyAgent: notifications.get<boolean>("agent", true),
notifyPermissions: notifications.get<boolean>("permissions", true),
notifyErrors: notifications.get<boolean>("errors", true),
soundAgent: sounds.get<string>("agent", "default"),
soundPermissions: sounds.get<string>("permissions", "default"),
soundErrors: sounds.get<string>("errors", "default"),
},
})
}
private sendTimelineSetting(): void {
const config = vscode.workspace.getConfiguration("kilo-code.new")
this.postMessage({
type: "timelineSettingLoaded",
visible: config.get<boolean>("showTaskTimeline", true),
})
}
/** Returns the number of sessions currently in "busy" state. */
private getBusySessionCount(): number {
return getBusySessionCount(this.sessionStatusMap)
}
/**
* Handle config update request from the webview.
* Applies a partial config update via the global config endpoint, then pushes
* the full merged config back to the webview.
*/
private async handleUpdateConfig(partial: Partial<Config>): Promise<void> {
if (!this.client || this.connectionState !== "connected") {
this.postMessage({ type: "error", message: "Not connected to CLI backend" })
return
}
const refreshProviders =
partial.provider !== undefined ||
partial.disabled_providers !== undefined ||
partial.enabled_providers !== undefined
// Belt-and-suspenders guard: prevent fetchAndSendConfig from sending a
// stale configLoaded while this write is in flight (the SSE-triggered reload
// races with the async config.update() write on the CLI backend).
this.pending++
try {
// Reject all pending permissions and questions across every provider
// so their CLI-side Promises resolve before disposeAll() wipes
// Instance state. Throws on failure to abort the config save.
await this.connectionService.drainPendingPrompts()
await this.client.global.config.update({ config: partial }, { throwOnError: true })
// Re-fetch the full merged config (global + project + all layers) so the
// webview receives the complete resolved config, not just global-only data.
// Config.state is reset by updateGlobal (via Instance.resetStateEntry) so
// config.get() returns fresh data without a full dispose cycle.
const dir = this.getWorkspaceDirectory()
const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true }))
this.cachedConfigMessage = { type: "configLoaded", config: merged }
this.postMessage({ type: "configUpdated", config: merged })
if (refreshProviders) {
await this.fetchAndSendProviders()
}
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to update config:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to update config",
})
// Send configUpdated with the last known good config so the webview
// clears its saving flag and reverts optimistic state.
if (this.cachedConfigMessage) {
this.postMessage({ type: "configUpdated", config: (this.cachedConfigMessage as { config: unknown }).config })
}
} finally {
this.pending--
}
}
/**
* Ensure a session exists, creating one if needed. Returns the resolved
* session ID and workspace directory, or undefined when the client is
* disconnected.
*/
private async resolveSession(
sessionID?: string,
draftID?: string,
): Promise<{ sid: string; dir: string } | undefined> {
if (!this.client) return undefined
const dir = sessionID ? this.getWorkspaceDirectory(sessionID) : this.getContextDirectory()
if (!sessionID && !this.currentSession) {
const { data: session } = await this.client.session.create({ directory: dir }, { throwOnError: true })
this.currentSession = session
this.contextSessionID = session.id
this.trackDirectory(session.id, dir)
this.trackedSessionIds.add(session.id)
if (draftID) this.contextSessionID = session.id
this.postMessage({
type: "sessionCreated",
session: this.sessionToWebview(session),
draftID,
})
}
const sid = sessionID || this.currentSession?.id
if (!sid) throw new Error("No session available")
this.trackedSessionIds.add(sid)
return { sid, dir }
}
/** Abort controllers for active retry loops, keyed by session ID */
private retryAbortControllers = new Map<string, AbortController>()
/**
* Execute an SDK call with exponential backoff on HTTP errors.
* Retries on 429, 5xx, and other retryable status codes.
* When the response includes `Retry-After` / `Retry-After-MS` headers,
* the delay honours that value (capped at 5 min). Otherwise uses the
* predefined backoff schedule: 5s -> 10s -> 30s -> 60s -> 300s.
*
* After MAX_RETRIES (5) attempts, automatically throws the error.
* Users can cancel via the cancel button in the UI which sends an abort
* message — this interrupts the backoff delay and stops the retry loop.
*
* The webview receives `sessionStatus` messages with a countdown so the
* user can see that a retry is in progress.
*/
private async withRetry(fn: () => Promise<{ error?: unknown; response: Response }>, sid: string): Promise<void> {
const abortController = new AbortController()
this.retryAbortControllers.set(sid, abortController)
try {
for (let attempt = 1; ; attempt++) {
if (abortController.signal.aborted) {
// User cancelled — return normally without triggering sendMessageFailed
return
}
const result = await fn()
if (!result.error) return
const status = result.response?.status ?? 0
// Non-retryable status codes fail immediately without retry
if (!retryable(status)) {
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
throw result.error
}
// Stop retrying after MAX_RETRIES attempts
if (attempt >= MAX_RETRIES) {
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
throw result.error
}
const delay = backoff(attempt, result.response?.headers)
console.log(`[Kilo New] KiloProvider: Retry on ${status}, attempt ${attempt}/${MAX_RETRIES}, delay ${delay}ms`)
this.postMessage({
type: "sessionStatus",
sessionID: sid,
status: "retry",
attempt,
message: `Error (${status}). Retrying...`,
next: Date.now() + delay,
})
// Wait for delay or until aborted
await new Promise((resolve) => {
const timer = setTimeout(resolve, delay)
abortController.signal.addEventListener("abort", () => {
clearTimeout(timer)
})
})
}
} finally {
this.retryAbortControllers.delete(sid)
}
}
/** Cancel an active retry loop for a session */
private cancelRetry(sid: string): void {
const controller = this.retryAbortControllers.get(sid)
if (controller) {
controller.abort()
this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" })
}
}
private async handleSendMessage(
text: string,
messageID?: string,
sessionID?: string,
draftID?: string,
providerID?: string,
modelID?: string,
agent?: string,
variant?: string,
files?: Array<{ mime: string; url: string }>,
): Promise<void> {
if (!this.client) {
this.postMessage({
type: "sendMessageFailed",
error: "Not connected to CLI backend",
text,
sessionID,
draftID,
messageID,
files,
})
return
}
let resolved: { sid: string; dir: string } | undefined
try {
resolved = await this.resolveSession(sessionID, draftID)
const parts: Array<TextPartInput | FilePartInput> = []
if (files) {
for (const f of files) {
parts.push({ type: "file", mime: f.mime, url: f.url })
}
}
parts.push({ type: "text", text })
const editorContext = await this.gatherEditorContext()
if (messageID) {
this.connectionService.recordMessageSessionId(messageID, resolved!.sid)
}
const sid = resolved!.sid
const dir = resolved!.dir
await this.withRetry(
() =>
this.client!.session.promptAsync({
sessionID: sid,
directory: dir,
messageID,
parts,
model: providerID && modelID ? { providerID, modelID } : undefined,
agent,
variant,
editorContext,
}),
sid,
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to send message:", error)
this.postMessage({
type: "sendMessageFailed",
error: getErrorMessage(error) || "Failed to send message",
text,
sessionID: resolved?.sid ?? sessionID,
draftID,
messageID,
files,
})
}
}
private async handleSendCommand(
command: string,
args: string,
messageID?: string,
sessionID?: string,
draftID?: string,
providerID?: string,
modelID?: string,
agent?: string,
variant?: string,
files?: Array<{ mime: string; url: string }>,
): Promise<void> {
if (!this.client) {
this.postMessage({
type: "sendMessageFailed",
error: "Not connected to CLI backend",
text: `/${command} ${args}`.trim(),
sessionID,
draftID,
messageID,
files,
})
return
}
let resolved: { sid: string; dir: string } | undefined
try {
resolved = await this.resolveSession(sessionID, draftID)
if (messageID) {
this.connectionService.recordMessageSessionId(messageID, resolved!.sid)
}
const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url }))
const sid = resolved!.sid
const dir = resolved!.dir
await this.withRetry(
() =>
this.client!.session.command({
sessionID: sid,
directory: dir,
command,
arguments: args,
messageID,
model: providerID && modelID ? `${providerID}/${modelID}` : undefined,
agent,
variant,
parts,
}),
sid,
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to send command:", error)
this.postMessage({
type: "sendMessageFailed",
error: getErrorMessage(error) || "Failed to send command",
text: `/${command} ${args}`.trim(),
sessionID: resolved?.sid ?? sessionID,
draftID,
messageID,
files,
})
}
}
/**
* Handle abort request from the webview.
*/
private async handleAbort(sessionID?: string): Promise<void> {
if (!this.client) {
return
}
const targetSessionID = sessionID || this.currentSession?.id
if (!targetSessionID) {
return
}
try {
const workspaceDir = this.getWorkspaceDirectory(targetSessionID)
await this.client.session.abort({ sessionID: targetSessionID, directory: workspaceDir }, { throwOnError: true })
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to abort session:", error)
}
}
private async handleRevertSession(sessionID: string, messageID: string): Promise<void> {
if (!this.client) return
const dir = this.getWorkspaceDirectory(sessionID)
const { data, error } = await this.client.session.revert({ sessionID, messageID, directory: dir })
if (error) {
console.error("[Kilo New] KiloProvider: Failed to revert session:", error)
this.postMessage({ type: "error", message: "Failed to revert session", sessionID })
return
}
if (data) this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) })
}
private async handleUnrevertSession(sessionID: string): Promise<void> {
if (!this.client) return
const dir = this.getWorkspaceDirectory(sessionID)
const { data, error } = await this.client.session.unrevert({ sessionID, directory: dir })
if (error) {
console.error("[Kilo New] KiloProvider: Failed to unrevert session:", error)
this.postMessage({ type: "error", message: "Failed to redo session", sessionID })
return
}
if (data) this.postMessage({ type: "sessionUpdated", session: sessionToWebview(data) })
}
/**
* Handle compact (context summarization) request from the webview.
*/
private async handleCompact(sessionID?: string, providerID?: string, modelID?: string): Promise<void> {
if (!this.client) {
this.postMessage({
type: "error",
message: "Not connected to CLI backend",
})
return
}
const target = sessionID || this.currentSession?.id
if (!target) {
console.error("[Kilo New] KiloProvider: No sessionID for compact")
return
}
if (!providerID || !modelID) {
console.error("[Kilo New] KiloProvider: No model selected for compact")
this.postMessage({
type: "error",
message: "No model selected. Connect a provider to compact this session.",
})
return
}
try {
const workspaceDir = this.getWorkspaceDirectory(target)
await this.client.session.summarize(
{ sessionID: target, directory: workspaceDir, providerID, modelID },
{ throwOnError: true },
)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to compact session:", error)
this.postMessage({
type: "error",
message: getErrorMessage(error) || "Failed to compact session",
})
}
}
// Permission + question handlers extracted to kilo-provider/handlers/permission.ts and question.ts
private get permissionCtx(): PermissionContext {
return {
client: this.client,
currentSessionId: this.currentSession?.id,
trackedSessionIds: this.trackedSessionIds,
sessionDirectories: this.sessionDirectories,
postMessage: (msg) => this.postMessage(msg),
getWorkspaceDirectory: (sid) => this.getWorkspaceDirectory(sid),
}
}
private get questionCtx() {
return {
client: this.client,
currentSessionId: this.currentSession?.id,
trackedSessionIds: this.trackedSessionIds,
sessionDirectories: this.sessionDirectories,
postMessage: (msg: unknown) => this.postMessage(msg),
getWorkspaceDirectory: (sid?: string) => this.getWorkspaceDirectory(sid),
}
}
// Cloud session handlers extracted to kilo-provider/handlers/cloud-session.ts
private get cloudSessionCtx(): CloudSessionContext {
const self = this
return {
client: this.client,
get currentSession() {
return self.currentSession
},
set currentSession(session) {
self.currentSession = session
if (session) self.contextSessionID = session.id
},
trackedSessionIds: this.trackedSessionIds,
connectionService: this.connectionService,
postMessage: (msg) => this.postMessage(msg),
getWorkspaceDirectory: (sid) => this.getWorkspaceDirectory(sid),
gatherEditorContext: () => this.gatherEditorContext(),
}
}
// Auth handlers extracted to kilo-provider/handlers/auth.ts
private get authCtx(): AuthContext {
return {
client: this.client,
postMessage: (msg) => this.postMessage(msg),
getWorkspaceDirectory: () => this.getWorkspaceDirectory(),
disposeGlobal: () => this.disposeGlobal(),
fetchAndSendProviders: () => this.fetchAndSendProviders(),
fetchAndSendAgents: () => this.fetchAndSendAgents(),
}
}
private async disposeGlobal(): Promise<void> {
if (!this.client) return
await this.client.global
.dispose()
.catch((e: unknown) => console.warn("[Kilo New] KiloProvider: global.dispose() after org switch failed:", e))
// Org switch succeeded — refresh profile and providers independently (best-effort)
try {
const profileResult = await this.client!.kilo.profile()
// Broadcast to all webviews (sidebar, profile tab, agent manager, etc.)
this.connectionService.notifyProfileChanged(profileResult.data ?? null)
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to refresh profile after org switch:", error)
}
try {
await this.fetchAndSendProviders()
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to refresh providers after org switch:", error)
}
}
private handlePreviewImage(dataUrl: string, filename: string): void {
const dir = this.extensionContext?.globalStorageUri
if (!dir) return
const img = parseImage(dataUrl, filename)
if (!img) return
const root = vscode.Uri.joinPath(dir, getPreviewDir())
const uri = vscode.Uri.joinPath(dir, buildPreviewPath(img.name, Date.now()))
const clean = () =>
vscode.workspace.fs.readDirectory(root).then(
(items) => {
const stale = trimEntries(items.map(([name]) => ({ path: name })))
return Promise.all(
stale.map((name) =>
Promise.resolve(vscode.workspace.fs.delete(vscode.Uri.joinPath(root, name), { recursive: true })).then(
undefined,
(err: unknown) => {
console.warn("[Kilo New] KiloProvider: Failed to delete stale preview:", err)
},
),
),
)
},
() => [],
)
const open = () =>
vscode.commands
.executeCommand(...getPreviewCommand(uri))
.then(undefined, () => vscode.commands.executeCommand("vscode.open", uri))
void vscode.workspace.fs
.createDirectory(root)
.then(() => vscode.workspace.fs.writeFile(uri, img.data))
.then(() => clean())
.then(open, (err) => console.error("[Kilo New] KiloProvider: Failed to preview image:", err))
}
/**
* Handle openFile request from the webview — open a file in the VS Code editor.
* Resolves relative paths against the current session's directory (which may be
* a worktree path registered via setSessionDirectory), falling back to workspace root.
* Absolute paths (Unix `/…` or Windows `C:\…`) are used as-is.
*/
private handleOpenFile(filePath: string, line?: number, column?: number): void {
const uri = isAbsolutePath(filePath)
? vscode.Uri.file(filePath)
: vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory(this.currentSession?.id)), filePath)
vscode.workspace.openTextDocument(uri).then(
(doc) => {
const options: vscode.TextDocumentShowOptions = { preview: true }
if (line !== undefined && line > 0) {
const col = column !== undefined && column > 0 ? column - 1 : 0
const pos = new vscode.Position(line - 1, col)
options.selection = new vscode.Range(pos, pos)
}
vscode.window.showTextDocument(doc, options)
},
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
)
}
/**
* Handle a generic setting update from the webview.
* The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled").
*/
private async handleUpdateSetting(key: string, value: unknown): Promise<void> {
const { section, leaf } = buildSettingPath(key)
const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`)
await config.update(leaf, value, vscode.ConfigurationTarget.Global)
}
/**
* Reset all "kilo-code.new.*" extension settings to their defaults by reading
* contributes.configuration from the extension's package.json at runtime.
* Only resets settings under the "kilo-code.new." namespace to avoid touching
* settings from the previous version of the extension which shares the same
* extension ID and "kilo-code.*" namespace.
*/
private async handleResetAllSettings(): Promise<void> {
const confirmed = await vscode.window.showWarningMessage(
"Reset all Kilo Code extension settings to defaults?",
{ modal: true },
"Reset",
)
if (confirmed !== "Reset") return
const prefix = "kilo-code.new."
const ext = vscode.extensions.getExtension("kilocode.kilo-code")
const properties = ext?.packageJSON?.contributes?.configuration?.properties as Record<string, unknown> | undefined
if (!properties) return
for (const key of Object.keys(properties)) {
if (!key.startsWith(prefix)) continue
const parts = key.split(".")
const section = parts.slice(0, -1).join(".")
const leaf = parts[parts.length - 1]!
const config = vscode.workspace.getConfiguration(section)
await config.update(leaf, undefined, vscode.ConfigurationTarget.Global)
}
// Clear globalState items that are not part of the configuration
await this.extensionContext?.globalState.update("variantSelections", undefined)
await this.extensionContext?.globalState.update("recentModels", undefined)
await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", undefined)
// Re-send all settings to the webview so the UI reflects the reset
this.sendAutocompleteSettings()
this.sendBrowserSettings()
this.sendNotificationSettings()
this.sendTimelineSetting()
// Re-send globalState items to the webview
this.postMessage({ type: "variantsLoaded", variants: {} })
this.postMessage({ type: "recentsLoaded", recents: [] })
// Re-fetch notifications to reflect cleared dismissed IDs
await this.fetchAndSendNotifications()
vscode.window.showInformationMessage("Kilo Code settings have been reset to defaults.")
}
/**
* Read the current browser automation settings and push them to the webview.
*/
private sendBrowserSettings(): void {
const config = vscode.workspace.getConfiguration("kilo-code.new.browserAutomation")
this.postMessage({
type: "browserSettingsLoaded",
settings: {
enabled: config.get<boolean>("enabled", false),
useSystemChrome: config.get<boolean>("useSystemChrome", true),
headless: config.get<boolean>("headless", false),
},
})
}
/**
* Read the current Claude Code compatibility setting and push it to the webview.
*/
private sendClaudeCompatSetting(): void {
const enabled = vscode.workspace.getConfiguration("kilo-code.new").get<boolean>("claudeCodeCompat", false)
this.postMessage({
type: "claudeCompatSettingLoaded",
enabled: enabled ?? false,
})
}
/** Re-fetch all server-side state after an auth change. */
private async reloadAfterAuthChange(): Promise<void> {
await Promise.all([
this.fetchAndSendProviders(),
this.fetchAndSendAgents(),
this.fetchAndSendSkills(),
this.fetchAndSendCommands(),
this.fetchAndSendConfig(),
this.fetchAndSendNotifications(),
])
}
/**
* Handle SSE events from the CLI backend.
* Filters events by project ID and tracked session IDs so each webview only sees its own sessions.
*/
private handleEvent(event: Event): void {
// Drop session events from other projects before any tracking logic.
// This must come first: the trackedSessionIds guard below would otherwise
// let a foreign session through if it was accidentally tracked.
if (isEventFromForeignProject(event, this.projectID)) return
// session.status events pass the onEventFiltered pre-filter for all providers (see line 842),
// so this runs on every KiloProvider instance — including the Settings panel which has no
// tracked sessions. Update sessionStatusMap and forward to webview before the
// trackedSessionIds guard so the Settings panel's allStatusMap stays current for the
// busy-session warning on Save.
if (event.type === "session.status") {
const sid = event.properties.sessionID
this.sessionStatusMap.set(sid, event.properties.status.type)
const msg = mapSSEEventToWebviewMessage(event, sid)
if (msg) this.postMessage(msg)
return
}
// Extract sessionID from the event
if (event.type === "session.created" && this.adoptPendingFollowup(event.properties.info)) {
return
}
const sessionID = this.connectionService.resolveEventSessionId(event)
// Events without sessionID (server.connected, server.heartbeat) → always forward
// Events with sessionID → only forward if this webview tracks that session
// message.part.updated and message.part.delta are always session-scoped; drop if session unknown.
if (!sessionID && (event.type === "message.part.updated" || event.type === "message.part.delta")) {
return
}
if (sessionID && !this.trackedSessionIds.has(sessionID)) {
return
}
// Refresh provider and agent lists when the server signals a state disposal
if (event.type === "global.disposed") {
void this.reloadAfterAuthChange()
return
}
if (event.type === "server.instance.disposed") {
const props = event.properties as Record<string, unknown> | null
const dir = typeof props?.directory === "string" ? props.directory : undefined
if (dir && path.resolve(dir) !== path.resolve(this.getWorkspaceDirectory())) return
void this.reloadAfterAuthChange()
return
}
// Config was updated without a full dispose (e.g. permission-only save).
// Fetch and push the updated config so the Settings panel reflects the change.
if (event.type === "global.config.updated") {
void this.fetchAndSendConfigUpdated()
return
}
// Forward relevant events to webview
// Side effects that must happen before the webview message is sent
if (event.type === "session.created" && !this.currentSession) {
this.currentSession = event.properties.info
this.contextSessionID = event.properties.info.id
this.trackedSessionIds.add(event.properties.info.id)
}
if (event.type === "session.updated" && this.currentSession?.id === event.properties.info.id) {
this.currentSession = event.properties.info
this.contextSessionID = event.properties.info.id
}
// Auto-adopt child sessions as soon as the task tool part reveals their ID.
// This means the child's permission/question events are tracked immediately —
// before the webview renderer has a chance to call syncSession — eliminating
// the race where the child blocks on a prompt that the UI never sees.
if (event.type === "message.part.updated") {
const part = event.properties.part as {
type?: string
tool?: string
metadata?: { sessionId?: string }
sessionID?: string
}
const childId = part.type === "tool" && part.tool === "task" ? part.metadata?.sessionId : undefined
if (childId && !this.trackedSessionIds.has(childId)) {
console.log("[Kilo New] KiloProvider: 🔗 Auto-adopting child session from task tool", { childId })
void this.handleSyncSession(childId, part.sessionID ?? sessionID)
}
}
const msg = mapSSEEventToWebviewMessage(event, sessionID)
if (msg) {
if (msg.type === "partUpdated") {
this.postMessage({
...msg,
part: this.slimPart(msg.part),
})
return
}
this.postMessage(msg)
}
}
/**
* Read autocomplete settings from VS Code configuration and push to the webview.
*/
private sendAutocompleteSettings(): void {
const config = vscode.workspace.getConfiguration("kilo-code.new.autocomplete")
this.postMessage({
type: "autocompleteSettingsLoaded",
settings: {
enableAutoTrigger: config.get<boolean>("enableAutoTrigger", true),
enableSmartInlineTaskKeybinding: config.get<boolean>("enableSmartInlineTaskKeybinding", false),
enableChatAutocomplete: config.get<boolean>("enableChatAutocomplete", false),
},
})
}
/** Wait until the webview has sent "webviewReady". Resolves immediately when already ready. */
public waitForReady(): Promise<void> {
return this.isWebviewReady && this.webview ? Promise.resolve() : new Promise((r) => this.readyResolvers.push(r))
}
/** Post a message to the webview. Public so toolbar button commands can send messages. */
public postMessage(message: unknown): void {
if (!this.webview) {
const type =
typeof message === "object" &&
message !== null &&
"type" in message &&
typeof (message as { type?: unknown }).type === "string"
? (message as { type: string }).type
: "<unknown>"
console.warn("[Kilo New] KiloProvider: ⚠️ postMessage dropped (no webview)", { type })
return
}
void this.webview.postMessage(message).then(undefined, (error) => {
console.error("[Kilo New] KiloProvider: ❌ postMessage failed", error)
})
}
public async appendReviewComments(comments: unknown[], autoSend = false): Promise<void> {
this.pendingReviewComments.push({ comments, autoSend })
if (!this.webview) {
await vscode.commands.executeCommand(`${KiloProvider.viewType}.focus`)
}
this.flushPendingReviewComments()
}
private flushPendingReviewComments(): void {
if (!this.webview || !this.isWebviewReady || this.pendingReviewComments.length === 0) return
const pending = this.pendingReviewComments
this.pendingReviewComments = []
for (const entry of pending) {
this.postMessage({ type: "appendReviewComments", comments: entry.comments, autoSend: entry.autoSend })
}
}
/**
* Get the git remote URL for the current workspace using VS Code's built-in Git API.
* Returns undefined if not in a git repo or no remotes are configured.
*/
private async getGitRemoteUrl(): Promise<string | undefined> {
try {
const extension = vscode.extensions.getExtension("vscode.git")
if (!extension) return undefined
const api = extension.isActive ? extension.exports?.getAPI(1) : (await extension.activate())?.getAPI(1)
if (!api) return undefined
const repo = api.repositories?.[0]
if (!repo) return undefined
const remote = repo.state?.remotes?.find((r: { name: string }) => r.name === "origin")
return remote?.fetchUrl ?? remote?.pushUrl
} catch (error) {
console.warn("[Kilo New] KiloProvider: Failed to get git remote URL:", error)
return undefined
}
}
/**
* Gather VS Code editor context to send alongside messages to the CLI backend.
*/
/**
* Return the set of relative paths for all open text-editor tabs within the
* given directory, filtered through .kilocodeignore.
*/
private async getOpenTabPaths(dir: string): Promise<Set<string>> {
const controller = await this.getIgnoreController(dir)
const result = new Set<string>()
for (const group of vscode.window.tabGroups.all) {
for (const tab of group.tabs) {
if (tab.input instanceof vscode.TabInputText) {
const uri = tab.input.uri
if (uri.scheme === "file") {
const rel = path.relative(dir, uri.fsPath)
if (!rel.startsWith("..") && !path.isAbsolute(rel) && controller.validateAccess(uri.fsPath)) {
result.add(rel.replaceAll("\\", "/"))
}
}
}
}
}
return result
}
/**
* Get or create a FileIgnoreController for the current workspace directory.
* Reinitializes if the workspace directory has changed.
*/
private async getIgnoreController(workspaceDir: string): Promise<FileIgnoreController> {
if (this.ignoreController && this.ignoreControllerDir === workspaceDir) {
return this.ignoreController
}
const controller = new FileIgnoreController(workspaceDir)
await controller.initialize()
this.ignoreController = controller
this.ignoreControllerDir = workspaceDir
return controller
}
private async gatherEditorContext(): Promise<EditorContext> {
const workspaceDir = this.getWorkspaceDirectory()
const controller = await this.getIgnoreController(workspaceDir)
const toRelative = (fsPath: string): string | undefined => {
if (!workspaceDir) {
return undefined
}
const relative = path.relative(workspaceDir, fsPath)
if (relative.startsWith("..") || path.isAbsolute(relative)) {
return undefined
}
return relative
}
// Visible files (capped to avoid bloating context, filtered through .kilocodeignore)
const visibleFiles = vscode.window.visibleTextEditors
.map((e) => e.document.uri)
.filter((uri) => uri.scheme === "file")
.map((uri) => toRelative(uri.fsPath))
.filter((p): p is string => p !== undefined && controller.validateAccess(path.resolve(workspaceDir, p)))
.slice(0, 200)
// Open tabs — use instanceof TabInputText to exclude notebooks, diffs, custom editors
const openTabs = [...(await this.getOpenTabPaths(workspaceDir))].slice(0, 20)
// Active file (also filtered through .kilocodeignore)
const activeEditor = vscode.window.activeTextEditor
const activeRel =
activeEditor?.document.uri.scheme === "file" ? toRelative(activeEditor.document.uri.fsPath) : undefined
const activeFile = activeRel && controller.validateAccess(activeEditor!.document.uri.fsPath) ? activeRel : undefined
// Shell
const shell = vscode.env.shell || undefined
return {
...(visibleFiles.length > 0 ? { visibleFiles } : {}),
...(openTabs.length > 0 ? { openTabs } : {}),
...(activeFile ? { activeFile } : {}),
...(shell ? { shell } : {}),
}
}
/**
* Get the workspace directory for a session.
* Checks session directory overrides first (e.g., worktree paths), then falls back to workspace root.
*/
private getWorkspaceDirectory(sessionId?: string): string {
return resolveWorkspaceDirectory({
sessionID: sessionId,
sessionDirectories: this.sessionDirectories,
workspaceDirectory: this.getRootDirectory(),
})
}
private getContextDirectory(): string {
return resolveContextDirectory({
currentSessionID: this.currentSession?.id,
contextSessionID: this.contextSessionID,
sessionDirectories: this.sessionDirectories,
workspaceDirectory: this.getRootDirectory(),
})
}
private getRootDirectory(): string {
const workspaceFolders = vscode.workspace.workspaceFolders
if (workspaceFolders && workspaceFolders.length > 0) {
return workspaceFolders[0]!.uri.fsPath
}
return process.cwd()
}
private trackDirectory(sessionId: string, dir: string) {
if (path.resolve(dir) === path.resolve(this.getRootDirectory())) {
this.sessionDirectories.delete(sessionId)
return
}
this.sessionDirectories.set(sessionId, dir)
}
private noteFollowup(answers: string[][], sessionID?: string) {
const dir = this.getWorkspaceDirectory(sessionID)
this.pendingFollowup = recordFollowup({ answers, dir, now: Date.now() }) ?? null
}
private matchesPendingFollowup(session: Session) {
return matchFollowup({ pending: this.pendingFollowup, dir: session.directory, now: Date.now() })
}
private adoptPendingFollowup(session: Session) {
const now = Date.now()
const match = this.matchesPendingFollowup(session)
if (!match) {
if (
this.pendingFollowup &&
!matchFollowup({ pending: this.pendingFollowup, dir: this.pendingFollowup.dir, now })
) {
this.pendingFollowup = null
}
return false
}
this.pendingFollowup = null
this.trackDirectory(session.id, session.directory)
this.registerSession(session)
void this.handleLoadMessages(session.id)
return true
}
private getProjectDirectory(sessionId?: string): string | undefined {
return resolveProjectDirectory(this.projectDirectory, () => this.getWorkspaceDirectory(sessionId))
}
private _getHtmlForWebview(webview: vscode.Webview): string {
return buildWebviewHtml(webview, {
scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")),
styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.css")),
iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")),
title: "Kilo Code",
port: this.connectionService.getServerInfo()?.port,
extraStyles: `.container { height: 100%; display: flex; flex-direction: column; height: 100vh; border-right: 1px solid var(--border-weak-base); }`,
})
}
// legacy-migration start -------------------------------------------------------
// Migration handlers extracted to kilo-provider/handlers/migration.ts
private get migrationCtx(): MigrationContext {
const self = this
return {
client: this.client,
extensionContext: this.extensionContext,
postMessage: (msg) => this.postMessage(msg),
get cachedLegacyData() {
return self.cachedLegacyData
},
set cachedLegacyData(data) {
self.cachedLegacyData = data
},
get migrationCheckInFlight() {
return self.migrationCheckInFlight
},
set migrationCheckInFlight(val) {
self.migrationCheckInFlight = val
},
refreshSessions: () => this.refreshSessions(),
disposeGlobal: () => this.disposeGlobal(),
broadcastComplete: () => this.connectionService.notifyMigrationComplete(),
}
}
// legacy-migration end ---------------------------------------------------------
private getMarketplace(): MarketplaceService {
if (this.marketplace) return this.marketplace
this.marketplace = new MarketplaceService()
return this.marketplace
}
// ── Worktree stats polling (sidebar diff badge) ──────────────────
private startStatsPolling(): void {
this.statsPoller?.stop()
this.statsGitOps?.dispose()
const git = new GitOps({ log: () => {} })
this.statsGitOps = git
this.statsPoller = new GitStatsPoller({
getWorktrees: () => [],
getWorkspaceRoot: () => getWorkspaceRoot(),
getClient: () => this.connectionService.getClient(),
git,
onStats: () => {},
onLocalStats: (stats: LocalStats) => {
const msg = {
type: "worktreeStatsLoaded" as const,
files: stats.files,
additions: stats.additions,
deletions: stats.deletions,
}
this.cachedStats = msg
this.postMessage(msg)
},
log: () => {},
})
this.statsPoller.setEnabled(true)
}
/**
* Dispose of the provider and clean up subscriptions.
* Does NOT kill the server — that's the connection service's job.
*/
dispose(): void {
this.statsPoller?.stop()
this.statsGitOps?.dispose()
this.unsubscribeEvent?.()
this.unsubscribeState?.()
this.unsubscribeNotificationDismiss?.()
this.unsubscribeLanguageChange?.()
this.unsubscribeProfileChange?.()
this.unsubscribeFavoritesChange?.()
this.unsubscribeMigrationComplete?.()
this.unsubscribeClearPendingPrompts?.()
this.unsubscribeDirectoryProvider?.()
this.webviewMessageDisposable?.dispose()
this.trackedSessionIds.clear()
this.syncedChildSessions.clear()
this.sessionDirectories.clear()
this.sessionStatusMap.clear()
this.ignoreController?.dispose()
this.chatAutocomplete?.dispose()
this.marketplace?.dispose()
}
}