From 2f0c3f6a17df03edb6fa7831226965e988476415 Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Tue, 12 May 2026 11:16:00 -0500 Subject: [PATCH] =?UTF-8?q?feat(agents):=20add=20Kilo=20(KiloCode)=20agent?= =?UTF-8?q?=20=E2=80=94=20fork=20of=20OpenCode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KiloCode is a 1:1 fork of OpenCode with the same CLI surface, JSONL output format, and session storage layout. Wires up the agent end-to-end: ID registration, definition, capabilities, path probing, output parser (subclass of OpenCodeOutputParser), session storage (parallel copy of OpenCode storage with kilo paths), error patterns, metadata, icon, context window, renderer services (groomer, wizards), CLI spawner, SSH PATH, and model discovery. KILO_CONFIG_CONTENT env var and ~/.local/share/kilo/kilo.db paths mirror the OpenCode equivalents — assumed parity per the issue; should be verified against a real Kilo install. --- src/__tests__/main/agents/detector.test.ts | 9 +- src/__tests__/main/parsers/index.test.ts | 26 +- src/cli/services/agent-spawner.ts | 5 + src/main/agents/capabilities.ts | 33 + src/main/agents/definitions.ts | 45 + src/main/agents/detector.ts | 5 +- src/main/agents/path-prober.ts | 28 + src/main/parsers/error-patterns.ts | 15 +- src/main/parsers/index.ts | 3 + src/main/parsers/kilo-output-parser.ts | 13 + .../process-manager/handlers/StdoutHandler.ts | 5 +- src/main/storage/index.ts | 3 + src/main/storage/kilo-session-storage.ts | 1744 +++++++++++++++++ src/main/utils/ssh-command-builder.ts | 1 + .../Wizard/screens/AgentSelectionScreen.tsx | 30 + .../Wizard/services/conversationManager.ts | 9 +- src/renderer/constants/agentIcons.ts | 1 + src/renderer/services/contextGroomer.ts | 21 + .../services/inlineWizardConversation.ts | 7 +- .../inlineWizardDocumentGeneration.ts | 11 +- src/shared/agentConstants.ts | 1 + src/shared/agentIds.ts | 1 + src/shared/agentMetadata.ts | 13 +- 23 files changed, 1999 insertions(+), 30 deletions(-) create mode 100644 src/main/parsers/kilo-output-parser.ts create mode 100644 src/main/storage/kilo-session-storage.ts 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.