cli.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774
  1. import { basename } from "node:path"
  2. import { render, Instance, type RenderOptions } from "ink"
  3. import React from "react"
  4. import { createStore } from "jotai"
  5. import { createExtensionService, ExtensionService } from "./services/extension.js"
  6. import { App } from "./ui/App.js"
  7. import { logs } from "./services/logs.js"
  8. import { initializeSyntaxHighlighter } from "./ui/utils/syntaxHighlight.js"
  9. import { supportsTitleSetting } from "./ui/utils/terminalCapabilities.js"
  10. import { extensionServiceAtom } from "./state/atoms/service.js"
  11. import { initializeServiceEffectAtom } from "./state/atoms/effects.js"
  12. import { loadConfigAtom, mappedExtensionStateAtom, providersAtom, saveConfigAtom } from "./state/atoms/config.js"
  13. import { ciExitReasonAtom } from "./state/atoms/ci.js"
  14. import { requestRouterModelsAtom } from "./state/atoms/actions.js"
  15. import { loadHistoryAtom } from "./state/atoms/history.js"
  16. import {
  17. addPendingRequestAtom,
  18. removePendingRequestAtom,
  19. TaskHistoryData,
  20. updateTaskHistoryFiltersAtom,
  21. } from "./state/atoms/taskHistory.js"
  22. import { sendWebviewMessageAtom } from "./state/atoms/actions.js"
  23. import { taskResumedViaContinueOrSessionAtom, currentTaskAtom } from "./state/atoms/extension.js"
  24. import { getTelemetryService, getIdentityManager } from "./services/telemetry/index.js"
  25. import { notificationsAtom, notificationsErrorAtom, notificationsLoadingAtom } from "./state/atoms/notifications.js"
  26. import { fetchKilocodeNotifications } from "./utils/notifications.js"
  27. import { finishParallelMode } from "./parallel/parallel.js"
  28. import { finishWithOnTaskCompleted } from "./pr/on-task-completed.js"
  29. import { isGitWorktree } from "./utils/git.js"
  30. import { Package } from "./constants/package.js"
  31. import type { CLIOptions } from "./types/cli.js"
  32. import type { CLIConfig, ProviderConfig } from "./config/types.js"
  33. import { getModelIdKey } from "./constants/providers/models.js"
  34. import type { ProviderName } from "./types/messages.js"
  35. import { getSelectedModelId } from "./utils/providers.js"
  36. import { KiloCodePathProvider, ExtensionMessengerAdapter } from "./services/session-adapters.js"
  37. import { getKiloToken } from "./config/persistence.js"
  38. import { SessionManager } from "../../src/shared/kilocode/cli-sessions/core/SessionManager.js"
  39. import { triggerExitConfirmationAtom } from "./state/atoms/keyboard.js"
  40. import { randomUUID } from "crypto"
  41. /**
  42. * Main application class that orchestrates the CLI lifecycle
  43. */
  44. export class CLI {
  45. private service: ExtensionService | null = null
  46. private store: ReturnType<typeof createStore> | null = null
  47. private ui: Instance | null = null
  48. private options: CLIOptions
  49. private isInitialized = false
  50. private sessionService: SessionManager | null = null
  51. constructor(options: CLIOptions = {}) {
  52. this.options = options
  53. }
  54. /**
  55. * Initialize the application
  56. * - Creates ExtensionService
  57. * - Sets up Jotai store
  58. * - Initializes service through effects
  59. */
  60. async initialize(): Promise<void> {
  61. if (this.isInitialized) {
  62. logs.warn("Application already initialized", "CLI")
  63. return
  64. }
  65. try {
  66. logs.info("Initializing Kilo Code CLI...", "CLI")
  67. logs.info(`Version: ${Package.version}`, "CLI")
  68. // Initialize syntax highlighter early so it's ready when diffs are displayed
  69. // This runs in the background and doesn't block startup
  70. void initializeSyntaxHighlighter().then(() => {
  71. logs.debug("Syntax highlighter initialized", "CLI")
  72. })
  73. // Set terminal title - use process.cwd() in parallel mode to show original directory
  74. const titleWorkspace = this.options.parallel ? process.cwd() : this.options.workspace || process.cwd()
  75. const folderName = `${basename(titleWorkspace)}${(await isGitWorktree(this.options.workspace || "")) ? " ⎇" : ""}`
  76. if (supportsTitleSetting()) {
  77. process.stdout.write(`\x1b]0;Kilo Code - ${folderName}\x07`)
  78. }
  79. // Create Jotai store
  80. this.store = createStore()
  81. logs.debug("Jotai store created", "CLI")
  82. // Initialize telemetry service first to get identity
  83. let config = await this.store.set(loadConfigAtom, this.options.mode)
  84. logs.debug("CLI configuration loaded", "CLI", { mode: this.options.mode })
  85. // Apply provider and model overrides from CLI
  86. if (this.options.provider || this.options.model) {
  87. config = await this.applyProviderModelOverrides(config)
  88. // Save the updated config to persist changes
  89. await this.store.set(saveConfigAtom, config)
  90. logs.info("Provider/model overrides applied and saved", "CLI")
  91. }
  92. const telemetryService = getTelemetryService()
  93. await telemetryService.initialize(config, {
  94. workspace: this.options.workspace || process.cwd(),
  95. mode: this.options.mode || "code",
  96. ciMode: this.options.ci || false,
  97. })
  98. logs.debug("Telemetry service initialized", "CLI")
  99. // Get identity from Identity Manager
  100. const identityManager = getIdentityManager()
  101. const identity = identityManager.getIdentity()
  102. // Create ExtensionService with identity
  103. const serviceOptions: Parameters<typeof createExtensionService>[0] = {
  104. workspace: this.options.workspace || process.cwd(),
  105. mode: this.options.mode || "code",
  106. }
  107. if (identity) {
  108. serviceOptions.identity = {
  109. machineId: identity.machineId,
  110. sessionId: identity.sessionId,
  111. cliUserId: identity.cliUserId,
  112. }
  113. }
  114. if (this.options.customModes) {
  115. serviceOptions.customModes = this.options.customModes
  116. }
  117. if (this.options.appendSystemPrompt) {
  118. serviceOptions.appendSystemPrompt = this.options.appendSystemPrompt
  119. }
  120. this.service = createExtensionService(serviceOptions)
  121. logs.debug("ExtensionService created with identity", "CLI", {
  122. hasIdentity: !!identity,
  123. })
  124. // Set service in store
  125. this.store.set(extensionServiceAtom, this.service)
  126. logs.debug("ExtensionService set in store", "CLI")
  127. // Track extension initialization
  128. telemetryService.trackExtensionInitialized(false) // Will be updated after actual initialization
  129. // Initialize service through effect atom
  130. // This sets up all event listeners and activates the extension
  131. await this.store.set(initializeServiceEffectAtom, this.store)
  132. logs.info("ExtensionService initialized through effects", "CLI")
  133. // Track successful extension initialization
  134. telemetryService.trackExtensionInitialized(true)
  135. // Initialize services and restore session if kiloToken is available
  136. // This must happen AFTER ExtensionService initialization to allow webview messages
  137. const kiloToken = getKiloToken(config)
  138. if (kiloToken) {
  139. // Inject CLI configuration into ExtensionHost
  140. // This must happen BEFORE session restoration to ensure org ID is set
  141. await this.injectConfigurationToExtension()
  142. logs.debug("CLI configuration injected into extension", "CLI")
  143. const pathProvider = new KiloCodePathProvider()
  144. const extensionMessenger = new ExtensionMessengerAdapter(this.service)
  145. this.sessionService = SessionManager.init({
  146. pathProvider,
  147. logger: logs,
  148. extensionMessenger,
  149. getToken: () => Promise.resolve(kiloToken),
  150. onSessionCreated: (message) => {
  151. if (this.options.json) {
  152. console.log(JSON.stringify(message))
  153. }
  154. },
  155. onSessionRestored: () => {
  156. if (this.store) {
  157. this.store.set(taskResumedViaContinueOrSessionAtom, true)
  158. }
  159. },
  160. onSessionSynced: (message) => {
  161. if (this.options.json) {
  162. console.log(JSON.stringify(message))
  163. }
  164. },
  165. onSessionTitleGenerated: (message) => {
  166. if (this.options.json) {
  167. console.log(JSON.stringify(message))
  168. }
  169. },
  170. platform: "cli",
  171. getOrganizationId: async () => {
  172. const state = this.service?.getState()
  173. const result = state?.apiConfiguration?.kilocodeOrganizationId
  174. logs.debug(`Resolved organization ID: "${result}"`, "SessionManager")
  175. return result
  176. },
  177. getMode: async () => {
  178. const state = this.service?.getState()
  179. const result = state?.mode
  180. logs.debug(`Resolved mode: "${result}"`, "SessionManager")
  181. return result
  182. },
  183. getModel: async () => {
  184. const state = this.service?.getState()
  185. const provider = state?.apiConfiguration?.apiProvider
  186. const result = getSelectedModelId(provider || "unknown", state?.apiConfiguration)
  187. logs.debug(`Resolved model: "${result}"`, "SessionManager")
  188. return result
  189. },
  190. getParentTaskId: async (taskId: string) => {
  191. const result = await (async () => {
  192. try {
  193. // Check if the current task matches the taskId
  194. const currentTask = this.store?.get(currentTaskAtom)
  195. if (currentTask?.id === taskId) {
  196. return currentTask.parentTaskId
  197. }
  198. // Otherwise, fetch the task from history using promise-based request/response pattern
  199. const requestId = randomUUID()
  200. // Create a promise that will be resolved when the response arrives
  201. const responsePromise = new Promise<TaskHistoryData>((resolve, reject) => {
  202. const timeout = setTimeout(() => {
  203. reject(new Error("Task history request timed out"))
  204. }, 5000) // 5 second timeout as fallback
  205. this.store?.set(addPendingRequestAtom, {
  206. requestId,
  207. resolve,
  208. reject,
  209. timeout,
  210. })
  211. })
  212. // Send task history request to get the specific task
  213. await this.store?.set(sendWebviewMessageAtom, {
  214. type: "taskHistoryRequest",
  215. payload: {
  216. requestId,
  217. workspace: "current",
  218. sort: "newest",
  219. favoritesOnly: false,
  220. pageIndex: 0,
  221. },
  222. })
  223. // Wait for the actual response (not a timer)
  224. const taskHistoryData = await responsePromise
  225. const task = taskHistoryData.historyItems.find((item) => item.id === taskId)
  226. return task?.parentTaskId
  227. } catch {
  228. return undefined
  229. }
  230. })()
  231. logs.debug(`Resolved parent task ID for task ${taskId}: "${result}"`, "SessionManager")
  232. return result || undefined
  233. },
  234. })
  235. logs.debug("SessionManager initialized with dependencies", "CLI")
  236. const workspace = this.options.workspace || process.cwd()
  237. this.sessionService?.setWorkspaceDirectory(workspace)
  238. logs.debug("SessionManager workspace directory set", "CLI", { workspace })
  239. if (this.options.session) {
  240. // Set flag BEFORE restoring session to prevent race condition
  241. // The session restoration triggers async state updates that may contain
  242. // historical completion_result messages. Without this flag set first,
  243. // the CI exit logic may trigger before the prompt can execute.
  244. this.store.set(taskResumedViaContinueOrSessionAtom, true)
  245. await this.sessionService?.restoreSession(this.options.session)
  246. } else if (this.options.fork) {
  247. // Set flag BEFORE forking session (same race condition as restore)
  248. this.store.set(taskResumedViaContinueOrSessionAtom, true)
  249. logs.info("Forking session from share ID", "CLI", { shareId: this.options.fork })
  250. await this.sessionService?.forkSession(this.options.fork)
  251. }
  252. }
  253. // Load command history
  254. await this.store.set(loadHistoryAtom)
  255. logs.debug("Command history loaded", "CLI")
  256. // Inject CLI configuration into ExtensionHost
  257. // This happens after session restoration (if any) to ensure CLI config takes precedence
  258. // Session restoration may have activated a saved profile that doesn't include org ID from env vars
  259. await this.injectConfigurationToExtension()
  260. logs.debug("CLI configuration injected into extension", "CLI")
  261. const extensionHost = this.service.getExtensionHost()
  262. // In JSON-IO mode, don't set yoloMode on the extension host.
  263. // This prevents Task.ts from auto-answering followup questions.
  264. // The CLI's approval layer handles YOLO behavior and correctly excludes followups.
  265. if (!this.options.jsonInteractive) {
  266. extensionHost.sendWebviewMessage({
  267. type: "yoloMode",
  268. bool: Boolean(this.options.ci || this.options.yolo),
  269. })
  270. }
  271. // Request router models after configuration is injected
  272. void this.requestRouterModels()
  273. if (!this.options.ci && !this.options.prompt) {
  274. // Fetch Kilocode notifications if provider is kilocode
  275. void this.fetchNotifications()
  276. }
  277. // Resume conversation if continue mode is enabled
  278. if (this.options.continue) {
  279. await this.resumeLastConversation()
  280. }
  281. this.isInitialized = true
  282. logs.info("Kilo Code CLI initialized successfully", "CLI")
  283. } catch (error) {
  284. logs.error("Failed to initialize CLI", "CLI", { error })
  285. throw error
  286. }
  287. }
  288. /**
  289. * Start the application
  290. * - Initializes if not already done
  291. * - Renders the UI
  292. * - Waits for exit
  293. */
  294. async start(): Promise<void> {
  295. // Initialize if not already done
  296. if (!this.isInitialized) {
  297. await this.initialize()
  298. }
  299. if (!this.store) {
  300. throw new Error("Store not initialized")
  301. }
  302. // Render UI with store
  303. // Disable stdin for Ink when in CI mode or when stdin is piped (not a TTY)
  304. // This prevents the "Raw mode is not supported" error
  305. const shouldDisableStdin = this.options.jsonInteractive || this.options.ci || !process.stdin.isTTY
  306. const renderOptions: RenderOptions = {
  307. // Enable Ink's incremental renderer to avoid redrawing the entire screen on every update.
  308. // This reduces flickering for frequently updating UIs.
  309. incrementalRendering: true,
  310. exitOnCtrlC: false,
  311. ...(shouldDisableStdin ? { stdout: process.stdout, stderr: process.stderr } : {}),
  312. }
  313. this.ui = render(
  314. React.createElement(App, {
  315. store: this.store,
  316. options: {
  317. mode: this.options.mode || "code",
  318. workspace: this.options.workspace || process.cwd(),
  319. ci: this.options.ci || false,
  320. yolo: this.options.yolo || false,
  321. json: this.options.json || false,
  322. jsonInteractive: this.options.jsonInteractive || false,
  323. prompt: this.options.prompt || "",
  324. ...(this.options.timeout !== undefined && { timeout: this.options.timeout }),
  325. parallel: this.options.parallel || false,
  326. worktreeBranch: this.options.worktreeBranch || undefined,
  327. noSplash: this.options.noSplash || false,
  328. attachments: this.options.attachments,
  329. },
  330. onExit: () => this.dispose(),
  331. }),
  332. renderOptions,
  333. )
  334. // Wait for UI to exit
  335. await this.ui.waitUntilExit()
  336. }
  337. /**
  338. * Apply provider and model overrides from CLI options
  339. */
  340. private async applyProviderModelOverrides(config: CLIConfig): Promise<CLIConfig> {
  341. const updatedConfig = { ...config }
  342. // Apply provider override
  343. if (this.options.provider) {
  344. const provider = config.providers.find((p) => p.id === this.options.provider)
  345. if (provider) {
  346. updatedConfig.provider = this.options.provider
  347. logs.info(`Provider overridden to: ${this.options.provider}`, "CLI")
  348. }
  349. }
  350. // Apply model override
  351. if (this.options.model) {
  352. const activeProviderId = updatedConfig.provider
  353. const providerIndex = updatedConfig.providers.findIndex((p) => p.id === activeProviderId)
  354. if (providerIndex !== -1) {
  355. const provider = updatedConfig.providers[providerIndex]
  356. if (provider) {
  357. const modelField = getModelIdKey(provider.provider as ProviderName)
  358. // Update the provider's model field
  359. updatedConfig.providers[providerIndex] = {
  360. ...provider,
  361. [modelField]: this.options.model,
  362. } as ProviderConfig
  363. logs.info(`Model overridden to: ${this.options.model} for provider ${activeProviderId}`, "CLI")
  364. }
  365. }
  366. }
  367. return updatedConfig
  368. }
  369. private isDisposing = false
  370. /**
  371. * Dispose the application and clean up resources
  372. * - Unmounts UI
  373. * - Disposes service
  374. * - Cleans up store
  375. */
  376. async dispose(signal?: string): Promise<void> {
  377. if (this.isDisposing) {
  378. logs.info("Already disposing, ignoring duplicate dispose call", "CLI")
  379. return
  380. }
  381. this.isDisposing = true
  382. // Determine exit code based on signal type and CI mode
  383. let exitCode = 0
  384. // beforeExit may be an async cleanup function or a sync one. Default to noop.
  385. let beforeExit: (() => Promise<void>) | (() => void) = async () => {}
  386. try {
  387. logs.info("Disposing Kilo Code CLI...", "CLI")
  388. await this.sessionService?.doSync(true)
  389. // Signal codes take precedence over CI logic
  390. if (signal === "SIGINT") {
  391. exitCode = 130
  392. logs.info("Exiting with SIGINT code (130)", "CLI")
  393. } else if (signal === "SIGTERM") {
  394. exitCode = 143
  395. logs.info("Exiting with SIGTERM code (143)", "CLI")
  396. } else if (this.options.ci && this.store) {
  397. // CI mode logic only when not interrupted by signal
  398. const exitReason = this.store.get(ciExitReasonAtom)
  399. // Set exit code based on the actual exit reason
  400. if (exitReason === "timeout") {
  401. exitCode = 124
  402. logs.warn("Exiting with timeout code", "CLI")
  403. // Track CI mode timeout
  404. getTelemetryService().trackCIModeTimeout()
  405. } else if (exitReason === "completion_result" || exitReason === "command_finished") {
  406. exitCode = 0
  407. logs.info("Exiting with success code", "CLI", { reason: exitReason })
  408. } else {
  409. // No exit reason set - this shouldn't happen in normal flow
  410. exitCode = 1
  411. logs.info("Exiting with default failure code", "CLI")
  412. }
  413. }
  414. // In parallel mode, we need to do manual git worktree cleanup
  415. if (this.options.parallel) {
  416. const cleanup = await finishParallelMode(this, this.options.workspace!, this.options.worktreeBranch!)
  417. if (typeof cleanup === "function") {
  418. beforeExit = cleanup as (() => Promise<void>) | (() => void)
  419. } else {
  420. beforeExit = async () => {}
  421. }
  422. }
  423. // Handle --on-task-completed flag (only if not in parallel mode, which has its own flow)
  424. if (this.options.onTaskCompleted && !this.options.parallel) {
  425. const onTaskCompletedBeforeExit = await finishWithOnTaskCompleted(this, {
  426. cwd: this.options.workspace || process.cwd(),
  427. prompt: this.options.onTaskCompleted,
  428. })
  429. const originalBeforeExit = beforeExit
  430. beforeExit = () => {
  431. originalBeforeExit()
  432. onTaskCompletedBeforeExit()
  433. }
  434. }
  435. // Shutdown telemetry service before exiting
  436. const telemetryService = getTelemetryService()
  437. await telemetryService.shutdown()
  438. logs.debug("Telemetry service shut down", "CLI")
  439. // Unmount UI
  440. if (this.ui) {
  441. await this.ui.unmount()
  442. this.ui = null
  443. }
  444. // Dispose service
  445. if (this.service) {
  446. await this.service.dispose()
  447. this.service = null
  448. }
  449. // Clear store reference
  450. this.store = null
  451. this.isInitialized = false
  452. logs.info("Kilo Code CLI disposed", "CLI")
  453. } catch (error) {
  454. logs.error("Error disposing CLI", "CLI", { error })
  455. exitCode = 1
  456. } finally {
  457. try {
  458. // Await cleanup in case it's async; catch and log errors but don't prevent process.exit
  459. await Promise.resolve(beforeExit())
  460. } catch (err) {
  461. logs.error("Error during beforeExit cleanup", "CLI", { error: err })
  462. }
  463. // Exit process with appropriate code
  464. process.exit(exitCode)
  465. }
  466. }
  467. /**
  468. * Inject CLI configuration into the extension host
  469. */
  470. private async injectConfigurationToExtension(): Promise<void> {
  471. if (!this.service || !this.store) {
  472. logs.warn("Cannot inject configuration: service or store not available", "CLI")
  473. return
  474. }
  475. try {
  476. // Get the mapped extension state from config atoms
  477. const mappedState = this.store.get(mappedExtensionStateAtom)
  478. logs.debug("Mapped config state for injection", "CLI", {
  479. mode: mappedState.mode,
  480. telemetry: mappedState.telemetrySetting,
  481. provider: mappedState.currentApiConfigName,
  482. })
  483. // Get the extension host from the service
  484. const extensionHost = this.service.getExtensionHost()
  485. // Inject the configuration (await to ensure mode/telemetry messages are sent)
  486. await extensionHost.injectConfiguration(mappedState)
  487. logs.info("Configuration injected into extension host", "CLI")
  488. } catch (error) {
  489. logs.error("Failed to inject configuration into extension host", "CLI", { error })
  490. }
  491. }
  492. /**
  493. * Request router models from the extension
  494. */
  495. private async requestRouterModels(): Promise<void> {
  496. if (!this.service || !this.store) {
  497. logs.warn("Cannot request router models: service or store not available", "CLI")
  498. return
  499. }
  500. try {
  501. await this.store.set(requestRouterModelsAtom)
  502. logs.debug("Router models requested", "CLI")
  503. } catch (error) {
  504. logs.error("Failed to request router models", "CLI", { error })
  505. }
  506. }
  507. /**
  508. * Fetch notifications from Kilocode backend if provider is kilocode
  509. */
  510. private async fetchNotifications(): Promise<void> {
  511. if (!this.store) {
  512. logs.warn("Cannot fetch notifications: store not available", "CLI")
  513. return
  514. }
  515. try {
  516. const providers = this.store.get(providersAtom)
  517. const provider = providers.find(({ provider }) => provider === "kilocode")
  518. if (!provider) {
  519. logs.debug("No provider configured, skipping notification fetch", "CLI")
  520. return
  521. }
  522. this.store.set(notificationsLoadingAtom, true)
  523. const notifications = await fetchKilocodeNotifications(provider)
  524. this.store.set(notificationsAtom, notifications)
  525. } catch (error) {
  526. const err = error instanceof Error ? error : new Error(String(error))
  527. this.store.set(notificationsErrorAtom, err)
  528. logs.error("Failed to fetch notifications", "CLI", { error })
  529. } finally {
  530. this.store.set(notificationsLoadingAtom, false)
  531. }
  532. }
  533. /**
  534. * Resume the last conversation from the current workspace
  535. */
  536. private async resumeLastConversation(): Promise<void> {
  537. if (!this.service || !this.store) {
  538. logs.error("Cannot resume conversation: service or store not available", "CLI")
  539. throw new Error("Service or store not initialized")
  540. }
  541. const workspace = this.options.workspace || process.cwd()
  542. try {
  543. logs.info("Attempting to resume last conversation", "CLI", { workspace })
  544. // First, try to restore from persisted session ID if kiloToken is available
  545. if (this.sessionService) {
  546. const restored = await this.sessionService.restoreLastSession()
  547. if (restored) {
  548. return
  549. }
  550. logs.debug("Falling back to task history", "CLI")
  551. }
  552. // Fallback: Use task history approach
  553. logs.debug("Using task history fallback to resume conversation", "CLI")
  554. // Update filters to current workspace and newest sort
  555. this.store.set(updateTaskHistoryFiltersAtom, {
  556. workspace: "current",
  557. sort: "newest",
  558. favoritesOnly: false,
  559. })
  560. // Create a unique request ID for tracking the response
  561. const requestId = `${Date.now()}-${Math.random()}`
  562. const TASK_HISTORY_TIMEOUT_MS = 5000
  563. // Fetch task history with Promise-based response handling
  564. const taskHistoryData = await new Promise<TaskHistoryData>((resolve, reject) => {
  565. // Set up timeout
  566. const timeout = setTimeout(() => {
  567. this.store!.set(removePendingRequestAtom, requestId)
  568. reject(new Error(`Task history request timed out after ${TASK_HISTORY_TIMEOUT_MS}ms`))
  569. }, TASK_HISTORY_TIMEOUT_MS)
  570. // Register the pending request - it will be resolved when the response arrives
  571. this.store!.set(addPendingRequestAtom, { requestId, resolve, reject, timeout })
  572. // Send task history request to extension
  573. this.store!.set(sendWebviewMessageAtom, {
  574. type: "taskHistoryRequest",
  575. payload: {
  576. requestId,
  577. workspace: "current",
  578. sort: "newest",
  579. favoritesOnly: false,
  580. pageIndex: 0,
  581. },
  582. }).catch((err) => {
  583. this.store!.set(removePendingRequestAtom, requestId)
  584. reject(err)
  585. })
  586. })
  587. if (!taskHistoryData || !taskHistoryData.historyItems || taskHistoryData.historyItems.length === 0) {
  588. logs.warn("No previous tasks found for workspace", "CLI", { workspace })
  589. console.error("\nNo previous tasks found for this workspace. Please start a new conversation.\n")
  590. process.exit(1)
  591. }
  592. // Find the most recent task (first in the list since we sorted by newest)
  593. const lastTask = taskHistoryData.historyItems[0]
  594. if (!lastTask) {
  595. logs.warn("No valid task found in history", "CLI", { workspace })
  596. console.error("\nNo valid task found to resume. Please start a new conversation.\n")
  597. process.exit(1)
  598. }
  599. logs.debug("Found last task", "CLI", { taskId: lastTask.id, task: lastTask.task })
  600. // Send message to resume the task
  601. await this.store.set(sendWebviewMessageAtom, {
  602. type: "showTaskWithId",
  603. text: lastTask.id,
  604. })
  605. // Mark that the task was resumed via --continue to prevent showing "Task ready to resume" message
  606. this.store.set(taskResumedViaContinueOrSessionAtom, true)
  607. logs.info("Task resume initiated", "CLI", { taskId: lastTask.id, task: lastTask.task })
  608. } catch (error) {
  609. logs.error("Failed to resume conversation", "CLI", { error, workspace })
  610. const errorMessage = error instanceof Error ? error.message : String(error)
  611. if (errorMessage.includes("timed out")) {
  612. console.error("\nFailed to fetch task history (request timed out). Please try again.\n")
  613. } else {
  614. console.error("\nFailed to resume conversation. Please try starting a new conversation.\n")
  615. }
  616. process.exit(1)
  617. }
  618. }
  619. /**
  620. * Get the ExtensionService instance
  621. */
  622. getService(): ExtensionService | null {
  623. return this.service
  624. }
  625. /**
  626. * Get the Jotai store instance
  627. */
  628. getStore(): ReturnType<typeof createStore> | null {
  629. return this.store
  630. }
  631. /**
  632. * Returns true if the CLI should show an exit confirmation prompt for SIGINT.
  633. */
  634. shouldConfirmExitOnSigint(): boolean {
  635. return (
  636. !!this.store &&
  637. !this.options.ci &&
  638. !this.options.json &&
  639. !this.options.jsonInteractive &&
  640. process.stdin.isTTY
  641. )
  642. }
  643. /**
  644. * Trigger the exit confirmation prompt. Returns true if handled.
  645. */
  646. requestExitConfirmation(): boolean {
  647. if (!this.shouldConfirmExitOnSigint()) {
  648. return false
  649. }
  650. this.store?.set(triggerExitConfirmationAtom)
  651. return true
  652. }
  653. /**
  654. * Check if the application is initialized
  655. */
  656. isReady(): boolean {
  657. return this.isInitialized
  658. }
  659. }