diff --git a/src/__tests__/main/agents/detector.test.ts b/src/__tests__/main/agents/detector.test.ts index cfedf5f4f6..f97766185a 100644 --- a/src/__tests__/main/agents/detector.test.ts +++ b/src/__tests__/main/agents/detector.test.ts @@ -278,8 +278,8 @@ describe('agent-detector', () => { const agents = await detector.detectAgents(); - // Should have all 8 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider) - expect(agents.length).toBe(8); + // Should have all 9 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, kilo, factory-droid, aider) + expect(agents.length).toBe(9); const agentIds = agents.map((a) => a.id); expect(agentIds).toContain('terminal'); @@ -288,6 +288,7 @@ describe('agent-detector', () => { expect(agentIds).toContain('gemini-cli'); expect(agentIds).toContain('qwen3-coder'); expect(agentIds).toContain('opencode'); + expect(agentIds).toContain('kilo'); expect(agentIds).toContain('factory-droid'); expect(agentIds).toContain('aider'); }); @@ -924,8 +925,8 @@ describe('agent-detector', () => { const result = await detectPromise; expect(result).toBeDefined(); - // Should have all 8 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider) - expect(result.length).toBe(8); + // Should have all 9 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, kilo, factory-droid, aider) + expect(result.length).toBe(9); }); it('should handle very long PATH', async () => { diff --git a/src/__tests__/main/parsers/index.test.ts b/src/__tests__/main/parsers/index.test.ts index 792f404fea..91daf457ed 100644 --- a/src/__tests__/main/parsers/index.test.ts +++ b/src/__tests__/main/parsers/index.test.ts @@ -8,6 +8,7 @@ import { clearParserRegistry, ClaudeOutputParser, OpenCodeOutputParser, + KiloOutputParser, CodexOutputParser, } from '../../../main/parsers'; @@ -33,6 +34,14 @@ describe('parsers/index', () => { expect(hasOutputParser('opencode')).toBe(true); }); + it('should register Kilo parser', () => { + expect(hasOutputParser('kilo')).toBe(false); + + initializeOutputParsers(); + + expect(hasOutputParser('kilo')).toBe(true); + }); + it('should register Codex parser', () => { expect(hasOutputParser('codex')).toBe(false); @@ -49,21 +58,21 @@ describe('parsers/index', () => { expect(hasOutputParser('factory-droid')).toBe(true); }); - it('should register exactly 4 parsers', () => { + it('should register exactly 5 parsers', () => { initializeOutputParsers(); const parsers = getAllOutputParsers(); - expect(parsers.length).toBe(4); // Claude, OpenCode, Codex, Factory Droid + expect(parsers.length).toBe(5); // Claude, OpenCode, Kilo, Codex, Factory Droid }); it('should clear existing parsers before registering', () => { // First initialization initializeOutputParsers(); - expect(getAllOutputParsers().length).toBe(4); + expect(getAllOutputParsers().length).toBe(5); - // Second initialization should still have exactly 4 + // Second initialization should still have exactly 5 initializeOutputParsers(); - expect(getAllOutputParsers().length).toBe(4); + expect(getAllOutputParsers().length).toBe(5); }); }); @@ -73,7 +82,7 @@ describe('parsers/index', () => { ensureParsersInitialized(); - expect(getAllOutputParsers().length).toBe(4); + expect(getAllOutputParsers().length).toBe(5); }); it('should be idempotent after first call', () => { @@ -132,6 +141,11 @@ describe('parsers/index', () => { expect(parser.agentId).toBe('opencode'); }); + it('should export KiloOutputParser class', () => { + const parser = new KiloOutputParser(); + expect(parser.agentId).toBe('kilo'); + }); + it('should export CodexOutputParser class', () => { const parser = new CodexOutputParser(); expect(parser.agentId).toBe('codex'); diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 6e7a4a0f21..38e08bfd92 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -7,6 +7,7 @@ import type { ToolType, UsageStats } from '../../shared/types'; import type { AgentOutputParser } from '../../main/parsers/agent-output-parser'; import { CodexOutputParser } from '../../main/parsers/codex-output-parser'; import { OpenCodeOutputParser } from '../../main/parsers/opencode-output-parser'; +import { KiloOutputParser } from '../../main/parsers/kilo-output-parser'; import { FactoryDroidOutputParser } from '../../main/parsers/factory-droid-output-parser'; import { aggregateModelUsage } from '../../main/parsers/usage-aggregator'; import { getAgentDefinition } from '../../main/agents/definitions'; @@ -141,6 +142,7 @@ export async function detectAgent(toolType: ToolType): Promise { export const detectClaude = () => detectAgent('claude-code'); export const detectCodex = () => detectAgent('codex'); export const detectOpenCode = () => detectAgent('opencode'); +export const detectKilo = () => detectAgent('kilo'); export const detectDroid = () => detectAgent('factory-droid'); /** @@ -158,6 +160,7 @@ export function getAgentCommand(toolType: ToolType): string { export const getClaudeCommand = () => getAgentCommand('claude-code'); export const getCodexCommand = () => getAgentCommand('codex'); export const getOpenCodeCommand = () => getAgentCommand('opencode'); +export const getKiloCommand = () => getAgentCommand('kilo'); export const getDroidCommand = () => getAgentCommand('factory-droid'); /** @@ -324,6 +327,8 @@ function createParser(toolType: ToolType): AgentOutputParser { return new CodexOutputParser(); case 'opencode': return new OpenCodeOutputParser(); + case 'kilo': + return new KiloOutputParser(); case 'factory-droid': return new FactoryDroidOutputParser(); default: diff --git a/src/main/agents/capabilities.ts b/src/main/agents/capabilities.ts index fece986819..10e64438f3 100644 --- a/src/main/agents/capabilities.ts +++ b/src/main/agents/capabilities.ts @@ -321,6 +321,39 @@ export const AGENT_CAPABILITIES: Record = { usesCombinedContextWindow: false, // Depends on model provider }, + /** + * Kilo (KiloCode) - A fork of OpenCode with identical CLI surface. + * https://github.com/Kilo-Org/kilocode + * + * Capabilities mirror OpenCode since KiloCode is a 1:1 fork with the + * same CLI flags, JSON output format, and session storage layout. + */ + kilo: { + supportsResume: true, + supportsReadOnlyMode: true, + supportsJsonOutput: true, + supportsSessionId: true, + supportsImageInput: true, + supportsImageInputOnResume: true, + supportsSlashCommands: false, + supportsSessionStorage: true, + supportsCostTracking: true, + supportsUsageStats: true, + supportsBatchMode: true, + requiresPromptToStart: true, + supportsStreaming: true, + supportsResultMessages: true, + supportsModelSelection: true, + supportsStreamJsonInput: false, + supportsThinkingDisplay: true, + supportsContextMerge: true, + supportsContextExport: true, + supportsWizard: true, + supportsGroupChatModeration: true, + usesJsonLineOutput: true, + usesCombinedContextWindow: false, + }, + /** * Factory Droid - Enterprise AI coding assistant from Factory * https://docs.factory.ai/cli diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 8693830459..805492a770 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -307,6 +307,51 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ }, ], }, + { + id: 'kilo', + name: 'Kilo', + binaryName: 'kilo', + command: 'kilo', + args: [], // KiloCode is a 1:1 fork of OpenCode; same CLI surface + batchModePrefix: ['run'], + jsonOutputArgs: ['--format', 'json'], + resumeArgs: (sessionId: string) => ['--session', sessionId], + readOnlyArgs: ['--agent', 'plan'], + readOnlyCliEnforced: true, + modelArgs: (modelId: string) => ['--model', modelId], + imageArgs: (imagePath: string) => ['-f', imagePath], + defaultEnvVars: { + KILO_CONFIG_CONTENT: + '{"permission":{"*":"allow","external_directory":"allow","question":"deny"},"tools":{"question":false}}', + }, + readOnlyEnvOverrides: { + KILO_CONFIG_CONTENT: '{"permission":{"question":"deny"},"tools":{"question":false}}', + }, + configOptions: [ + { + key: 'model', + type: 'text', + label: 'Model', + description: + 'Model to use (e.g., "ollama/qwen3:8b", "anthropic/claude-sonnet-4-20250514"). Leave empty for default.', + default: '', + argBuilder: (value: string) => { + if (value && value.trim()) { + return ['--model', value.trim()]; + } + return []; + }, + }, + { + key: 'contextWindow', + type: 'number', + label: 'Context Window Size', + description: + 'Maximum context window size in tokens. Required for context usage display. Varies by model.', + default: 128000, + }, + ], + }, { id: 'factory-droid', name: 'Factory Droid', diff --git a/src/main/agents/detector.ts b/src/main/agents/detector.ts index 01cd99bf9d..0435bb4c59 100644 --- a/src/main/agents/detector.ts +++ b/src/main/agents/detector.ts @@ -251,8 +251,9 @@ export class AgentDetector { try { // Agent-specific model discovery commands switch (agentId) { - case 'opencode': { - // OpenCode: `opencode models` returns one model per line + case 'opencode': + case 'kilo': { + // OpenCode / Kilo: ` models` returns one model per line const result = await execFileNoThrow(command, ['models'], undefined, env); if (result.exitCode !== 0) { diff --git a/src/main/agents/path-prober.ts b/src/main/agents/path-prober.ts index 689dcf9bc2..42a9acba74 100644 --- a/src/main/agents/path-prober.ts +++ b/src/main/agents/path-prober.ts @@ -93,6 +93,7 @@ export function getExpandedEnv(): NodeJS.ProcessEnv { // Scoop package manager (OpenCode, other tools) path.join(home, 'scoop', 'shims'), path.join(home, 'scoop', 'apps', 'opencode', 'current'), + path.join(home, 'scoop', 'apps', 'kilo', 'current'), // Chocolatey (OpenCode, other tools) path.join(process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin'), // Go binaries (some tools installed via 'go install') @@ -113,6 +114,7 @@ export function getExpandedEnv(): NodeJS.ProcessEnv { `${home}/bin`, // User bin directory `${home}/.claude/local`, // Claude local install location `${home}/.opencode/bin`, // OpenCode installer default location + `${home}/.kilo/bin`, // Kilo (KiloCode) installer default location '/usr/bin', '/bin', '/usr/sbin', @@ -308,6 +310,20 @@ function getWindowsKnownPaths(binaryName: string): string[] { // npm (has known issues on Windows, but check anyway) ...npmGlobal('opencode'), ], + kilo: [ + // Scoop installation + path.join(home, 'scoop', 'shims', 'kilo.exe'), + path.join(home, 'scoop', 'apps', 'kilo', 'current', 'kilo.exe'), + // Volta - Node version manager + path.join(home, '.volta', 'bin', 'kilo'), + path.join(home, '.volta', 'bin', 'kilo.cmd'), + // Chocolatey installation + path.join(process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin', 'kilo.exe'), + // Go install + ...goBin('kilo'), + // npm + ...npmGlobal('kilo'), + ], gemini: [ // npm global installation ...npmGlobal('gemini'), @@ -410,6 +426,18 @@ function getUnixKnownPaths(binaryName: string): string[] { // Node version managers (nvm, fnm, volta, etc.) ...nodeVersionManagers('opencode'), ], + kilo: [ + // Kilo installer default location + path.join(home, '.kilo', 'bin', 'kilo'), + // Go install location + path.join(home, 'go', 'bin', 'kilo'), + // User local bin + ...localBin('kilo'), + // Homebrew paths + ...homebrew('kilo'), + // Node version managers (nvm, fnm, volta, etc.) + ...nodeVersionManagers('kilo'), + ], gemini: [ // npm global paths ...npmGlobal('gemini'), diff --git a/src/main/parsers/error-patterns.ts b/src/main/parsers/error-patterns.ts index b027697dae..8fcae881a5 100644 --- a/src/main/parsers/error-patterns.ts +++ b/src/main/parsers/error-patterns.ts @@ -378,6 +378,14 @@ const OPENCODE_ERROR_PATTERNS: AgentErrorPatterns = { ], }; +// ============================================================================ +// Kilo Error Patterns +// ============================================================================ +// +// KiloCode is a 1:1 fork of OpenCode — patterns mirror OPENCODE_ERROR_PATTERNS. + +const KILO_ERROR_PATTERNS: AgentErrorPatterns = OPENCODE_ERROR_PATTERNS; + // ============================================================================ // Codex Error Patterns // ============================================================================ @@ -793,8 +801,8 @@ export const SSH_ERROR_PATTERNS: AgentErrorPatterns = { { // Agent command not found for other agents pattern: - /bash:.*opencode.*command not found|sh:.*opencode.*command not found|zsh:.*command not found:.*opencode/i, - message: 'OpenCode command not found. Ensure OpenCode is installed.', + /bash:.*(opencode|kilo).*command not found|sh:.*(opencode|kilo).*command not found|zsh:.*command not found:.*(opencode|kilo)/i, + message: 'OpenCode/Kilo command not found. Ensure the agent is installed.', recoverable: false, }, { @@ -809,7 +817,7 @@ export const SSH_ERROR_PATTERNS: AgentErrorPatterns = { // More specific pattern: requires path-like structure before the binary name // Matches: "/usr/local/bin/claude: No such file or directory" // Does NOT match: "claude: error: File 'foo.txt': No such file or directory" (normal file errors) - pattern: /\/[^\s:]*\/(claude|opencode|codex):\s*No such file or directory/i, + pattern: /\/[^\s:]*\/(claude|opencode|kilo|codex):\s*No such file or directory/i, message: 'Agent binary not found at the specified path. Ensure the agent is installed.', recoverable: false, }, @@ -862,6 +870,7 @@ export const SSH_ERROR_PATTERNS: AgentErrorPatterns = { const patternRegistry = new Map([ ['claude-code', CLAUDE_ERROR_PATTERNS], ['opencode', OPENCODE_ERROR_PATTERNS], + ['kilo', KILO_ERROR_PATTERNS], ['codex', CODEX_ERROR_PATTERNS], ['factory-droid', FACTORY_DROID_ERROR_PATTERNS], ]); diff --git a/src/main/parsers/index.ts b/src/main/parsers/index.ts index d2f55e477f..d2cf4d97d1 100644 --- a/src/main/parsers/index.ts +++ b/src/main/parsers/index.ts @@ -52,6 +52,7 @@ export { // Import parser implementations import { ClaudeOutputParser } from './claude-output-parser'; import { OpenCodeOutputParser } from './opencode-output-parser'; +import { KiloOutputParser } from './kilo-output-parser'; import { CodexOutputParser } from './codex-output-parser'; import { FactoryDroidOutputParser } from './factory-droid-output-parser'; import { @@ -64,6 +65,7 @@ import { logger } from '../utils/logger'; // Export parser classes for direct use if needed export { ClaudeOutputParser } from './claude-output-parser'; export { OpenCodeOutputParser } from './opencode-output-parser'; +export { KiloOutputParser } from './kilo-output-parser'; export { CodexOutputParser } from './codex-output-parser'; export { FactoryDroidOutputParser } from './factory-droid-output-parser'; @@ -80,6 +82,7 @@ export function initializeOutputParsers(): void { // Register all parser implementations registerOutputParser(new ClaudeOutputParser()); registerOutputParser(new OpenCodeOutputParser()); + registerOutputParser(new KiloOutputParser()); registerOutputParser(new CodexOutputParser()); registerOutputParser(new FactoryDroidOutputParser()); diff --git a/src/main/parsers/kilo-output-parser.ts b/src/main/parsers/kilo-output-parser.ts new file mode 100644 index 0000000000..7f75211627 --- /dev/null +++ b/src/main/parsers/kilo-output-parser.ts @@ -0,0 +1,13 @@ +/** + * Kilo Output Parser Implementation + * + * KiloCode is a 1:1 fork of OpenCode with the same JSONL output format, + * so the parser logic is identical — we just subclass and override agentId. + */ + +import type { ToolType } from '../../shared/types'; +import { OpenCodeOutputParser } from './opencode-output-parser'; + +export class KiloOutputParser extends OpenCodeOutputParser { + readonly agentId: ToolType = 'kilo'; +} diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 3b90331245..d7958c65a9 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -258,7 +258,10 @@ export class StdoutHandler { // OpenCode emits multiple steps: step_start → text → tool_use → step_finish(tool-calls) → repeat // Each step may have a text event. Only the final text (before reason:"stop") is the real result. // Reset resultEmitted on each new step so the last text event wins instead of the first. - if (event.type === 'init' && managedProcess.toolType === 'opencode') { + if ( + event.type === 'init' && + (managedProcess.toolType === 'opencode' || managedProcess.toolType === 'kilo') + ) { managedProcess.resultEmitted = false; managedProcess.streamedText = ''; } diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 71981b0301..72af28b443 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -7,6 +7,7 @@ export { ClaudeSessionStorage, ClaudeSessionOriginsData } from './claude-session-storage'; export { OpenCodeSessionStorage } from './opencode-session-storage'; +export { KiloSessionStorage } from './kilo-session-storage'; export { CodexSessionStorage } from './codex-session-storage'; export { FactoryDroidSessionStorage } from './factory-droid-session-storage'; @@ -14,6 +15,7 @@ import Store from 'electron-store'; import { registerSessionStorage } from '../agents'; import { ClaudeSessionStorage, ClaudeSessionOriginsData } from './claude-session-storage'; import { OpenCodeSessionStorage } from './opencode-session-storage'; +import { KiloSessionStorage } from './kilo-session-storage'; import { CodexSessionStorage } from './codex-session-storage'; import { FactoryDroidSessionStorage } from './factory-droid-session-storage'; @@ -34,6 +36,7 @@ export interface InitializeSessionStoragesOptions { export function initializeSessionStorages(options?: InitializeSessionStoragesOptions): void { registerSessionStorage(new ClaudeSessionStorage(options?.claudeSessionOriginsStore)); registerSessionStorage(new OpenCodeSessionStorage()); + registerSessionStorage(new KiloSessionStorage()); registerSessionStorage(new CodexSessionStorage()); registerSessionStorage(new FactoryDroidSessionStorage()); } diff --git a/src/main/storage/kilo-session-storage.ts b/src/main/storage/kilo-session-storage.ts new file mode 100644 index 0000000000..d08e1ec4c1 --- /dev/null +++ b/src/main/storage/kilo-session-storage.ts @@ -0,0 +1,1744 @@ +/** + * Kilo Session Storage Implementation + * + * This module implements the AgentSessionStorage interface for Kilo. + * + * Kilo v1.2+ stores sessions in SQLite at ~/.local/share/kilo/kilo.db + * Older versions used JSON files at ~/.local/share/kilo/storage/ + * + * This implementation reads from SQLite first, falls back to JSON for pre-v1.2 + * installs, and deduplicates sessions when both sources exist (migration period). + * + * Session IDs: Format is `ses_{base62}` (e.g., ses_4d585107dffeO9bO3HvMdvLYyC) + * Project IDs: SHA1 hash of the project path + * + * CLI commands available: + * - `kilo session list` - Lists all sessions + * - `kilo export ` - Exports full session as JSON + */ + +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; +import fsSync from 'fs'; +import { createHash } from 'crypto'; +import Database from 'better-sqlite3'; +import { logger } from '../utils/logger'; +import { captureException } from '../utils/sentry'; +import { readFileRemote, readDirRemote, statRemote } from '../utils/remote-fs'; +import type { + AgentSessionInfo, + SessionMessagesResult, + SessionReadOptions, + SessionMessage, +} from '../agents'; +import { BaseSessionStorage, type SearchableMessage } from './base-session-storage'; +import type { ToolType, SshRemoteConfig } from '../../shared/types'; +import { isWindows } from '../../shared/platformDetection'; + +const LOG_CONTEXT = '[KiloSessionStorage]'; + +/** Regex matching one or more trailing path separators (platform-aware) */ +const TRAILING_SEP_RE = new RegExp(`${path.sep.replace('\\', '\\\\')}+$`); + +/** + * Get Kilo data base directory (platform-specific) + * - Linux/macOS: ~/.local/share/kilo + * - Windows: %APPDATA%\kilo + */ +function getKiloDataDir(): string { + if (isWindows()) { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + return path.join(appData, 'kilo'); + } + return path.join(os.homedir(), '.local', 'share', 'kilo'); +} + +/** + * Get Kilo JSON storage directory (pre-v1.2) + */ +function getKiloStorageDir(): string { + return path.join(getKiloDataDir(), 'storage'); +} + +/** + * Get Kilo SQLite database path (v1.2+) + */ +function getKiloDbPath(): string { + return path.join(getKiloDataDir(), 'kilo.db'); +} + +const KILO_STORAGE_DIR = getKiloStorageDir(); +const KILO_DB_PATH = getKiloDbPath(); + +/** + * Kilo project metadata structure + */ +interface KiloProject { + id: string; + worktree: string; // Project path (called "worktree" in Kilo) + vcsDir?: string; + vcs?: string; + time?: { + created?: number; + updated?: number; + }; +} + +/** + * Kilo session metadata structure + */ +interface KiloSession { + id: string; // Session ID (e.g., ses_...) + version?: string; // Kilo version + projectID: string; // Project ID this session belongs to + directory?: string; // Working directory + title?: string; // Auto-generated title + time?: { + created?: number; // Unix timestamp in milliseconds + updated?: number; // Unix timestamp in milliseconds + }; + summary?: { + additions?: number; + deletions?: number; + files?: number; + }; +} + +/** + * Kilo message structure + */ +interface KiloMessage { + id: string; + sessionID: string; + role: 'user' | 'assistant'; + time?: { + created?: number; // Unix timestamp in milliseconds + }; + model?: { + providerID?: string; + modelID?: string; + }; + agent?: string; + tokens?: { + input?: number; + output?: number; + reasoning?: number; + cache?: { + read?: number; + write?: number; + }; + }; + cost?: number; + summary?: { + title?: string; + diffs?: unknown[]; + }; +} + +/** + * Kilo message part structure + */ +interface KiloPart { + id: string; + messageID: string; + type: 'text' | 'reasoning' | 'tool' | 'step-start' | 'step-finish'; + text?: string; + tool?: string; + state?: { + status?: string; + input?: unknown; + output?: unknown; + }; +} + +// ─── SQLite row types (v1.2+) ──────────────────────────────────────────────── + +/** + * Raw row from the SQLite `session` table + */ +interface SqliteSessionRow { + id: string; + project_id: string; + directory: string; + title: string; + version: string; + time_created: number; // Unix ms + time_updated: number; // Unix ms + summary_additions: number | null; + summary_deletions: number | null; + summary_files: number | null; +} + +/** + * Raw row from the SQLite `message` table + * The `data` column is a JSON blob containing role, model, tokens, cost, etc. + */ +interface SqliteMessageRow { + id: string; + session_id: string; + time_created: number; + time_updated: number; + data: string; // JSON blob +} + +/** + * Parsed message data from the SQLite `data` JSON blob + */ +interface SqliteMessageData { + role?: 'user' | 'assistant'; + modelID?: string; + providerID?: string; + agent?: string; + tokens?: { + input?: number; + output?: number; + reasoning?: number; + cache?: { + read?: number; + write?: number; + }; + }; + cost?: number; +} + +/** + * Parsed part data from the SQLite `data` JSON blob + */ +interface SqlitePartData { + type?: 'text' | 'reasoning' | 'tool' | 'step-start' | 'step-finish'; + text?: string; + tool?: string; + state?: { + status?: string; + input?: unknown; + output?: unknown; + }; +} + +// ─── SQLite helpers ────────────────────────────────────────────────────────── + +/** + * Open the Kilo SQLite database in read-only mode. + * Returns null if the database file doesn't exist. + */ +function openKiloDb(dbPath: string = KILO_DB_PATH): Database.Database | null { + if (!fsSync.existsSync(dbPath)) { + return null; + } + try { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + return db; + } catch (error) { + logger.warn(`${LOG_CONTEXT} Failed to open Kilo SQLite database at ${dbPath}: ${error}`); + captureException(error instanceof Error ? error : new Error(String(error)), { + extra: { dbPath }, + }); + throw error; + } +} + +/** + * Open the DB, run a callback, close the DB. + * Returns null if the database file doesn't exist. + */ +function withKiloDb(fn: (db: Database.Database) => T): T | null { + const db = openKiloDb(); + if (!db) return null; + try { + return fn(db); + } finally { + db.close(); + } +} + +/** + * Check if a session exists in the SQLite database (lightweight check). + */ +function sessionExistsInSqlite(sessionId: string): boolean { + return ( + withKiloDb((db) => { + if (!tableExists(db, 'session')) return false; + return !!db.prepare('SELECT 1 FROM session WHERE id = ? LIMIT 1').get(sessionId); + }) ?? false + ); +} + +/** + * Safely parse a JSON string, returning null on failure + */ +function safeJsonParse(json: string): T | null { + try { + return JSON.parse(json) as T; + } catch { + return null; + } +} + +/** + * Check if a table exists in a SQLite database + */ +function tableExists(db: Database.Database, tableName: string): boolean { + const row = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?") + .get(tableName) as { name: string } | undefined; + return !!row; +} + +/** + * Check if an error is an expected SQLite schema/migration issue (e.g., missing tables) + * that should be swallowed, as opposed to unexpected errors that should reach Sentry. + */ +function isExpectedSqliteError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /no such table|no such column|SQLITE_ERROR|database is locked/.test(message); +} + +// ─── Shared helpers ────────────────────────────────────────────────────────── + +/** + * Generate the project ID hash from a path (SHA1) + */ +function hashProjectPath(projectPath: string): string { + return createHash('sha1').update(projectPath).digest('hex'); +} + +/** + * Read a JSON file from the storage directory + */ +async function readJsonFile(filePath: string): Promise { + try { + const content = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(content) as T; + } catch { + return null; + } +} + +/** + * List all JSON files in a directory + */ +async function listJsonFiles(dirPath: string): Promise { + try { + const files = await fs.readdir(dirPath); + return files.filter((f) => f.endsWith('.json')); + } catch { + return []; + } +} + +/** + * Read a JSON file from a remote host via SSH + */ +async function readJsonFileRemote( + filePath: string, + sshConfig: SshRemoteConfig +): Promise { + try { + const result = await readFileRemote(filePath, sshConfig); + if (!result.success || !result.data) { + return null; + } + return JSON.parse(result.data) as T; + } catch { + return null; + } +} + +/** + * List all JSON files in a remote directory via SSH + */ +async function listJsonFilesRemote(dirPath: string, sshConfig: SshRemoteConfig): Promise { + try { + const result = await readDirRemote(dirPath, sshConfig); + if (!result.success || !result.data) { + return []; + } + return result.data + .filter((entry) => !entry.isDirectory && entry.name.endsWith('.json')) + .map((entry) => entry.name); + } catch { + return []; + } +} + +/** + * Kilo Session Storage Implementation + * + * Reads from SQLite (v1.2+) with JSON file fallback (pre-v1.2). + * During migration periods, both sources are merged with dedup by session ID. + */ +export class KiloSessionStorage extends BaseSessionStorage { + readonly agentId: ToolType = 'kilo'; + + /** + * Get the session directory for a project (local) + */ + private getSessionDir(projectId: string): string { + return path.join(KILO_STORAGE_DIR, 'session', projectId); + } + + /** + * Get the message directory for a session (local) + */ + private getMessageDir(sessionId: string): string { + return path.join(KILO_STORAGE_DIR, 'message', sessionId); + } + + /** + * Get the part directory for a message (local) + */ + private getPartDir(messageId: string): string { + return path.join(KILO_STORAGE_DIR, 'part', messageId); + } + + /** + * Get the Kilo storage base directory (remote) + * On remote Linux hosts, ~ expands to the user's home directory + */ + private getRemoteStorageDir(): string { + return '~/.local/share/kilo/storage'; + } + + /** + * Get the session directory for a project (remote) + */ + private getRemoteSessionDir(projectId: string): string { + return `${this.getRemoteStorageDir()}/session/${projectId}`; + } + + /** + * Get the message directory for a session (remote) + */ + private getRemoteMessageDir(sessionId: string): string { + return `${this.getRemoteStorageDir()}/message/${sessionId}`; + } + + /** + * Get the part directory for a message (remote) + */ + private getRemotePartDir(messageId: string): string { + return `${this.getRemoteStorageDir()}/part/${messageId}`; + } + + /** + * Find the project ID for a given path by checking existing projects + */ + private async findProjectId(projectPath: string): Promise { + const projectDir = path.join(KILO_STORAGE_DIR, 'project'); + + try { + await fs.access(projectDir); + } catch { + logger.info(`Kilo project directory not found: ${projectDir}`, LOG_CONTEXT); + return null; + } + + const projectFiles = await listJsonFiles(projectDir); + + // Normalize project path for comparison (resolve and remove trailing separators) + const normalizedPath = path.resolve(projectPath).replace(TRAILING_SEP_RE, ''); + logger.info(`Looking for Kilo project for path: ${normalizedPath}`, LOG_CONTEXT); + + for (const file of projectFiles) { + // Skip global.json - we'll use it as fallback + if (file === 'global.json') continue; + + const projectData = await readJsonFile(path.join(projectDir, file)); + if (!projectData?.worktree) continue; + + // Normalize stored path the same way + const storedPath = path.resolve(projectData.worktree).replace(TRAILING_SEP_RE, ''); + + // Exact match + if (storedPath === normalizedPath) { + logger.info( + `Found Kilo project: ${projectData.id} for path: ${normalizedPath}`, + LOG_CONTEXT + ); + return projectData.id; + } + + // Check if one is a subdirectory of the other (handles worktree subdirs) + if ( + normalizedPath.startsWith(storedPath + path.sep) || + storedPath.startsWith(normalizedPath + path.sep) + ) { + logger.info( + `Found Kilo project (subdirectory match): ${projectData.id} for path: ${normalizedPath}`, + LOG_CONTEXT + ); + return projectData.id; + } + } + + // Also check using hash-based ID (Kilo may use SHA1 of path) + const hashedId = hashProjectPath(projectPath); + const hashedFile = path.join(projectDir, `${hashedId}.json`); + try { + await fs.access(hashedFile); + logger.info(`Found Kilo project by hash: ${hashedId}`, LOG_CONTEXT); + return hashedId; + } catch { + // Not found by hash + } + + // Fall back to 'global' project - Kilo stores sessions for non-project directories here + // Sessions in global have a 'directory' field that indicates the actual working directory + logger.info( + `No dedicated Kilo project found for path: ${normalizedPath}, will check global project`, + LOG_CONTEXT + ); + return 'global'; + } + + /** + * Find the project ID for a given path by checking existing projects (remote via SSH) + */ + private async findProjectIdRemote( + projectPath: string, + sshConfig: SshRemoteConfig + ): Promise { + const projectDir = `${this.getRemoteStorageDir()}/project`; + + const projectFiles = await listJsonFilesRemote(projectDir, sshConfig); + if (projectFiles.length === 0) { + logger.info(`Kilo project directory not found on remote: ${projectDir}`, LOG_CONTEXT); + return null; + } + + // Normalize project path for comparison (remove trailing slashes) + // Note: On remote, we don't resolve paths since the remote may have different filesystem + const normalizedPath = projectPath.replace(/\/+$/, ''); + logger.info(`Looking for Kilo project for path on remote: ${normalizedPath}`, LOG_CONTEXT); + + for (const file of projectFiles) { + // Skip global.json - we'll use it as fallback + if (file === 'global.json') continue; + + const projectData = await readJsonFileRemote(`${projectDir}/${file}`, sshConfig); + if (!projectData?.worktree) continue; + + // Normalize stored path (remove trailing slashes) + const storedPath = projectData.worktree.replace(/\/+$/, ''); + + // Exact match + if (storedPath === normalizedPath) { + logger.info( + `Found Kilo project on remote: ${projectData.id} for path: ${normalizedPath}`, + LOG_CONTEXT + ); + return projectData.id; + } + + // Check if one is a subdirectory of the other (handles worktree subdirs) + if ( + normalizedPath.startsWith(storedPath + '/') || + storedPath.startsWith(normalizedPath + '/') + ) { + logger.info( + `Found Kilo project (subdirectory match) on remote: ${projectData.id} for path: ${normalizedPath}`, + LOG_CONTEXT + ); + return projectData.id; + } + } + + // Also check using hash-based ID (Kilo may use SHA1 of path) + const hashedId = hashProjectPath(projectPath); + const hashedFile = `${projectDir}/${hashedId}.json`; + const hashedResult = await statRemote(hashedFile, sshConfig); + if (hashedResult.success) { + logger.info(`Found Kilo project by hash on remote: ${hashedId}`, LOG_CONTEXT); + return hashedId; + } + + // Fall back to 'global' project + logger.info( + `No dedicated Kilo project found for path on remote: ${normalizedPath}, will check global project`, + LOG_CONTEXT + ); + return 'global'; + } + + /** + * Check if a session's directory matches the requested project path + * Used for filtering global project sessions by their working directory + */ + private sessionMatchesPath(sessionDirectory: string | undefined, projectPath: string): boolean { + if (!sessionDirectory) return false; + + const normalizedSessionDir = path.resolve(sessionDirectory).replace(TRAILING_SEP_RE, ''); + const normalizedProjectPath = path.resolve(projectPath).replace(TRAILING_SEP_RE, ''); + + // Exact match + if (normalizedSessionDir === normalizedProjectPath) return true; + + // Session is in a subdirectory of the project + if (normalizedSessionDir.startsWith(normalizedProjectPath + path.sep)) return true; + + return false; + } + + /** + * Check if a session's directory matches the requested project path (remote version) + * Used for filtering global project sessions by their working directory + * Note: On remote, we don't use path.resolve since we're operating on remote paths + */ + private sessionMatchesPathRemote( + sessionDirectory: string | undefined, + projectPath: string + ): boolean { + if (!sessionDirectory) return false; + + const normalizedSessionDir = sessionDirectory.replace(/\/+$/, ''); + const normalizedProjectPath = projectPath.replace(/\/+$/, ''); + + // Exact match + if (normalizedSessionDir === normalizedProjectPath) return true; + + // Session is in a subdirectory of the project + if (normalizedSessionDir.startsWith(normalizedProjectPath + '/')) return true; + + return false; + } + + /** + * Load all messages for a session + */ + private async loadSessionMessages(sessionId: string): Promise<{ + messages: KiloMessage[]; + parts: Map; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheWriteTokens: number; + totalCost: number; + }> { + const messageDir = this.getMessageDir(sessionId); + const messages: KiloMessage[] = []; + const parts = new Map(); + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheWriteTokens = 0; + let totalCost = 0; + + try { + const messageFiles = await listJsonFiles(messageDir); + + for (const file of messageFiles) { + const msg = await readJsonFile(path.join(messageDir, file)); + if (msg) { + messages.push(msg); + + // Aggregate token stats + if (msg.tokens) { + totalInputTokens += msg.tokens.input || 0; + totalOutputTokens += msg.tokens.output || 0; + totalCacheReadTokens += msg.tokens.cache?.read || 0; + totalCacheWriteTokens += msg.tokens.cache?.write || 0; + } + if (msg.cost) { + totalCost += msg.cost; + } + + // Load parts for this message + const partDir = this.getPartDir(msg.id); + const partFiles = await listJsonFiles(partDir); + const messageParts: KiloPart[] = []; + + for (const partFile of partFiles) { + const part = await readJsonFile(path.join(partDir, partFile)); + if (part) { + messageParts.push(part); + } + } + + parts.set(msg.id, messageParts); + } + } + } catch { + // Directory may not exist + } + + // Sort messages by creation time (Kilo uses time.created as Unix timestamp in ms) + messages.sort((a, b) => { + const aTime = a.time?.created || 0; + const bTime = b.time?.created || 0; + return aTime - bTime; + }); + + return { + messages, + parts, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheWriteTokens, + totalCost, + }; + } + + /** + * Load all messages for a session (remote via SSH) + */ + private async loadSessionMessagesRemote( + sessionId: string, + sshConfig: SshRemoteConfig + ): Promise<{ + messages: KiloMessage[]; + parts: Map; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheWriteTokens: number; + totalCost: number; + }> { + const messageDir = this.getRemoteMessageDir(sessionId); + const messages: KiloMessage[] = []; + const parts = new Map(); + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheWriteTokens = 0; + let totalCost = 0; + + try { + const messageFiles = await listJsonFilesRemote(messageDir, sshConfig); + + for (const file of messageFiles) { + const msg = await readJsonFileRemote(`${messageDir}/${file}`, sshConfig); + if (msg) { + messages.push(msg); + + // Aggregate token stats + if (msg.tokens) { + totalInputTokens += msg.tokens.input || 0; + totalOutputTokens += msg.tokens.output || 0; + totalCacheReadTokens += msg.tokens.cache?.read || 0; + totalCacheWriteTokens += msg.tokens.cache?.write || 0; + } + if (msg.cost) { + totalCost += msg.cost; + } + + // Load parts for this message + const partDir = this.getRemotePartDir(msg.id); + const partFiles = await listJsonFilesRemote(partDir, sshConfig); + const messageParts: KiloPart[] = []; + + for (const partFile of partFiles) { + const part = await readJsonFileRemote(`${partDir}/${partFile}`, sshConfig); + if (part) { + messageParts.push(part); + } + } + + parts.set(msg.id, messageParts); + } + } + } catch { + // Directory may not exist + } + + // Sort messages by creation time (Kilo uses time.created as Unix timestamp in ms) + messages.sort((a, b) => { + const aTime = a.time?.created || 0; + const bTime = b.time?.created || 0; + return aTime - bTime; + }); + + return { + messages, + parts, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheWriteTokens, + totalCost, + }; + } + + /** + * Extract text content from message parts + */ + private extractTextFromParts(parts: KiloPart[]): string { + const textParts = parts.filter((p) => p.type === 'text' && p.text).map((p) => p.text || ''); + return textParts.join(' ').trim(); + } + + // ─── SQLite-based methods (Kilo v1.2+) ────────────────────────────── + + /** + * List sessions from SQLite database for a given project path. + * Returns null if the database doesn't exist or lacks the expected schema. + */ + private listSessionsSqlite(projectPath: string): AgentSessionInfo[] | null { + const db = openKiloDb(); + if (!db) return null; + + try { + if (!tableExists(db, 'session') || !tableExists(db, 'project')) { + return null; + } + + const normalizedPath = path.resolve(projectPath).replace(TRAILING_SEP_RE, ''); + + // Find matching project(s) — exact match or subdirectory match + const projects = db.prepare('SELECT id, worktree FROM project').all() as Array<{ + id: string; + worktree: string; + }>; + + const matchingProjectIds: string[] = []; + let hasGlobalProject = false; + for (const proj of projects) { + // Skip the 'global' project (worktree '/') from project-level matching — + // it matches everything. Its sessions are filtered by directory below. + if (proj.id === 'global') { + hasGlobalProject = true; + continue; + } + const storedPath = path.resolve(proj.worktree).replace(TRAILING_SEP_RE, ''); + if ( + storedPath === normalizedPath || + normalizedPath.startsWith(storedPath + path.sep) || + storedPath.startsWith(normalizedPath + path.sep) + ) { + matchingProjectIds.push(proj.id); + } + } + + // Collect sessions from matching dedicated projects + let sessions: SqliteSessionRow[] = []; + if (matchingProjectIds.length > 0) { + const placeholders = matchingProjectIds.map(() => '?').join(','); + sessions = db + .prepare( + `SELECT id, project_id, directory, title, version, time_created, time_updated, summary_additions, summary_deletions, summary_files FROM session WHERE project_id IN (${placeholders}) ORDER BY time_updated DESC` + ) + .all(...matchingProjectIds) as SqliteSessionRow[]; + } + + // Also include global project sessions that match by directory field + if (hasGlobalProject) { + const escapedPath = normalizedPath.replace(/[%_\\]/g, '\\$&'); + const globalSessions = db + .prepare( + "SELECT id, project_id, directory, title, version, time_created, time_updated, summary_additions, summary_deletions, summary_files FROM session WHERE project_id = 'global' AND (directory = ? OR directory LIKE ? ESCAPE '\\') ORDER BY time_updated DESC" + ) + .all(normalizedPath, escapedPath + path.sep + '%') as SqliteSessionRow[]; + if (globalSessions.length > 0) { + const existingIds = new Set(sessions.map((s) => s.id)); + for (const gs of globalSessions) { + if (!existingIds.has(gs.id)) { + sessions.push(gs); + } + } + } + } + + // Re-sort after merging global sessions so newest come first + sessions.sort((a, b) => b.time_updated - a.time_updated); + + if (sessions.length === 0) { + logger.info(`No Kilo sessions found in SQLite for: ${normalizedPath}`, LOG_CONTEXT); + return []; + } + + logger.info( + `Found ${sessions.length} Kilo sessions in SQLite for: ${normalizedPath}`, + LOG_CONTEXT + ); + + return this.convertSqliteSessionRows(sessions, projectPath, db); + } catch (error) { + if (isExpectedSqliteError(error)) { + logger.warn(`Error reading Kilo SQLite database: ${error}`, LOG_CONTEXT); + return null; + } + logger.error(`Unexpected error reading Kilo SQLite database: ${error}`, LOG_CONTEXT); + captureException(error instanceof Error ? error : new Error(String(error))); + throw error; + } finally { + db.close(); + } + } + + /** + * Convert SQLite session rows to AgentSessionInfo array, loading message stats. + * Uses batch queries (2 total) instead of per-session/per-message queries. + */ + private convertSqliteSessionRows( + rows: SqliteSessionRow[], + projectPath: string, + db: Database.Database + ): AgentSessionInfo[] { + const hasMessageTable = tableExists(db, 'message'); + const hasPartTable = tableExists(db, 'part'); + + if (!hasMessageTable) { + return rows.map((row) => this.sqliteRowToSessionInfo(row, projectPath)); + } + + const sessionIds = rows.map((r) => r.id); + const placeholders = sessionIds.map(() => '?').join(','); + + // Batch query 1: all messages for all sessions + const allMessages = db + .prepare( + `SELECT id, session_id, data, time_created FROM message WHERE session_id IN (${placeholders}) ORDER BY time_created ASC` + ) + .all(...sessionIds) as Array<{ + id: string; + session_id: string; + data: string; + time_created: number; + }>; + + // Group messages by session + const messagesBySession = new Map< + string, + Array<{ id: string; data: string; time_created: number }> + >(); + const allMessageIds: string[] = []; + for (const msg of allMessages) { + let list = messagesBySession.get(msg.session_id); + if (!list) { + list = []; + messagesBySession.set(msg.session_id, list); + } + list.push(msg); + allMessageIds.push(msg.id); + } + + // Batch query 2: all parts for all messages (for preview text extraction) + const partsByMessageId = new Map>(); + if (hasPartTable && allMessageIds.length > 0) { + // SQLite has a variable limit; batch in chunks of 500 + const CHUNK_SIZE = 500; + for (let i = 0; i < allMessageIds.length; i += CHUNK_SIZE) { + const chunk = allMessageIds.slice(i, i + CHUNK_SIZE); + const partPlaceholders = chunk.map(() => '?').join(','); + const partRows = db + .prepare( + `SELECT message_id, data FROM part WHERE message_id IN (${partPlaceholders}) ORDER BY time_created ASC` + ) + .all(...chunk) as Array<{ message_id: string; data: string }>; + for (const part of partRows) { + let list = partsByMessageId.get(part.message_id); + if (!list) { + list = []; + partsByMessageId.set(part.message_id, list); + } + list.push({ data: part.data }); + } + } + } + + const sessions: AgentSessionInfo[] = []; + for (const row of rows) { + const messages = messagesBySession.get(row.id) || []; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheWriteTokens = 0; + let totalCost = 0; + let firstMessage = row.title || ''; + let durationSeconds = 0; + + if (messages.length >= 2) { + const first = messages[0].time_created; + const last = messages[messages.length - 1].time_created; + if (first && last) { + durationSeconds = Math.max(0, Math.floor((last - first) / 1000)); + } + } + + let foundPreview = false; + let candidateUserPreview: string | undefined; + for (const msg of messages) { + const data = safeJsonParse(msg.data); + if (!data) continue; + + if (data.tokens) { + totalInputTokens += data.tokens.input || 0; + totalOutputTokens += data.tokens.output || 0; + totalCacheReadTokens += data.tokens.cache?.read || 0; + totalCacheWriteTokens += data.tokens.cache?.write || 0; + } + if (data.cost) { + totalCost += data.cost; + } + + if (!foundPreview && data.role === 'assistant') { + const parts = partsByMessageId.get(msg.id) || []; + for (const part of parts) { + const partData = safeJsonParse(part.data); + if (partData?.type === 'text' && partData.text?.trim()) { + firstMessage = partData.text; + foundPreview = true; + break; + } + } + } + + if (!candidateUserPreview && data.role === 'user') { + const parts = partsByMessageId.get(msg.id) || []; + for (const part of parts) { + const partData = safeJsonParse(part.data); + if (partData?.type === 'text' && partData.text?.trim()) { + candidateUserPreview = partData.text; + break; + } + } + } + } + + if (!foundPreview && candidateUserPreview) { + firstMessage = candidateUserPreview; + } + + const createdAt = row.time_created + ? new Date(row.time_created).toISOString() + : new Date().toISOString(); + const updatedAt = row.time_updated ? new Date(row.time_updated).toISOString() : createdAt; + + sessions.push({ + sessionId: row.id, + projectPath, + timestamp: createdAt, + modifiedAt: updatedAt, + firstMessage: firstMessage.slice(0, 200), + messageCount: messages.length, + sizeBytes: 0, + costUsd: totalCost, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadTokens: totalCacheReadTokens, + cacheCreationTokens: totalCacheWriteTokens, + durationSeconds, + }); + } + + return sessions; + } + + /** + * Convert a single SQLite session row to AgentSessionInfo (no message data) + */ + private sqliteRowToSessionInfo(row: SqliteSessionRow, projectPath: string): AgentSessionInfo { + const createdAt = row.time_created + ? new Date(row.time_created).toISOString() + : new Date().toISOString(); + const updatedAt = row.time_updated ? new Date(row.time_updated).toISOString() : createdAt; + return { + sessionId: row.id, + projectPath, + timestamp: createdAt, + modifiedAt: updatedAt, + firstMessage: (row.title || '').slice(0, 200), + messageCount: 0, + sizeBytes: 0, + costUsd: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + durationSeconds: 0, + }; + } + + /** + * Load messages for a session from SQLite. + * Returns null if the database doesn't exist or lacks the expected schema. + * Accepts an optional db handle to avoid re-opening the database. + */ + private loadSessionMessagesSqlite( + sessionId: string, + existingDb?: Database.Database + ): { + messages: KiloMessage[]; + parts: Map; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheWriteTokens: number; + totalCost: number; + } | null { + const ownsDb = !existingDb; + const db = existingDb ?? openKiloDb(); + if (!db) return null; + + try { + if (!tableExists(db, 'message')) return null; + + const messageRows = db + .prepare( + 'SELECT id, session_id, time_created, time_updated, data FROM message WHERE session_id = ? ORDER BY time_created ASC' + ) + .all(sessionId) as SqliteMessageRow[]; + + if (messageRows.length === 0) { + // Verify session actually exists in SQLite before blocking JSON fallback + if (tableExists(db, 'session')) { + const sessionExists = db + .prepare('SELECT 1 FROM session WHERE id = ? LIMIT 1') + .get(sessionId); + if (sessionExists) { + return { + messages: [], + parts: new Map(), + totalInputTokens: 0, + totalOutputTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + totalCost: 0, + }; + } + } + return null; + } + + const hasPartTable = tableExists(db, 'part'); + const messages: KiloMessage[] = []; + const parts = new Map(); + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheWriteTokens = 0; + let totalCost = 0; + + for (const row of messageRows) { + const data = safeJsonParse(row.data); + if (!data) continue; + + const msg: KiloMessage = { + id: row.id, + sessionID: sessionId, + role: data.role || 'user', + time: { created: row.time_created }, + tokens: data.tokens, + cost: data.cost, + }; + messages.push(msg); + + if (data.tokens) { + totalInputTokens += data.tokens.input || 0; + totalOutputTokens += data.tokens.output || 0; + totalCacheReadTokens += data.tokens.cache?.read || 0; + totalCacheWriteTokens += data.tokens.cache?.write || 0; + } + if (data.cost) { + totalCost += data.cost; + } + + // Load parts from SQLite + if (hasPartTable) { + const partRows = db + .prepare('SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created ASC') + .all(row.id) as Array<{ id: string; data: string }>; + + const messageParts: KiloPart[] = []; + for (const partRow of partRows) { + const partData = safeJsonParse(partRow.data); + if (partData) { + messageParts.push({ + id: partRow.id, + messageID: row.id, + type: partData.type || 'text', + text: partData.text, + tool: partData.tool, + state: partData.state, + }); + } + } + parts.set(row.id, messageParts); + } + } + + return { + messages, + parts, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheWriteTokens, + totalCost, + }; + } catch (error) { + if (isExpectedSqliteError(error)) { + logger.warn(`Error loading messages from Kilo SQLite: ${error}`, LOG_CONTEXT); + return null; + } + logger.error(`Unexpected error loading messages from Kilo SQLite: ${error}`, LOG_CONTEXT); + captureException(error instanceof Error ? error : new Error(String(error))); + throw error; + } finally { + if (ownsDb) db.close(); + } + } + + /** + * Load messages for a local session, trying SQLite first then JSON fallback. + */ + private async loadMessagesLocal(sessionId: string): Promise<{ + messages: KiloMessage[]; + parts: Map; + totalInputTokens: number; + totalOutputTokens: number; + totalCacheReadTokens: number; + totalCacheWriteTokens: number; + totalCost: number; + }> { + const sqliteResult = this.loadSessionMessagesSqlite(sessionId); + if (sqliteResult) return sqliteResult; + return this.loadSessionMessages(sessionId); + } + + // ─── Merged listing (SQLite + JSON) ───────────────────────────────────── + + async listSessions( + projectPath: string, + sshConfig?: SshRemoteConfig + ): Promise { + // Use SSH remote access if config provided (JSON only for SSH — no remote SQLite) + if (sshConfig) { + return this.listSessionsRemote(projectPath, sshConfig); + } + + // Try SQLite first (v1.2+), then fall back to JSON, merge and dedup + const sqliteSessions = this.listSessionsSqlite(projectPath); + const jsonSessions = await this.listSessionsJson(projectPath); + + if (sqliteSessions && sqliteSessions.length > 0) { + if (jsonSessions.length > 0) { + // Merge: SQLite is authoritative, add JSON-only sessions + const sqliteIds = new Set(sqliteSessions.map((s) => s.sessionId)); + const merged = [...sqliteSessions]; + for (const jsonSession of jsonSessions) { + if (!sqliteIds.has(jsonSession.sessionId)) { + merged.push(jsonSession); + } + } + merged.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); + logger.info( + `Merged ${sqliteSessions.length} SQLite + ${merged.length - sqliteSessions.length} JSON-only sessions for: ${projectPath}`, + LOG_CONTEXT + ); + return merged; + } + return sqliteSessions; + } + + // SQLite unavailable or empty — use JSON results + return jsonSessions; + } + + /** + * List sessions from JSON files (pre-v1.2 format) + */ + private async listSessionsJson(projectPath: string): Promise { + const projectId = await this.findProjectId(projectPath); + + if (!projectId) { + return []; + } + + // When using the 'global' project, we need to filter sessions by their directory field + const isGlobalProject = projectId === 'global'; + + const sessionDir = this.getSessionDir(projectId); + + try { + await fs.access(sessionDir); + } catch { + return []; + } + + const sessionFiles = await listJsonFiles(sessionDir); + const sessions: AgentSessionInfo[] = []; + + for (const file of sessionFiles) { + const sessionData = await readJsonFile(path.join(sessionDir, file)); + + if (!sessionData) continue; + + // For global project, filter by the session's directory field + if (isGlobalProject && !this.sessionMatchesPath(sessionData.directory, projectPath)) { + continue; + } + + // Load messages to get first message and stats + const { + messages, + parts, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheWriteTokens, + totalCost, + } = await this.loadSessionMessages(sessionData.id); + + // Get preview message - prefer first assistant response, fall back to user message or title + let firstAssistantMessage = ''; + let firstUserMessage = ''; + + for (const msg of messages) { + const msgParts = parts.get(msg.id) || []; + const textContent = this.extractTextFromParts(msgParts); + + if (!firstUserMessage && msg.role === 'user' && textContent.trim()) { + firstUserMessage = textContent; + } + if (!firstAssistantMessage && msg.role === 'assistant' && textContent.trim()) { + firstAssistantMessage = textContent; + break; // Found first assistant response, stop scanning + } + } + + // Priority: assistant response > user message > title + const previewMessage = firstAssistantMessage || firstUserMessage || sessionData.title || ''; + + // Calculate duration using time.created (Unix timestamp in ms) + let durationSeconds = 0; + if (messages.length >= 2) { + const firstMsg = messages[0]; + const lastMsg = messages[messages.length - 1]; + const startTime = firstMsg.time?.created || 0; + const endTime = lastMsg.time?.created || 0; + if (startTime && endTime) { + durationSeconds = Math.max(0, Math.floor((endTime - startTime) / 1000)); + } + } + + // Get file stats for size + let sizeBytes = 0; + try { + const stats = await fs.stat(path.join(sessionDir, file)); + sizeBytes = stats.size; + } catch { + // Ignore stat errors + } + + // Convert Kilo timestamps (Unix ms) to ISO strings + const createdAt = sessionData.time?.created + ? new Date(sessionData.time.created).toISOString() + : new Date().toISOString(); + const updatedAt = sessionData.time?.updated + ? new Date(sessionData.time.updated).toISOString() + : createdAt; + + sessions.push({ + sessionId: sessionData.id, + projectPath, + timestamp: createdAt, + modifiedAt: updatedAt, + firstMessage: previewMessage.slice(0, 200), + messageCount: messages.filter((m) => m.role === 'user' || m.role === 'assistant').length, + sizeBytes, + costUsd: totalCost, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadTokens: totalCacheReadTokens, + cacheCreationTokens: totalCacheWriteTokens, + durationSeconds, + }); + } + + // Sort by modified date (newest first) + sessions.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); + + if (sessions.length > 0) { + logger.info(`Found ${sessions.length} Kilo sessions (JSON) for: ${projectPath}`, LOG_CONTEXT); + } + return sessions; + } + + /** + * List sessions from remote host via SSH + */ + private async listSessionsRemote( + projectPath: string, + sshConfig: SshRemoteConfig + ): Promise { + const projectId = await this.findProjectIdRemote(projectPath, sshConfig); + + if (!projectId) { + logger.info(`No Kilo project found for path on remote: ${projectPath}`, LOG_CONTEXT); + return []; + } + + // When using the 'global' project, we need to filter sessions by their directory field + const isGlobalProject = projectId === 'global'; + + const sessionDir = this.getRemoteSessionDir(projectId); + + const sessionFiles = await listJsonFilesRemote(sessionDir, sshConfig); + if (sessionFiles.length === 0) { + logger.info(`No Kilo sessions directory for project on remote: ${projectPath}`, LOG_CONTEXT); + return []; + } + + const sessions: AgentSessionInfo[] = []; + + for (const file of sessionFiles) { + const sessionData = await readJsonFileRemote(`${sessionDir}/${file}`, sshConfig); + + if (!sessionData) continue; + + // For global project, filter by the session's directory field + if (isGlobalProject && !this.sessionMatchesPathRemote(sessionData.directory, projectPath)) { + continue; + } + + // Load messages to get first message and stats + const { + messages, + parts, + totalInputTokens, + totalOutputTokens, + totalCacheReadTokens, + totalCacheWriteTokens, + totalCost, + } = await this.loadSessionMessagesRemote(sessionData.id, sshConfig); + + // Get preview message - prefer first assistant response, fall back to user message or title + let firstAssistantMessage = ''; + let firstUserMessage = ''; + + for (const msg of messages) { + const msgParts = parts.get(msg.id) || []; + const textContent = this.extractTextFromParts(msgParts); + + if (!firstUserMessage && msg.role === 'user' && textContent.trim()) { + firstUserMessage = textContent; + } + if (!firstAssistantMessage && msg.role === 'assistant' && textContent.trim()) { + firstAssistantMessage = textContent; + break; // Found first assistant response, stop scanning + } + } + + // Priority: assistant response > user message > title + const previewMessage = firstAssistantMessage || firstUserMessage || sessionData.title || ''; + + // Calculate duration using time.created (Unix timestamp in ms) + let durationSeconds = 0; + if (messages.length >= 2) { + const firstMsg = messages[0]; + const lastMsg = messages[messages.length - 1]; + const startTime = firstMsg.time?.created || 0; + const endTime = lastMsg.time?.created || 0; + if (startTime && endTime) { + durationSeconds = Math.max(0, Math.floor((endTime - startTime) / 1000)); + } + } + + // Get file stats for size via SSH + let sizeBytes = 0; + const statResult = await statRemote(`${sessionDir}/${file}`, sshConfig); + if (statResult.success && statResult.data) { + sizeBytes = statResult.data.size; + } + + // Convert Kilo timestamps (Unix ms) to ISO strings + const createdAt = sessionData.time?.created + ? new Date(sessionData.time.created).toISOString() + : new Date().toISOString(); + const updatedAt = sessionData.time?.updated + ? new Date(sessionData.time.updated).toISOString() + : createdAt; + + sessions.push({ + sessionId: sessionData.id, + projectPath, + timestamp: createdAt, + modifiedAt: updatedAt, + firstMessage: previewMessage.slice(0, 200), + messageCount: messages.filter((m) => m.role === 'user' || m.role === 'assistant').length, + sizeBytes, + costUsd: totalCost, + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadTokens: totalCacheReadTokens, + cacheCreationTokens: totalCacheWriteTokens, + durationSeconds, + }); + } + + // Sort by modified date (newest first) + sessions.sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); + + logger.info( + `Found ${sessions.length} Kilo sessions for project: ${projectPath} (remote via SSH)`, + LOG_CONTEXT + ); + return sessions; + } + + async readSessionMessages( + _projectPath: string, + sessionId: string, + options?: SessionReadOptions, + sshConfig?: SshRemoteConfig + ): Promise { + const { messages, parts } = sshConfig + ? await this.loadSessionMessagesRemote(sessionId, sshConfig) + : await this.loadMessagesLocal(sessionId); + + const sessionMessages: SessionMessage[] = []; + + for (const msg of messages) { + if (msg.role !== 'user' && msg.role !== 'assistant') continue; + + const msgParts = parts.get(msg.id) || []; + const textContent = this.extractTextFromParts(msgParts); + + // Extract tool use if present + const toolParts = msgParts.filter((p) => p.type === 'tool'); + const toolUse = toolParts.length > 0 ? toolParts : undefined; + + if (textContent || toolUse) { + // Convert Unix timestamp (ms) to ISO string + const timestamp = msg.time?.created ? new Date(msg.time.created).toISOString() : ''; + + sessionMessages.push({ + type: msg.role, + role: msg.role, + content: textContent, + timestamp, + uuid: msg.id, + toolUse, + }); + } + } + + return BaseSessionStorage.applyMessagePagination(sessionMessages, options); + } + + protected async getSearchableMessages( + sessionId: string, + _projectPath: string, + sshConfig?: SshRemoteConfig + ): Promise { + const { messages, parts } = sshConfig + ? await this.loadSessionMessagesRemote(sessionId, sshConfig) + : await this.loadMessagesLocal(sessionId); + + return messages + .filter((msg) => msg.role === 'user' || msg.role === 'assistant') + .map((msg) => ({ + role: msg.role as 'user' | 'assistant', + textContent: this.extractTextFromParts(parts.get(msg.id) || []), + })) + .filter((msg) => msg.textContent.length > 0); + } + + getSessionPath( + _projectPath: string, + sessionId: string, + sshConfig?: SshRemoteConfig + ): string | null { + if (sshConfig) { + return this.getRemoteMessageDir(sessionId); + } + if (sessionExistsInSqlite(sessionId)) { + return KILO_DB_PATH; + } + return this.getMessageDir(sessionId); + } + + async deleteMessagePair( + _projectPath: string, + sessionId: string, + userMessageUuid: string, + fallbackContent?: string, + sshConfig?: SshRemoteConfig + ): Promise<{ success: boolean; error?: string; linesRemoved?: number }> { + // Delete operations on remote sessions are not supported + if (sshConfig) { + logger.warn('Delete message pair not supported for SSH remote sessions', LOG_CONTEXT); + return { success: false, error: 'Delete not supported for remote sessions' }; + } + + try { + // Deletion not supported for SQLite sessions (DB opened read-only) + if (sessionExistsInSqlite(sessionId)) { + logger.warn( + 'Delete message pair not supported for SQLite-backed Kilo sessions', + LOG_CONTEXT + ); + return { + success: false, + error: 'Delete not supported for Kilo v1.2+ SQLite sessions', + }; + } + + // Load all messages for the session (JSON files) + const { messages, parts } = await this.loadSessionMessages(sessionId); + + if (messages.length === 0) { + logger.warn('No messages found in Kilo session', LOG_CONTEXT, { sessionId }); + return { success: false, error: 'No messages found in session' }; + } + + // Find the target user message + let userMessageIndex = -1; + let targetMessage: KiloMessage | null = null; + + // First try matching by UUID (message ID) + for (let i = 0; i < messages.length; i++) { + if (messages[i].id === userMessageUuid && messages[i].role === 'user') { + userMessageIndex = i; + targetMessage = messages[i]; + break; + } + } + + // Fallback: try content match + if (userMessageIndex === -1 && fallbackContent) { + const normalizedFallback = fallbackContent.trim().toLowerCase(); + + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'user') { + const msgParts = parts.get(messages[i].id) || []; + const textContent = this.extractTextFromParts(msgParts); + if (textContent.trim().toLowerCase() === normalizedFallback) { + userMessageIndex = i; + targetMessage = messages[i]; + logger.info('Found Kilo message by content match', LOG_CONTEXT, { + sessionId, + index: i, + }); + break; + } + } + } + } + + if (userMessageIndex === -1 || !targetMessage) { + logger.warn('User message not found for deletion in Kilo session', LOG_CONTEXT, { + sessionId, + userMessageUuid, + hasFallback: !!fallbackContent, + }); + return { success: false, error: 'User message not found' }; + } + + // Find all messages to delete (user message + following assistant messages until next user) + const messagesToDelete: KiloMessage[] = [targetMessage]; + const toolPartsBeingDeleted: KiloPart[] = []; + + for (let i = userMessageIndex + 1; i < messages.length; i++) { + if (messages[i].role === 'user') { + break; + } + messagesToDelete.push(messages[i]); + + // Collect tool parts from messages being deleted + const msgParts = parts.get(messages[i].id) || []; + for (const part of msgParts) { + if (part.type === 'tool') { + toolPartsBeingDeleted.push(part); + } + } + } + + // Delete message files and their associated parts + let filesDeleted = 0; + const messageDir = this.getMessageDir(sessionId); + + for (const msg of messagesToDelete) { + // Delete message file + const messageFile = path.join(messageDir, `${msg.id}.json`); + try { + await fs.unlink(messageFile); + filesDeleted++; + } catch { + // File may not exist + } + + // Delete all part files for this message + const partDir = this.getPartDir(msg.id); + try { + const partFiles = await listJsonFiles(partDir); + for (const partFile of partFiles) { + await fs.unlink(path.join(partDir, partFile)); + filesDeleted++; + } + // Try to remove the part directory if empty + try { + await fs.rmdir(partDir); + } catch { + // Directory may not be empty or may not exist + } + } catch { + // Part directory may not exist + } + } + + // If we deleted tool parts, we need to clean up any orphaned tool references + // in remaining messages. Kilo stores tool state in parts, so we need to + // check if any remaining messages reference the deleted tools. + if (toolPartsBeingDeleted.length > 0) { + const deletedToolIds = new Set(toolPartsBeingDeleted.map((p) => p.id)); + + // Scan remaining messages for tool parts that might reference deleted tools + for (const msg of messages) { + if (messagesToDelete.includes(msg)) continue; + + const msgParts = parts.get(msg.id) || []; + const partDir = this.getPartDir(msg.id); + + for (const part of msgParts) { + // Check if this is a tool part that references a deleted tool + // Kilo tool parts may have state.input or state.output referencing other tool IDs + if (part.type === 'tool' && part.state) { + const stateStr = JSON.stringify(part.state); + for (const deletedId of deletedToolIds) { + if (stateStr.includes(deletedId)) { + // This part references a deleted tool, remove it + try { + await fs.unlink(path.join(partDir, `${part.id}.json`)); + filesDeleted++; + logger.info('Removed orphaned tool part reference', LOG_CONTEXT, { + sessionId, + partId: part.id, + referencedDeletedTool: deletedId, + }); + } catch { + // Part file may not exist + } + break; + } + } + } + } + } + + logger.info('Cleaned up tool parts in Kilo session', LOG_CONTEXT, { + sessionId, + deletedToolIds: Array.from(deletedToolIds), + }); + } + + logger.info('Deleted message pair from Kilo session', LOG_CONTEXT, { + sessionId, + userMessageUuid, + messagesDeleted: messagesToDelete.length, + filesDeleted, + }); + + return { success: true, linesRemoved: filesDeleted }; + } catch (error) { + logger.error('Error deleting message pair from Kilo session', LOG_CONTEXT, { + sessionId, + error, + }); + captureException(error, { operation: 'kiloStorage:deleteMessagePair', sessionId }); + return { success: false, error: String(error) }; + } + } +} diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index 555be34073..c362acc7ff 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -21,6 +21,7 @@ import { parseDataUrl, buildImagePromptPrefix } from '../process-manager/utils/i const BASE_SSH_PATH_DIRS = [ '$HOME/.local/bin', '$HOME/.opencode/bin', + '$HOME/.kilo/bin', '$HOME/bin', '/usr/local/bin', '/opt/homebrew/bin', diff --git a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx index c31ca84a64..5f95e91d92 100644 --- a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx +++ b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx @@ -198,6 +198,36 @@ export function AgentLogo({ ); + case 'kilo': + // Kilo (KiloCode) - lightning bolt over terminal frame + return ( + + + + + ); + case 'qwen3-coder': // Qwen - Alibaba cloud inspired return ( diff --git a/src/renderer/components/Wizard/services/conversationManager.ts b/src/renderer/components/Wizard/services/conversationManager.ts index f71a6ae866..51a13530ad 100644 --- a/src/renderer/components/Wizard/services/conversationManager.ts +++ b/src/renderer/components/Wizard/services/conversationManager.ts @@ -723,8 +723,9 @@ class ConversationManager { return args; } - case 'opencode': { - // OpenCode requires 'run' batch mode with JSON output for wizard conversations + case 'opencode': + case 'kilo': { + // OpenCode / Kilo require 'run' batch mode with JSON output for wizard conversations const args = []; // Add base args (if any) - batchModePrefix will be added by buildAgentArgs @@ -799,8 +800,8 @@ class ConversationManager { try { const lines = output.split('\n'); - // For OpenCode: concatenate all text parts - if (agentType === 'opencode') { + // For OpenCode / Kilo: concatenate all text parts (KiloCode is a 1:1 fork) + if (agentType === 'opencode' || agentType === 'kilo') { const textParts: string[] = []; for (const line of lines) { if (!line.trim()) continue; diff --git a/src/renderer/constants/agentIcons.ts b/src/renderer/constants/agentIcons.ts index e84a0344d1..3809c02766 100644 --- a/src/renderer/constants/agentIcons.ts +++ b/src/renderer/constants/agentIcons.ts @@ -41,6 +41,7 @@ export const AGENT_ICONS: Record = { // Open-source alternatives opencode: '📟', + kilo: '⚡', // Enterprise 'factory-droid': '🏭', diff --git a/src/renderer/services/contextGroomer.ts b/src/renderer/services/contextGroomer.ts index 1960ccddac..97e7324bc6 100644 --- a/src/renderer/services/contextGroomer.ts +++ b/src/renderer/services/contextGroomer.ts @@ -74,6 +74,22 @@ export const AGENT_ARTIFACTS: Partial> = { 'GPT', 'Gemini', ], + kilo: [ + // Slash commands + '/help', + '/clear', + '/cost', + '/model', + // Brand references + 'Kilo', + 'kilo', + 'KiloCode', + 'kilocode', + // Model references + 'Claude', + 'GPT', + 'Gemini', + ], codex: [ // Slash commands '/help', @@ -121,6 +137,11 @@ export const AGENT_TARGET_NOTES: Partial> = { OpenCode is a multi-model AI coding assistant. It supports multiple AI providers and models. It can read and edit files, run commands, and search code. + `, + kilo: ` + Kilo (KiloCode) is a multi-model AI coding assistant — a fork of OpenCode. + It supports multiple AI providers and models. + It can read and edit files, run commands, and search code. `, codex: ` OpenAI Codex is an AI coding assistant by OpenAI. diff --git a/src/renderer/services/inlineWizardConversation.ts b/src/renderer/services/inlineWizardConversation.ts index 40f5845052..f8bc66d712 100644 --- a/src/renderer/services/inlineWizardConversation.ts +++ b/src/renderer/services/inlineWizardConversation.ts @@ -398,8 +398,8 @@ function extractResultFromStreamJson(output: string, agentType: ToolType): strin try { const lines = output.split('\n'); - // For OpenCode: concatenate all text parts - if (agentType === 'opencode') { + // For OpenCode / Kilo: concatenate all text parts (KiloCode is a 1:1 fork) + if (agentType === 'opencode' || agentType === 'kilo') { const textParts: string[] = []; for (const line of lines) { if (!line.trim()) continue; @@ -497,7 +497,8 @@ function buildArgsForAgent(agent: any): string[] { return [...(agent.args || [])]; } - case 'opencode': { + case 'opencode': + case 'kilo': { // Return base args plus read-only restriction for wizard conversations. // The IPC handler's buildAgentArgs() adds batchModePrefix, jsonOutputArgs, // and workingDirArgs automatically when a prompt is present. diff --git a/src/renderer/services/inlineWizardDocumentGeneration.ts b/src/renderer/services/inlineWizardDocumentGeneration.ts index b2557290dd..143916c28f 100644 --- a/src/renderer/services/inlineWizardDocumentGeneration.ts +++ b/src/renderer/services/inlineWizardDocumentGeneration.ts @@ -60,8 +60,8 @@ export function extractDisplayTextFromChunk(chunk: string, agentType: ToolType): } } - // OpenCode format - else if (agentType === 'opencode') { + // OpenCode / Kilo format (KiloCode is a 1:1 fork of OpenCode) + else if (agentType === 'opencode' || agentType === 'kilo') { if (msg.type === 'text' && msg.part?.text) { textParts.push(msg.part.text); } @@ -536,8 +536,8 @@ function extractResultFromStreamJson(output: string, agentType: ToolType): strin try { const lines = output.split('\n'); - // For OpenCode: concatenate all text parts - if (agentType === 'opencode') { + // For OpenCode / Kilo: concatenate all text parts (KiloCode is a 1:1 fork) + if (agentType === 'opencode' || agentType === 'kilo') { const textParts: string[] = []; for (const line of lines) { if (!line.trim()) continue; @@ -630,7 +630,8 @@ function buildArgsForAgent(agent: { id: string; args?: string[] }): string[] { return [...(agent.args || [])]; } - case 'opencode': { + case 'opencode': + case 'kilo': { // Return only base args — the IPC handler's buildAgentArgs() adds // batchModePrefix, jsonOutputArgs, and workingDirArgs automatically // when a prompt is present. diff --git a/src/shared/agentConstants.ts b/src/shared/agentConstants.ts index 38cc1ba03e..806428aed9 100644 --- a/src/shared/agentConstants.ts +++ b/src/shared/agentConstants.ts @@ -17,6 +17,7 @@ export const DEFAULT_CONTEXT_WINDOWS: Partial> = { 'claude-code': 200000, // Claude 3.5 Sonnet/Claude 4 default context codex: 200000, // OpenAI o3/o4-mini context window opencode: 128000, // OpenCode (depends on model, 128k is conservative default) + kilo: 128000, // Kilo / KiloCode (fork of OpenCode — same default) 'factory-droid': 200000, // Factory Droid (varies by model, defaults to Claude Opus) terminal: 0, // Terminal has no context window }; diff --git a/src/shared/agentIds.ts b/src/shared/agentIds.ts index c6716d4dfd..29990d2bb3 100644 --- a/src/shared/agentIds.ts +++ b/src/shared/agentIds.ts @@ -20,6 +20,7 @@ export const AGENT_IDS = [ 'gemini-cli', 'qwen3-coder', 'opencode', + 'kilo', 'factory-droid', 'aider', ] as const; diff --git a/src/shared/agentMetadata.ts b/src/shared/agentMetadata.ts index e10af9304e..d7509a04dc 100644 --- a/src/shared/agentMetadata.ts +++ b/src/shared/agentMetadata.ts @@ -19,6 +19,7 @@ export const AGENT_DISPLAY_NAMES: Record = { 'gemini-cli': 'Gemini CLI', 'qwen3-coder': 'Qwen3 Coder', opencode: 'OpenCode', + kilo: 'Kilo', 'factory-droid': 'Factory Droid', aider: 'Aider', }; @@ -40,7 +41,11 @@ export function getAgentDisplayName(agentId: AgentId | string): string { * These agents can still read files but the CLI calls it "plan mode". * Other agents (Codex, Factory Droid) have true read-only enforcement. */ -const PLAN_MODE_AGENTS: ReadonlySet = new Set(['claude-code', 'opencode']); +const PLAN_MODE_AGENTS: ReadonlySet = new Set([ + 'claude-code', + 'opencode', + 'kilo', +]); /** * Get the UI label for the read-only mode pill based on the agent. @@ -64,7 +69,11 @@ export function getReadOnlyModeTooltip(agentId: AgentId | string): string { * Agents currently in beta/experimental status. * Used to render "(Beta)" badges throughout the UI. */ -export const BETA_AGENTS: ReadonlySet = new Set(['opencode', 'factory-droid']); +export const BETA_AGENTS: ReadonlySet = new Set([ + 'opencode', + 'kilo', + 'factory-droid', +]); /** * Check whether an agent is in beta status.