ExtensionHost.ts 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151
  1. import { EventEmitter } from "events"
  2. import { createVSCodeAPIMock, type IdentityInfo, type ExtensionContext } from "./VSCode.js"
  3. import { logs } from "../services/logs.js"
  4. import type { ExtensionMessage, WebviewMessage, ExtensionState, ModeConfig } from "../types/messages.js"
  5. import { getTelemetryService } from "../services/telemetry/index.js"
  6. import { argsToMessage } from "../utils/safe-stringify.js"
  7. export interface ExtensionHostOptions {
  8. workspacePath: string
  9. extensionBundlePath: string // Direct path to extension.js
  10. extensionRootPath: string // Root path for extension assets
  11. identity?: IdentityInfo // Identity information for VSCode environment
  12. customModes?: ModeConfig[] // Custom modes configuration
  13. appendSystemPrompt?: string // Custom text to append to system prompt
  14. }
  15. // Extension module interface
  16. interface ExtensionModule {
  17. activate: (context: unknown) => Promise<KiloCodeAPI> | KiloCodeAPI
  18. deactivate?: () => Promise<void> | void
  19. }
  20. // KiloCode API interface returned by extension activation
  21. interface KiloCodeAPI {
  22. startNewTask?: (task: string, images?: string[]) => Promise<void>
  23. sendMessage?: (message: ExtensionMessage) => void
  24. cancelTask?: () => Promise<void>
  25. condense?: () => Promise<void>
  26. condenseTaskContext?: () => Promise<void>
  27. handleTerminalOperation?: (operation: string) => Promise<void>
  28. getState?: () => ExtensionState | Promise<ExtensionState>
  29. }
  30. // VSCode API mock interface - matches the return type from createVSCodeAPIMock
  31. interface VSCodeAPIMock {
  32. context: ExtensionContext
  33. [key: string]: unknown
  34. }
  35. // Webview provider interface
  36. interface WebviewProvider {
  37. handleCLIMessage?: (message: WebviewMessage) => Promise<void>
  38. [key: string]: unknown
  39. }
  40. export interface ExtensionAPI {
  41. getState: () => ExtensionState | null
  42. sendMessage: (message: ExtensionMessage) => void
  43. updateState: (updates: Partial<ExtensionState>) => void
  44. }
  45. export class ExtensionHost extends EventEmitter {
  46. private options: ExtensionHostOptions
  47. private isActivated = false
  48. private currentState: ExtensionState | null = null
  49. private extensionModule: ExtensionModule | null = null
  50. private extensionAPI: KiloCodeAPI | null = null
  51. private vscodeAPI: VSCodeAPIMock | null = null
  52. private webviewProviders: Map<string, WebviewProvider> = new Map()
  53. private webviewInitialized = false
  54. private pendingMessages: WebviewMessage[] = []
  55. private isInitialSetup = true
  56. private originalConsole: {
  57. log: typeof console.log
  58. error: typeof console.error
  59. warn: typeof console.warn
  60. debug: typeof console.debug
  61. info: typeof console.info
  62. } | null = null
  63. private lastWebviewLaunchTime = 0
  64. private extensionHealth = {
  65. isHealthy: true,
  66. errorCount: 0,
  67. lastError: null as Error | null,
  68. lastErrorTime: 0,
  69. maxErrorsBeforeWarning: 10,
  70. }
  71. private unhandledRejectionHandler: ((reason: unknown, promise: Promise<unknown>) => void) | null = null
  72. private uncaughtExceptionHandler: ((error: Error) => void) | null = null
  73. constructor(options: ExtensionHostOptions) {
  74. super()
  75. this.options = options
  76. // Increase max listeners to avoid warnings in tests
  77. process.setMaxListeners(20)
  78. this.setupGlobalErrorHandlers()
  79. }
  80. /**
  81. * Setup global error handlers to catch unhandled errors from extension
  82. */
  83. private setupGlobalErrorHandlers(): void {
  84. // Handle unhandled promise rejections from extension
  85. this.unhandledRejectionHandler = (reason: unknown) => {
  86. const error = reason instanceof Error ? reason : new Error(String(reason))
  87. // Check if this is an expected error
  88. if (this.isExpectedError(error)) {
  89. logs.debug(`Caught expected unhandled rejection: ${error.message}`, "ExtensionHost")
  90. return
  91. }
  92. logs.error("Unhandled promise rejection from extension", "ExtensionHost", { error, reason })
  93. // Emit non-fatal error event
  94. this.emit("extension-error", {
  95. context: "unhandledRejection",
  96. error,
  97. recoverable: true,
  98. timestamp: Date.now(),
  99. })
  100. // Update health metrics
  101. this.extensionHealth.errorCount++
  102. this.extensionHealth.lastError = error
  103. this.extensionHealth.lastErrorTime = Date.now()
  104. }
  105. process.on("unhandledRejection", this.unhandledRejectionHandler)
  106. // Handle uncaught exceptions from extension
  107. this.uncaughtExceptionHandler = (error: Error) => {
  108. // Check if this is an expected error
  109. if (this.isExpectedError(error)) {
  110. logs.debug(`Caught expected uncaught exception: ${error.message}`, "ExtensionHost")
  111. return
  112. }
  113. logs.error("Uncaught exception from extension", "ExtensionHost", { error })
  114. // Emit non-fatal error event
  115. this.emit("extension-error", {
  116. context: "uncaughtException",
  117. error,
  118. recoverable: true,
  119. timestamp: Date.now(),
  120. })
  121. // Update health metrics
  122. this.extensionHealth.errorCount++
  123. this.extensionHealth.lastError = error
  124. this.extensionHealth.lastErrorTime = Date.now()
  125. }
  126. process.on("uncaughtException", this.uncaughtExceptionHandler)
  127. }
  128. /**
  129. * Remove global error handlers
  130. */
  131. private removeGlobalErrorHandlers(): void {
  132. if (this.unhandledRejectionHandler) {
  133. process.off("unhandledRejection", this.unhandledRejectionHandler)
  134. this.unhandledRejectionHandler = null
  135. }
  136. if (this.uncaughtExceptionHandler) {
  137. process.off("uncaughtException", this.uncaughtExceptionHandler)
  138. this.uncaughtExceptionHandler = null
  139. }
  140. }
  141. /**
  142. * Safely execute an operation, catching and logging any errors without crashing the CLI
  143. */
  144. private async safeExecute<T>(
  145. operation: () => T | Promise<T>,
  146. context: string,
  147. fallback?: T,
  148. ): Promise<T | undefined> {
  149. try {
  150. const result = await operation()
  151. return result
  152. } catch (error) {
  153. this.extensionHealth.errorCount++
  154. this.extensionHealth.lastError = error as Error
  155. this.extensionHealth.lastErrorTime = Date.now()
  156. // Check if this is an expected error (like task abortion)
  157. const isExpectedError = this.isExpectedError(error)
  158. if (!isExpectedError) {
  159. logs.error(`Extension error in ${context}`, "ExtensionHost", {
  160. error,
  161. errorCount: this.extensionHealth.errorCount,
  162. })
  163. // Emit non-fatal error event
  164. this.emit("extension-error", {
  165. context,
  166. error,
  167. recoverable: true,
  168. timestamp: Date.now(),
  169. })
  170. } else {
  171. logs.debug(`Expected error in ${context}: ${error}`, "ExtensionHost")
  172. }
  173. return fallback
  174. }
  175. }
  176. /**
  177. * Check if an error is expected (e.g., task abortion)
  178. */
  179. private isExpectedError(error: unknown): boolean {
  180. if (!error) return false
  181. const errorMessage = error instanceof Error ? error.message : String(error)
  182. // Task abortion errors are expected
  183. if (errorMessage.includes("task") && errorMessage.includes("aborted")) {
  184. return true
  185. }
  186. // Add other expected error patterns here
  187. return false
  188. }
  189. async activate(): Promise<ExtensionAPI> {
  190. if (this.isActivated) {
  191. return this.getAPI()
  192. }
  193. try {
  194. logs.info("Activating extension...", "ExtensionHost")
  195. // Set up console interception FIRST to capture all extension logs
  196. // This must happen before loading the extension module
  197. this.setupConsoleInterception()
  198. // Setup VSCode API mock
  199. await this.setupVSCodeAPIMock()
  200. // Load the extension (console already intercepted)
  201. await this.loadExtension()
  202. // Activate the extension
  203. await this.activateExtension()
  204. this.isActivated = true
  205. logs.info("Extension activated successfully", "ExtensionHost")
  206. // Emit activation event
  207. this.emit("activated", this.getAPI())
  208. return this.getAPI()
  209. } catch (error) {
  210. logs.error("Failed to activate extension", "ExtensionHost", { error })
  211. this.emit("extension-error", {
  212. context: "activation",
  213. error,
  214. recoverable: false,
  215. timestamp: Date.now(),
  216. })
  217. // Don't throw - return API with limited functionality
  218. return this.getAPI()
  219. }
  220. }
  221. async deactivate(): Promise<void> {
  222. if (!this.isActivated) {
  223. return
  224. }
  225. try {
  226. logs.info("Deactivating extension...", "ExtensionHost")
  227. // Call extension's deactivate function if it exists
  228. if (this.extensionModule && typeof this.extensionModule.deactivate === "function") {
  229. await this.extensionModule.deactivate()
  230. }
  231. // Clean up VSCode API mock
  232. if (this.vscodeAPI && this.vscodeAPI.context) {
  233. // Dispose all subscriptions
  234. for (const subscription of this.vscodeAPI.context.subscriptions) {
  235. if (subscription && typeof subscription.dispose === "function") {
  236. subscription.dispose()
  237. }
  238. }
  239. }
  240. // Restore original console methods
  241. this.restoreConsole()
  242. // Remove global error handlers
  243. this.removeGlobalErrorHandlers()
  244. this.isActivated = false
  245. this.currentState = null
  246. this.extensionModule = null
  247. this.extensionAPI = null
  248. this.vscodeAPI = null
  249. this.webviewProviders.clear()
  250. this.lastWebviewLaunchTime = 0
  251. this.removeAllListeners()
  252. logs.info("Extension deactivated", "ExtensionHost")
  253. } catch (error) {
  254. logs.error("Error during deactivation", "ExtensionHost", { error })
  255. throw error
  256. }
  257. }
  258. async sendWebviewMessage(message: WebviewMessage): Promise<void> {
  259. try {
  260. logs.debug(`Processing webview message: ${message.type}`, "ExtensionHost")
  261. if (!this.isActivated) {
  262. logs.warn("Extension not activated, ignoring message", "ExtensionHost")
  263. return
  264. }
  265. // Queue messages if webview not initialized
  266. if (!this.webviewInitialized) {
  267. this.pendingMessages.push(message)
  268. logs.debug(`Queued message ${message.type} - webview not ready`, "ExtensionHost")
  269. return
  270. }
  271. // Track extension message sent
  272. getTelemetryService().trackExtensionMessageSent(message.type)
  273. // Handle webviewDidLaunch for CLI state synchronization
  274. if (message.type === "webviewDidLaunch") {
  275. // Prevent rapid-fire webviewDidLaunch messages
  276. const now = Date.now()
  277. if (now - this.lastWebviewLaunchTime < 1000) {
  278. logs.debug("Ignoring webviewDidLaunch - too soon after last one", "ExtensionHost")
  279. return
  280. }
  281. this.lastWebviewLaunchTime = now
  282. await this.handleWebviewLaunch()
  283. }
  284. // Forward message directly to the webview provider instead of emitting event
  285. // This prevents duplicate handling (event listener + direct call)
  286. const webviewProvider = this.webviewProviders.get("kilo-code.SidebarProvider")
  287. if (webviewProvider && typeof webviewProvider.handleCLIMessage === "function") {
  288. await webviewProvider.handleCLIMessage(message)
  289. } else {
  290. logs.warn(
  291. `No webview provider found or handleCLIMessage not available for: ${message.type}`,
  292. "ExtensionHost",
  293. )
  294. }
  295. // Handle local state updates for CLI display after forwarding
  296. await this.handleLocalStateUpdates(message)
  297. } catch (error) {
  298. logs.error("Error handling webview message", "ExtensionHost", { error })
  299. // Don't emit "error" event - emit non-fatal event instead
  300. this.emit("extension-error", {
  301. context: `webview-message-${message.type}`,
  302. error,
  303. recoverable: true,
  304. timestamp: Date.now(),
  305. })
  306. // Don't re-throw - allow CLI to continue
  307. }
  308. }
  309. private async setupVSCodeAPIMock(): Promise<void> {
  310. // Create VSCode API mock with extension root path for assets and identity
  311. this.vscodeAPI = createVSCodeAPIMock(
  312. this.options.extensionRootPath,
  313. this.options.workspacePath,
  314. this.options.identity,
  315. ) as VSCodeAPIMock
  316. // Set global vscode object for the extension
  317. if (this.vscodeAPI) {
  318. ;(global as unknown as { vscode: VSCodeAPIMock }).vscode = this.vscodeAPI
  319. }
  320. // Set global reference to this ExtensionHost for webview provider registration
  321. ;(global as unknown as { __extensionHost: ExtensionHost }).__extensionHost = this
  322. // Set environment variables to disable problematic features in CLI mode
  323. process.env.KILO_CLI_MODE = "true"
  324. process.env.NODE_ENV = process.env.NODE_ENV || "production"
  325. logs.debug("VSCode API mock setup complete", "ExtensionHost")
  326. }
  327. private setupConsoleInterception(): void {
  328. // Store original console methods
  329. this.originalConsole = {
  330. log: console.log,
  331. error: console.error,
  332. warn: console.warn,
  333. debug: console.debug,
  334. info: console.info,
  335. }
  336. // Set up global.__interceptedConsole FIRST, before any module loading
  337. // This ensures it's available when the module compilation hook runs
  338. // and all extension modules can use the intercepted console
  339. ;(global as unknown as { __interceptedConsole: Console }).__interceptedConsole = {
  340. log: (...args: unknown[]) => {
  341. const message = argsToMessage(args)
  342. logs.info(message, "Extension")
  343. },
  344. error: (...args: unknown[]) => {
  345. const message = argsToMessage(args)
  346. logs.error(message, "Extension")
  347. },
  348. warn: (...args: unknown[]) => {
  349. const message = argsToMessage(args)
  350. logs.warn(message, "Extension")
  351. },
  352. debug: (...args: unknown[]) => {
  353. const message = argsToMessage(args)
  354. logs.debug(message, "Extension")
  355. },
  356. info: (...args: unknown[]) => {
  357. const message = argsToMessage(args)
  358. logs.info(message, "Extension")
  359. },
  360. } as Console
  361. // Override console methods to forward to LogsService ONLY (no console output)
  362. // IMPORTANT: Use safe serialization to avoid circular reference errors
  363. console.log = (...args: unknown[]) => {
  364. const message = argsToMessage(args)
  365. logs.info(message, "Extension")
  366. }
  367. console.error = (...args: unknown[]) => {
  368. const message = argsToMessage(args)
  369. logs.error(message, "Extension")
  370. }
  371. console.warn = (...args: unknown[]) => {
  372. const message = argsToMessage(args)
  373. logs.warn(message, "Extension")
  374. }
  375. console.debug = (...args: unknown[]) => {
  376. const message = argsToMessage(args)
  377. logs.debug(message, "Extension")
  378. }
  379. console.info = (...args: unknown[]) => {
  380. const message = argsToMessage(args)
  381. logs.info(message, "Extension")
  382. }
  383. }
  384. private restoreConsole(): void {
  385. if (this.originalConsole) {
  386. console.log = this.originalConsole.log
  387. console.error = this.originalConsole.error
  388. console.warn = this.originalConsole.warn
  389. console.debug = this.originalConsole.debug
  390. console.info = this.originalConsole.info
  391. this.originalConsole = null
  392. }
  393. // Clean up global console interception
  394. if ((global as unknown as { __interceptedConsole?: unknown }).__interceptedConsole) {
  395. delete (global as unknown as { __interceptedConsole?: unknown }).__interceptedConsole
  396. }
  397. logs.debug("Console methods and streams restored", "ExtensionHost")
  398. }
  399. private async loadExtension(): Promise<void> {
  400. // Use the direct path to extension.js
  401. const extensionPath = this.options.extensionBundlePath
  402. try {
  403. logs.info(`Loading extension from: ${extensionPath}`, "ExtensionHost")
  404. // Use createRequire to load CommonJS module from ES module context
  405. const { createRequire } = await import("module")
  406. const require = createRequire(import.meta.url)
  407. // Get Module class for interception
  408. const Module = await import("module")
  409. interface ModuleClass {
  410. _resolveFilename: (request: string, parent: unknown, isMain: boolean, options?: unknown) => string
  411. prototype: {
  412. _compile: (content: string, filename: string) => unknown
  413. }
  414. }
  415. const ModuleClass = Module.default as unknown as ModuleClass
  416. // Store original methods
  417. const originalResolveFilename = ModuleClass._resolveFilename
  418. const originalCompile = ModuleClass.prototype._compile
  419. // Set up module resolution interception for vscode
  420. ModuleClass._resolveFilename = function (
  421. request: string,
  422. parent: unknown,
  423. isMain: boolean,
  424. options?: unknown,
  425. ) {
  426. if (request === "vscode") {
  427. return "vscode-mock"
  428. }
  429. // Let all other modules (including events) resolve normally since we have dependencies
  430. return originalResolveFilename.call(this, request, parent, isMain, options)
  431. }
  432. // Set up module compilation hook to inject console interception
  433. // This ensures ALL modules (including dependencies) use our intercepted console
  434. ModuleClass.prototype._compile = function (content: string, filename: string) {
  435. // Inject console override at the top of every module
  436. // This makes the intercepted console available to all code in the module
  437. const modifiedContent = `
  438. // Console interception injected by ExtensionHost
  439. const console = global.__interceptedConsole || console;
  440. ${content}
  441. `
  442. return originalCompile.call(this, modifiedContent, filename)
  443. }
  444. // Set up the vscode module in require cache
  445. require.cache["vscode-mock"] = {
  446. id: "vscode-mock",
  447. filename: "vscode-mock",
  448. loaded: true,
  449. parent: null,
  450. children: [],
  451. exports: this.vscodeAPI,
  452. paths: [],
  453. } as unknown as NodeModule
  454. // Clear extension require cache to ensure fresh load
  455. if (require.cache[extensionPath]) {
  456. delete require.cache[extensionPath]
  457. }
  458. // Load the extension module (with console interception active)
  459. this.extensionModule = require(extensionPath)
  460. // Restore original methods
  461. ModuleClass._resolveFilename = originalResolveFilename
  462. ModuleClass.prototype._compile = originalCompile
  463. if (!this.extensionModule) {
  464. throw new Error("Extension module is null or undefined")
  465. }
  466. if (typeof this.extensionModule.activate !== "function") {
  467. throw new Error("Extension module does not export an activate function")
  468. }
  469. logs.info("Extension module loaded successfully", "ExtensionHost")
  470. } catch (error) {
  471. logs.error("Failed to load extension module", "ExtensionHost", { error })
  472. throw new Error(`Failed to load extension: ${error instanceof Error ? error.message : String(error)}`)
  473. }
  474. }
  475. private async activateExtension(): Promise<void> {
  476. try {
  477. // Call the extension's activate function with our mocked context
  478. // Use safeExecute to catch and handle any errors without crashing the CLI
  479. this.extensionAPI =
  480. (await this.safeExecute(
  481. async () => {
  482. if (!this.extensionModule || !this.vscodeAPI) {
  483. throw new Error("Extension module or VSCode API not initialized")
  484. }
  485. logs.info("Calling extension activate function...", "ExtensionHost")
  486. return await this.extensionModule.activate(this.vscodeAPI.context)
  487. },
  488. "extension.activate",
  489. null,
  490. )) ?? null
  491. if (!this.extensionAPI) {
  492. logs.warn(
  493. "Extension activation returned null/undefined, continuing with limited functionality",
  494. "ExtensionHost",
  495. )
  496. }
  497. // Log available API methods for debugging
  498. if (this.extensionAPI) {
  499. logs.info("Extension API methods available:", "ExtensionHost", {
  500. hasStartNewTask: typeof this.extensionAPI.startNewTask === "function",
  501. hasSendMessage: typeof this.extensionAPI.sendMessage === "function",
  502. hasCancelTask: typeof this.extensionAPI.cancelTask === "function",
  503. hasCondense: typeof this.extensionAPI.condense === "function",
  504. hasCondenseTaskContext: typeof this.extensionAPI.condenseTaskContext === "function",
  505. hasHandleTerminalOperation: typeof this.extensionAPI.handleTerminalOperation === "function",
  506. })
  507. } else {
  508. logs.warn("Extension API is null or undefined", "ExtensionHost")
  509. }
  510. logs.info("Extension activate function completed", "ExtensionHost")
  511. // Initialize state from extension
  512. this.initializeState()
  513. // Set up message listener to receive updates from the extension
  514. this.setupExtensionMessageListener()
  515. } catch (error) {
  516. logs.error("Extension activation failed", "ExtensionHost", { error })
  517. throw error
  518. }
  519. }
  520. private setupExtensionMessageListener(): void {
  521. // Listen for extension state updates and forward them
  522. if (this.vscodeAPI && this.vscodeAPI.context) {
  523. // The extension will update state through the webview provider
  524. // We need to listen for those updates and forward them to the CLI
  525. logs.debug("Setting up extension message listener", "ExtensionHost")
  526. // Track message IDs to prevent infinite loops
  527. const processedMessageIds = new Set<string>()
  528. // Listen for messages from the extension's webview (postMessage calls)
  529. this.on(
  530. "extensionWebviewMessage",
  531. (
  532. message: ExtensionMessage & {
  533. payload?: unknown
  534. state?: Partial<ExtensionState>
  535. clineMessage?: unknown
  536. chatMessage?: unknown
  537. listApiConfigMeta?: unknown
  538. },
  539. ) => {
  540. this.safeExecute(() => {
  541. // Create a unique ID for this message to prevent loops
  542. const messageId = `${message.type}_${Date.now()}_${JSON.stringify(message).slice(0, 50)}`
  543. if (processedMessageIds.has(messageId)) {
  544. logs.debug(`Skipping duplicate message: ${message.type}`, "ExtensionHost")
  545. return
  546. }
  547. processedMessageIds.add(messageId)
  548. // Clean up old message IDs to prevent memory leaks
  549. if (processedMessageIds.size > 100) {
  550. const oldestIds = Array.from(processedMessageIds).slice(0, 50)
  551. oldestIds.forEach((id) => processedMessageIds.delete(id))
  552. }
  553. // Track extension message received
  554. getTelemetryService().trackExtensionMessageReceived(message.type)
  555. // Only forward specific message types that are important for CLI
  556. switch (message.type) {
  557. case "state":
  558. // Extension is sending a full state update
  559. if (message.state && this.currentState) {
  560. // Build the new state object, handling optional properties correctly
  561. const newState: ExtensionState = {
  562. ...this.currentState,
  563. ...message.state,
  564. chatMessages:
  565. message.state.clineMessages ||
  566. message.state.chatMessages ||
  567. this.currentState.chatMessages,
  568. apiConfiguration:
  569. message.state.apiConfiguration || this.currentState.apiConfiguration,
  570. }
  571. // Handle optional properties explicitly to satisfy exactOptionalPropertyTypes
  572. if (message.state.currentApiConfigName !== undefined) {
  573. newState.currentApiConfigName = message.state.currentApiConfigName
  574. } else if (this.currentState.currentApiConfigName !== undefined) {
  575. newState.currentApiConfigName = this.currentState.currentApiConfigName
  576. }
  577. if (message.state.listApiConfigMeta !== undefined) {
  578. newState.listApiConfigMeta = message.state.listApiConfigMeta
  579. } else if (this.currentState.listApiConfigMeta !== undefined) {
  580. newState.listApiConfigMeta = this.currentState.listApiConfigMeta
  581. }
  582. if (message.state.routerModels !== undefined) {
  583. newState.routerModels = message.state.routerModels
  584. } else if (this.currentState.routerModels !== undefined) {
  585. newState.routerModels = this.currentState.routerModels
  586. }
  587. this.currentState = newState
  588. // Forward the updated state to the CLI
  589. this.emit("message", {
  590. type: "state",
  591. state: this.currentState,
  592. })
  593. }
  594. break
  595. case "messageUpdated": {
  596. // Extension is sending an individual message update
  597. // The extension uses 'clineMessage' property (legacy name)
  598. const chatMessage = message.clineMessage || message.chatMessage
  599. if (chatMessage) {
  600. // Forward the message update to the CLI
  601. const emitMessage = {
  602. type: "messageUpdated",
  603. chatMessage: chatMessage,
  604. }
  605. this.emit("message", emitMessage)
  606. }
  607. break
  608. }
  609. case "taskHistoryResponse":
  610. // Extension is sending task history data
  611. if (message.payload) {
  612. // Forward the task history response to the CLI
  613. this.emit("message", {
  614. type: "taskHistoryResponse",
  615. payload: message.payload,
  616. })
  617. }
  618. break
  619. // Handle configuration-related messages from extension
  620. case "listApiConfig":
  621. // Extension is sending updated API configuration list
  622. if (
  623. message.listApiConfigMeta &&
  624. this.currentState &&
  625. Array.isArray(message.listApiConfigMeta)
  626. ) {
  627. this.currentState.listApiConfigMeta = message.listApiConfigMeta
  628. logs.debug("Updated listApiConfigMeta from extension", "ExtensionHost")
  629. }
  630. break
  631. // Don't forward these message types as they can cause loops
  632. case "mcpServers":
  633. case "theme":
  634. case "rulesData":
  635. logs.debug(
  636. `Ignoring extension message type to prevent loops: ${message.type}`,
  637. "ExtensionHost",
  638. )
  639. break
  640. default:
  641. // Only forward other important messages
  642. if (message.type && !message.type.startsWith("_")) {
  643. logs.debug(`Forwarding extension message: ${message.type}`, "ExtensionHost")
  644. this.emit("message", message)
  645. }
  646. break
  647. }
  648. }, `extensionWebviewMessage-${message.type}`)
  649. },
  650. )
  651. }
  652. }
  653. private initializeState(): void {
  654. // Create initial state that matches the extension's expected structure
  655. this.currentState = {
  656. version: "1.0.0",
  657. apiConfiguration: {
  658. apiProvider: "kilocode",
  659. kilocodeToken: "",
  660. kilocodeModel: "",
  661. kilocodeOrganizationId: "",
  662. },
  663. chatMessages: [],
  664. mode: "code",
  665. customModes: this.options.customModes || [],
  666. taskHistoryFullLength: 0,
  667. taskHistoryVersion: 0,
  668. renderContext: "cli",
  669. telemetrySetting: "unset", // Start with unset, will be configured by CLI
  670. cwd: this.options.workspacePath,
  671. mcpServers: [],
  672. listApiConfigMeta: [],
  673. currentApiConfigName: "default",
  674. // Enable background editing (preventFocusDisruption) for CLI mode
  675. // This prevents the extension from trying to show VSCode diff views
  676. experiments: {
  677. preventFocusDisruption: true,
  678. morphFastApply: false,
  679. multiFileApplyDiff: false,
  680. powerSteering: false,
  681. imageGeneration: false,
  682. runSlashCommand: false,
  683. },
  684. // Add appendSystemPrompt from CLI options
  685. ...(this.options.appendSystemPrompt && { appendSystemPrompt: this.options.appendSystemPrompt }),
  686. }
  687. // The CLI will inject the actual configuration through updateState
  688. logs.debug("Initial state created, waiting for CLI config injection", "ExtensionHost")
  689. this.broadcastStateUpdate()
  690. }
  691. private async handleWebviewLaunch(): Promise<void> {
  692. // Sync with extension state when webview launches
  693. if (this.extensionAPI && typeof this.extensionAPI.getState === "function") {
  694. try {
  695. const extensionState = await this.safeExecute(
  696. () => {
  697. if (!this.extensionAPI?.getState) {
  698. return null
  699. }
  700. const result = this.extensionAPI.getState()
  701. return result instanceof Promise ? result : Promise.resolve(result)
  702. },
  703. "getState",
  704. null,
  705. )
  706. if (extensionState && this.currentState) {
  707. // Merge extension state with current state, preserving CLI context
  708. const mergedState: ExtensionState = {
  709. ...this.currentState,
  710. apiConfiguration: extensionState.apiConfiguration || this.currentState.apiConfiguration,
  711. mode: extensionState.mode || this.currentState.mode,
  712. chatMessages: extensionState.chatMessages || this.currentState.chatMessages,
  713. }
  714. // Handle optional properties explicitly to satisfy exactOptionalPropertyTypes
  715. if (extensionState.currentApiConfigName !== undefined) {
  716. mergedState.currentApiConfigName = extensionState.currentApiConfigName
  717. }
  718. if (extensionState.listApiConfigMeta !== undefined) {
  719. mergedState.listApiConfigMeta = extensionState.listApiConfigMeta
  720. }
  721. if (extensionState.routerModels !== undefined) {
  722. mergedState.routerModels = extensionState.routerModels
  723. }
  724. this.currentState = mergedState
  725. logs.debug("Synced state with extension on webview launch", "ExtensionHost")
  726. }
  727. } catch (error) {
  728. logs.warn("Failed to sync with extension state on webview launch", "ExtensionHost", { error })
  729. }
  730. }
  731. // Send initial state when webview launches
  732. this.broadcastStateUpdate()
  733. }
  734. /**
  735. * Handle local state updates for CLI display purposes after forwarding to extension
  736. */
  737. private async handleLocalStateUpdates(message: WebviewMessage): Promise<void> {
  738. try {
  739. switch (message.type) {
  740. case "upsertApiConfiguration":
  741. if (message.text && message.apiConfiguration && this.currentState) {
  742. // Update local state for CLI display purposes
  743. this.currentState.apiConfiguration = {
  744. ...this.currentState.apiConfiguration,
  745. ...message.apiConfiguration,
  746. }
  747. this.currentState.currentApiConfigName = message.text
  748. this.broadcastStateUpdate()
  749. }
  750. break
  751. case "loadApiConfiguration":
  752. // Configuration loading is handled by CLI config system
  753. logs.debug(`Profile loading requested but managed by CLI config: ${message.text}`, "ExtensionHost")
  754. break
  755. case "mode":
  756. if (message.text && this.currentState) {
  757. this.currentState.mode = message.text
  758. this.broadcastStateUpdate()
  759. }
  760. break
  761. case "clearTask":
  762. if (this.currentState) {
  763. this.currentState.chatMessages = []
  764. this.broadcastStateUpdate()
  765. }
  766. break
  767. case "selectImages":
  768. // For CLI, we don't support image selection - send empty response
  769. this.emit("message", {
  770. type: "selectedImages",
  771. images: [],
  772. context: message.context || "chat",
  773. messageTs: message.messageTs,
  774. })
  775. break
  776. default:
  777. // No local state updates needed for other message types
  778. break
  779. }
  780. } catch (error) {
  781. logs.error("Error handling local state updates", "ExtensionHost", { error })
  782. }
  783. }
  784. private broadcastStateUpdate(): void {
  785. if (this.currentState) {
  786. const stateMessage: ExtensionMessage = {
  787. type: "state",
  788. state: this.currentState,
  789. }
  790. logs.debug("Broadcasting state update", "ExtensionHost", {
  791. messageCount: this.currentState.chatMessages.length,
  792. mode: this.currentState.mode,
  793. })
  794. this.emit("message", stateMessage)
  795. }
  796. }
  797. public getAPI(): ExtensionAPI {
  798. return {
  799. getState: () => this.currentState,
  800. sendMessage: (message: ExtensionMessage) => {
  801. logs.debug(`Sending message: ${message.type}`, "ExtensionHost")
  802. this.emit("message", message)
  803. },
  804. updateState: (updates: Partial<ExtensionState>) => {
  805. if (this.currentState) {
  806. this.currentState = { ...this.currentState, ...updates }
  807. this.broadcastStateUpdate()
  808. }
  809. },
  810. }
  811. }
  812. /**
  813. * Send configuration sync messages to the extension
  814. * This is the shared logic used by both injectConfiguration and external sync calls
  815. */
  816. public async syncConfigurationMessages(configState: Partial<ExtensionState>): Promise<void> {
  817. // Send API configuration if present
  818. if (configState.apiConfiguration) {
  819. await this.sendWebviewMessage({
  820. type: "upsertApiConfiguration",
  821. text: configState.currentApiConfigName || "default",
  822. apiConfiguration: configState.apiConfiguration,
  823. })
  824. }
  825. // Sync mode if present
  826. if (configState.mode) {
  827. await this.sendWebviewMessage({
  828. type: "mode",
  829. text: configState.mode,
  830. })
  831. }
  832. // Sync telemetry setting if present
  833. if (configState.telemetrySetting) {
  834. await this.sendWebviewMessage({
  835. type: "telemetrySetting",
  836. text: configState.telemetrySetting,
  837. })
  838. logs.debug(`Telemetry setting synchronized: ${configState.telemetrySetting}`, "ExtensionHost")
  839. }
  840. // Sync experiments if present (critical for CLI background editing)
  841. if (configState.experiments || this.currentState?.experiments) {
  842. const experiments = (configState.experiments || this.currentState?.experiments) ?? {}
  843. await this.sendWebviewMessage({
  844. type: "updateSettings",
  845. updatedSettings: { experiments },
  846. })
  847. }
  848. // Sync auto-approval settings to the extension
  849. // These settings control whether the extension auto-approves operations
  850. // or defers to the CLI's approval flow (which prompts the user)
  851. const autoApprovalSettings: Record<string, unknown> = {}
  852. // Only include settings that are explicitly set in configState
  853. if (configState.autoApprovalEnabled !== undefined) {
  854. autoApprovalSettings.autoApprovalEnabled = configState.autoApprovalEnabled
  855. }
  856. if (configState.alwaysAllowReadOnly !== undefined) {
  857. autoApprovalSettings.alwaysAllowReadOnly = configState.alwaysAllowReadOnly
  858. }
  859. if (configState.alwaysAllowReadOnlyOutsideWorkspace !== undefined) {
  860. autoApprovalSettings.alwaysAllowReadOnlyOutsideWorkspace = configState.alwaysAllowReadOnlyOutsideWorkspace
  861. }
  862. if (configState.alwaysAllowWrite !== undefined) {
  863. autoApprovalSettings.alwaysAllowWrite = configState.alwaysAllowWrite
  864. }
  865. if (configState.alwaysAllowWriteOutsideWorkspace !== undefined) {
  866. autoApprovalSettings.alwaysAllowWriteOutsideWorkspace = configState.alwaysAllowWriteOutsideWorkspace
  867. }
  868. if (configState.alwaysAllowWriteProtected !== undefined) {
  869. autoApprovalSettings.alwaysAllowWriteProtected = configState.alwaysAllowWriteProtected
  870. }
  871. if (configState.alwaysAllowBrowser !== undefined) {
  872. autoApprovalSettings.alwaysAllowBrowser = configState.alwaysAllowBrowser
  873. }
  874. if (configState.alwaysApproveResubmit !== undefined) {
  875. autoApprovalSettings.alwaysApproveResubmit = configState.alwaysApproveResubmit
  876. }
  877. if (configState.requestDelaySeconds !== undefined) {
  878. autoApprovalSettings.requestDelaySeconds = configState.requestDelaySeconds
  879. }
  880. if (configState.alwaysAllowMcp !== undefined) {
  881. autoApprovalSettings.alwaysAllowMcp = configState.alwaysAllowMcp
  882. }
  883. if (configState.alwaysAllowModeSwitch !== undefined) {
  884. autoApprovalSettings.alwaysAllowModeSwitch = configState.alwaysAllowModeSwitch
  885. }
  886. if (configState.alwaysAllowSubtasks !== undefined) {
  887. autoApprovalSettings.alwaysAllowSubtasks = configState.alwaysAllowSubtasks
  888. }
  889. if (configState.alwaysAllowExecute !== undefined) {
  890. autoApprovalSettings.alwaysAllowExecute = configState.alwaysAllowExecute
  891. }
  892. if (configState.allowedCommands !== undefined) {
  893. autoApprovalSettings.allowedCommands = configState.allowedCommands
  894. }
  895. if (configState.deniedCommands !== undefined) {
  896. autoApprovalSettings.deniedCommands = configState.deniedCommands
  897. }
  898. if (configState.alwaysAllowFollowupQuestions !== undefined) {
  899. autoApprovalSettings.alwaysAllowFollowupQuestions = configState.alwaysAllowFollowupQuestions
  900. }
  901. if (configState.followupAutoApproveTimeoutMs !== undefined) {
  902. autoApprovalSettings.followupAutoApproveTimeoutMs = configState.followupAutoApproveTimeoutMs
  903. }
  904. if (configState.alwaysAllowUpdateTodoList !== undefined) {
  905. autoApprovalSettings.alwaysAllowUpdateTodoList = configState.alwaysAllowUpdateTodoList
  906. }
  907. // Send auto-approval settings if any are present
  908. if (Object.keys(autoApprovalSettings).length > 0) {
  909. await this.sendWebviewMessage({
  910. type: "updateSettings",
  911. updatedSettings: autoApprovalSettings,
  912. })
  913. logs.debug("Auto-approval settings synchronized to extension", "ExtensionHost", {
  914. settings: Object.keys(autoApprovalSettings),
  915. })
  916. }
  917. // Sync appendSystemPrompt to extension
  918. // This setting is passed from CLI options and needs to be stored in the extension's
  919. // contextProxy so it's available when generating the system prompt
  920. const appendSystemPrompt = configState.appendSystemPrompt || this.options.appendSystemPrompt
  921. if (appendSystemPrompt) {
  922. await this.sendWebviewMessage({
  923. type: "updateSettings",
  924. updatedSettings: { appendSystemPrompt },
  925. })
  926. logs.debug("appendSystemPrompt synchronized to extension", "ExtensionHost", {
  927. length: appendSystemPrompt.length,
  928. })
  929. }
  930. }
  931. /**
  932. * Inject CLI configuration into the extension state
  933. * This should be called after the CLI config is loaded
  934. */
  935. public async injectConfiguration(configState: Partial<ExtensionState>): Promise<void> {
  936. if (!this.currentState) {
  937. logs.warn("Cannot inject configuration: no current state", "ExtensionHost")
  938. return
  939. }
  940. // Preserve experiments from current state when merging
  941. // This ensures CLI-specific settings like preventFocusDisruption are not overwritten
  942. const preservedExperiments = this.currentState.experiments
  943. // Merge the configuration into current state
  944. this.currentState = {
  945. ...this.currentState,
  946. ...configState,
  947. // Restore experiments if they were set in initial state
  948. experiments: preservedExperiments || configState.experiments,
  949. }
  950. // Send configuration to the extension through webview message
  951. // This ensures the extension's internal state is updated
  952. await this.syncConfigurationMessages(configState)
  953. // Broadcast the updated state
  954. this.broadcastStateUpdate()
  955. }
  956. // Methods for webview provider registration (called from VSCode API mock)
  957. registerWebviewProvider(viewId: string, provider: WebviewProvider): void {
  958. this.webviewProviders.set(viewId, provider)
  959. logs.info(`Webview provider registered: ${viewId}`, "ExtensionHost")
  960. }
  961. unregisterWebviewProvider(viewId: string): void {
  962. this.webviewProviders.delete(viewId)
  963. logs.debug(`Unregistered webview provider: ${viewId}`, "ExtensionHost")
  964. }
  965. /**
  966. * Mark webview as ready and flush pending messages
  967. * Called by VSCode mock after resolveWebviewView completes
  968. */
  969. public markWebviewReady(): void {
  970. this.webviewInitialized = true
  971. this.isInitialSetup = false
  972. logs.info("Webview marked as ready, flushing pending messages", "ExtensionHost")
  973. void this.flushPendingMessages()
  974. }
  975. /**
  976. * Flush all pending messages that were queued before webview was ready
  977. */
  978. private async flushPendingMessages(): Promise<void> {
  979. const upsertMessages = this.pendingMessages.filter((m) => m.type === "upsertApiConfiguration")
  980. const otherMessages = this.pendingMessages.filter((m) => m.type !== "upsertApiConfiguration")
  981. this.pendingMessages = []
  982. logs.info(`Flushing ${upsertMessages.length + otherMessages.length} pending messages`, "ExtensionHost")
  983. // Ensure the API configuration is applied before anything tries to read it
  984. for (const message of upsertMessages) {
  985. logs.debug(`Flushing pending message: ${message.type}`, "ExtensionHost")
  986. // Serialize upserts so provider settings are persisted before readers run
  987. await this.sendWebviewMessage(message)
  988. }
  989. for (const message of otherMessages) {
  990. logs.debug(`Flushing pending message: ${message.type}`, "ExtensionHost")
  991. void this.sendWebviewMessage(message)
  992. }
  993. }
  994. /**
  995. * Check if webview is ready to receive messages
  996. */
  997. public isWebviewReady(): boolean {
  998. return this.webviewInitialized
  999. }
  1000. /**
  1001. * Check if this is the initial setup phase
  1002. */
  1003. public isInInitialSetup(): boolean {
  1004. return this.isInitialSetup
  1005. }
  1006. }
  1007. export function createExtensionHost(options: ExtensionHostOptions): ExtensionHost {
  1008. return new ExtensionHost(options)
  1009. }