Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/__tests__/main/agents/detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
});
Expand Down Expand Up @@ -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 () => {
Expand Down
26 changes: 20 additions & 6 deletions src/__tests__/main/parsers/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
clearParserRegistry,
ClaudeOutputParser,
OpenCodeOutputParser,
KiloOutputParser,
CodexOutputParser,
} from '../../../main/parsers';

Expand All @@ -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);

Expand All @@ -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);
});
});

Expand All @@ -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', () => {
Expand Down Expand Up @@ -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');
Expand Down
5 changes: 5 additions & 0 deletions src/cli/services/agent-spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -141,6 +142,7 @@ export async function detectAgent(toolType: ToolType): Promise<DetectResult> {
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');

/**
Expand All @@ -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');

/**
Expand Down Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions src/main/agents/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,39 @@ export const AGENT_CAPABILITIES: Record<string, AgentCapabilities> = {
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
Expand Down
45 changes: 45 additions & 0 deletions src/main/agents/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions src/main/agents/detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<binary> models` returns one model per line
const result = await execFileNoThrow(command, ['models'], undefined, env);

if (result.exitCode !== 0) {
Expand Down
28 changes: 28 additions & 0 deletions src/main/agents/path-prober.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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',
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down
15 changes: 12 additions & 3 deletions src/main/parsers/error-patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -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,
},
{
Expand All @@ -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,
},
Expand Down Expand Up @@ -862,6 +870,7 @@ export const SSH_ERROR_PATTERNS: AgentErrorPatterns = {
const patternRegistry = new Map<ToolType, AgentErrorPatterns>([
['claude-code', CLAUDE_ERROR_PATTERNS],
['opencode', OPENCODE_ERROR_PATTERNS],
['kilo', KILO_ERROR_PATTERNS],
['codex', CODEX_ERROR_PATTERNS],
['factory-droid', FACTORY_DROID_ERROR_PATTERNS],
]);
Expand Down
3 changes: 3 additions & 0 deletions src/main/parsers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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';

Expand All @@ -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());

Expand Down
13 changes: 13 additions & 0 deletions src/main/parsers/kilo-output-parser.ts
Original file line number Diff line number Diff line change
@@ -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';
}
5 changes: 4 additions & 1 deletion src/main/process-manager/handlers/StdoutHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
}
Expand Down
Loading
Loading