takt: opencode (#222)

This commit is contained in:
nrs 2026-02-11 06:35:50 +09:00 committed by GitHub
parent dbc296e97a
commit b80f6d0aa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1356 additions and 24 deletions

7
package-lock.json generated
View File

@ -11,6 +11,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
"@openai/codex-sdk": "^0.98.0",
"@opencode-ai/sdk": "^1.1.53",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"update-notifier": "^7.3.1",
@ -936,6 +937,12 @@
"node": ">=18"
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.1.53",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.1.53.tgz",
"integrity": "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag==",
"license": "MIT"
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",

View File

@ -59,6 +59,7 @@
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
"@openai/codex-sdk": "^0.98.0",
"@opencode-ai/sdk": "^1.1.53",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"update-notifier": "^7.3.1",

View File

@ -32,7 +32,7 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => {
});
// Import after mocking
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, resolveOpencodeApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
describe('GlobalConfigSchema API key fields', () => {
it('should accept config without API keys', () => {
@ -280,3 +280,65 @@ describe('resolveOpenaiApiKey', () => {
expect(key).toBeUndefined();
});
});
describe('resolveOpencodeApiKey', () => {
const originalEnv = process.env['TAKT_OPENCODE_API_KEY'];
beforeEach(() => {
invalidateGlobalConfigCache();
mkdirSync(taktDir, { recursive: true });
});
afterEach(() => {
if (originalEnv !== undefined) {
process.env['TAKT_OPENCODE_API_KEY'] = originalEnv;
} else {
delete process.env['TAKT_OPENCODE_API_KEY'];
}
rmSync(testDir, { recursive: true, force: true });
});
it('should return env var when set', () => {
process.env['TAKT_OPENCODE_API_KEY'] = 'sk-opencode-from-env';
const yaml = [
'language: en',
'default_piece: default',
'log_level: info',
'provider: claude',
'opencode_api_key: sk-opencode-from-yaml',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveOpencodeApiKey();
expect(key).toBe('sk-opencode-from-env');
});
it('should fall back to config when env var is not set', () => {
delete process.env['TAKT_OPENCODE_API_KEY'];
const yaml = [
'language: en',
'default_piece: default',
'log_level: info',
'provider: claude',
'opencode_api_key: sk-opencode-from-yaml',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveOpencodeApiKey();
expect(key).toBe('sk-opencode-from-yaml');
});
it('should return undefined when neither env var nor config is set', () => {
delete process.env['TAKT_OPENCODE_API_KEY'];
const yaml = [
'language: en',
'default_piece: default',
'log_level: info',
'provider: claude',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveOpencodeApiKey();
expect(key).toBeUndefined();
});
});

View File

@ -518,5 +518,65 @@ describe('loadGlobalConfig', () => {
expect(() => loadGlobalConfig()).not.toThrow();
});
it('should throw when provider is opencode but model is a Claude alias (opus)', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'provider: opencode\nmodel: opus\n',
'utf-8',
);
expect(() => loadGlobalConfig()).toThrow(/model 'opus' is a Claude model alias but provider is 'opencode'/);
});
it('should throw when provider is opencode but model is sonnet', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'provider: opencode\nmodel: sonnet\n',
'utf-8',
);
expect(() => loadGlobalConfig()).toThrow(/model 'sonnet' is a Claude model alias but provider is 'opencode'/);
});
it('should throw when provider is opencode but model is haiku', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'provider: opencode\nmodel: haiku\n',
'utf-8',
);
expect(() => loadGlobalConfig()).toThrow(/model 'haiku' is a Claude model alias but provider is 'opencode'/);
});
it('should not throw when provider is opencode with a compatible model', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'provider: opencode\nmodel: gpt-4o\n',
'utf-8',
);
expect(() => loadGlobalConfig()).not.toThrow();
});
it('should not throw when provider is opencode without a model', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'provider: opencode\n',
'utf-8',
);
expect(() => loadGlobalConfig()).not.toThrow();
});
});
});

View File

@ -0,0 +1,70 @@
/**
* Tests for OpenCode integration in schemas and global config
*/
import { describe, it, expect } from 'vitest';
import {
GlobalConfigSchema,
ProjectConfigSchema,
CustomAgentConfigSchema,
PieceMovementRawSchema,
ParallelSubMovementRawSchema,
} from '../core/models/index.js';
describe('Schemas accept opencode provider', () => {
it('should accept opencode in GlobalConfigSchema provider field', () => {
const result = GlobalConfigSchema.parse({ provider: 'opencode' });
expect(result.provider).toBe('opencode');
});
it('should accept opencode in GlobalConfigSchema persona_providers field', () => {
const result = GlobalConfigSchema.parse({
persona_providers: { coder: 'opencode' },
});
expect(result.persona_providers).toEqual({ coder: 'opencode' });
});
it('should accept opencode_api_key in GlobalConfigSchema', () => {
const result = GlobalConfigSchema.parse({
opencode_api_key: 'test-key-123',
});
expect(result.opencode_api_key).toBe('test-key-123');
});
it('should accept opencode in ProjectConfigSchema', () => {
const result = ProjectConfigSchema.parse({ provider: 'opencode' });
expect(result.provider).toBe('opencode');
});
it('should accept opencode in CustomAgentConfigSchema', () => {
const result = CustomAgentConfigSchema.parse({
name: 'test',
prompt: 'You are a test agent',
provider: 'opencode',
});
expect(result.provider).toBe('opencode');
});
it('should accept opencode in PieceMovementRawSchema', () => {
const result = PieceMovementRawSchema.parse({
name: 'test-movement',
provider: 'opencode',
});
expect(result.provider).toBe('opencode');
});
it('should accept opencode in ParallelSubMovementRawSchema', () => {
const result = ParallelSubMovementRawSchema.parse({
name: 'sub-1',
provider: 'opencode',
});
expect(result.provider).toBe('opencode');
});
it('should still accept existing providers (claude, codex, mock)', () => {
for (const provider of ['claude', 'codex', 'mock']) {
const result = GlobalConfigSchema.parse({ provider });
expect(result.provider).toBe(provider);
}
});
});

View File

@ -0,0 +1,67 @@
/**
* Tests for OpenCode provider implementation
*/
import { describe, it, expect } from 'vitest';
import { OpenCodeProvider } from '../infra/providers/opencode.js';
import { ProviderRegistry } from '../infra/providers/index.js';
describe('OpenCodeProvider', () => {
it('should throw when claudeAgent is specified', () => {
const provider = new OpenCodeProvider();
expect(() => provider.setup({
name: 'test',
claudeAgent: 'some-agent',
})).toThrow('Claude Code agent calls are not supported by the OpenCode provider');
});
it('should throw when claudeSkill is specified', () => {
const provider = new OpenCodeProvider();
expect(() => provider.setup({
name: 'test',
claudeSkill: 'some-skill',
})).toThrow('Claude Code skill calls are not supported by the OpenCode provider');
});
it('should return a ProviderAgent when setup with name only', () => {
const provider = new OpenCodeProvider();
const agent = provider.setup({ name: 'test' });
expect(agent).toBeDefined();
expect(typeof agent.call).toBe('function');
});
it('should return a ProviderAgent when setup with systemPrompt', () => {
const provider = new OpenCodeProvider();
const agent = provider.setup({
name: 'test',
systemPrompt: 'You are a helpful assistant.',
});
expect(agent).toBeDefined();
expect(typeof agent.call).toBe('function');
});
});
describe('ProviderRegistry with OpenCode', () => {
it('should return OpenCode provider from registry', () => {
ProviderRegistry.resetInstance();
const registry = ProviderRegistry.getInstance();
const provider = registry.get('opencode');
expect(provider).toBeDefined();
expect(provider).toBeInstanceOf(OpenCodeProvider);
});
it('should setup an agent through the registry', () => {
ProviderRegistry.resetInstance();
const registry = ProviderRegistry.getInstance();
const provider = registry.get('opencode');
const agent = provider.setup({ name: 'test' });
expect(agent).toBeDefined();
expect(typeof agent.call).toBe('function');
});
});

View File

@ -0,0 +1,364 @@
/**
* Tests for OpenCode stream event handling
*/
import { describe, it, expect, vi } from 'vitest';
import {
createStreamTrackingState,
emitInit,
emitText,
emitThinking,
emitToolUse,
emitToolResult,
emitResult,
handlePartUpdated,
type OpenCodeTextPart,
type OpenCodeReasoningPart,
type OpenCodeToolPart,
} from '../infra/opencode/OpenCodeStreamHandler.js';
import type { StreamCallback } from '../core/piece/types.js';
describe('createStreamTrackingState', () => {
it('should create fresh state with empty collections', () => {
const state = createStreamTrackingState();
expect(state.textOffsets.size).toBe(0);
expect(state.thinkingOffsets.size).toBe(0);
expect(state.startedTools.size).toBe(0);
});
});
describe('emitInit', () => {
it('should emit init event with model and sessionId', () => {
const onStream = vi.fn();
emitInit(onStream, 'gpt-4', 'session-123');
expect(onStream).toHaveBeenCalledOnce();
expect(onStream).toHaveBeenCalledWith({
type: 'init',
data: { model: 'gpt-4', sessionId: 'session-123' },
});
});
it('should use default model name when model is undefined', () => {
const onStream = vi.fn();
emitInit(onStream, undefined, 'session-abc');
expect(onStream).toHaveBeenCalledWith({
type: 'init',
data: { model: 'opencode', sessionId: 'session-abc' },
});
});
it('should not emit when onStream is undefined', () => {
emitInit(undefined, 'gpt-4', 'session-123');
});
});
describe('emitText', () => {
it('should emit text event', () => {
const onStream = vi.fn();
emitText(onStream, 'Hello world');
expect(onStream).toHaveBeenCalledWith({
type: 'text',
data: { text: 'Hello world' },
});
});
it('should not emit when text is empty', () => {
const onStream = vi.fn();
emitText(onStream, '');
expect(onStream).not.toHaveBeenCalled();
});
it('should not emit when onStream is undefined', () => {
emitText(undefined, 'Hello');
});
});
describe('emitThinking', () => {
it('should emit thinking event', () => {
const onStream = vi.fn();
emitThinking(onStream, 'Reasoning...');
expect(onStream).toHaveBeenCalledWith({
type: 'thinking',
data: { thinking: 'Reasoning...' },
});
});
it('should not emit when thinking is empty', () => {
const onStream = vi.fn();
emitThinking(onStream, '');
expect(onStream).not.toHaveBeenCalled();
});
});
describe('emitToolUse', () => {
it('should emit tool_use event', () => {
const onStream = vi.fn();
emitToolUse(onStream, 'Bash', { command: 'ls' }, 'tool-1');
expect(onStream).toHaveBeenCalledWith({
type: 'tool_use',
data: { tool: 'Bash', input: { command: 'ls' }, id: 'tool-1' },
});
});
});
describe('emitToolResult', () => {
it('should emit tool_result event for success', () => {
const onStream = vi.fn();
emitToolResult(onStream, 'file.txt', false);
expect(onStream).toHaveBeenCalledWith({
type: 'tool_result',
data: { content: 'file.txt', isError: false },
});
});
it('should emit tool_result event for error', () => {
const onStream = vi.fn();
emitToolResult(onStream, 'command not found', true);
expect(onStream).toHaveBeenCalledWith({
type: 'tool_result',
data: { content: 'command not found', isError: true },
});
});
});
describe('emitResult', () => {
it('should emit result event for success', () => {
const onStream = vi.fn();
emitResult(onStream, true, 'Completed', 'session-1');
expect(onStream).toHaveBeenCalledWith({
type: 'result',
data: {
result: 'Completed',
sessionId: 'session-1',
success: true,
error: undefined,
},
});
});
it('should emit result event for failure', () => {
const onStream = vi.fn();
emitResult(onStream, false, 'Network error', 'session-1');
expect(onStream).toHaveBeenCalledWith({
type: 'result',
data: {
result: 'Network error',
sessionId: 'session-1',
success: false,
error: 'Network error',
},
});
});
});
describe('handlePartUpdated', () => {
it('should handle text part with delta', () => {
const onStream = vi.fn();
const state = createStreamTrackingState();
const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello world' };
handlePartUpdated(part, 'Hello', onStream, state);
expect(onStream).toHaveBeenCalledWith({
type: 'text',
data: { text: 'Hello' },
});
});
it('should handle text part without delta using offset tracking', () => {
const onStream = vi.fn();
const state = createStreamTrackingState();
const part1: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' };
handlePartUpdated(part1, undefined, onStream, state);
expect(onStream).toHaveBeenCalledWith({
type: 'text',
data: { text: 'Hello' },
});
onStream.mockClear();
const part2: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello world' };
handlePartUpdated(part2, undefined, onStream, state);
expect(onStream).toHaveBeenCalledWith({
type: 'text',
data: { text: ' world' },
});
});
it('should not emit duplicate text when offset has not changed', () => {
const onStream = vi.fn();
const state = createStreamTrackingState();
const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' };
handlePartUpdated(part, undefined, onStream, state);
onStream.mockClear();
handlePartUpdated(part, undefined, onStream, state);
expect(onStream).not.toHaveBeenCalled();
});
it('should handle reasoning part with delta', () => {
const onStream = vi.fn();
const state = createStreamTrackingState();
const part: OpenCodeReasoningPart = { id: 'r1', type: 'reasoning', text: 'Thinking...' };
handlePartUpdated(part, 'Thinking', onStream, state);
expect(onStream).toHaveBeenCalledWith({
type: 'thinking',
data: { thinking: 'Thinking' },
});
});
it('should handle reasoning part without delta using offset tracking', () => {
const onStream = vi.fn();
const state = createStreamTrackingState();
const part: OpenCodeReasoningPart = { id: 'r1', type: 'reasoning', text: 'Step 1' };
handlePartUpdated(part, undefined, onStream, state);
expect(onStream).toHaveBeenCalledWith({
type: 'thinking',
data: { thinking: 'Step 1' },
});
});
it('should handle tool part in running state', () => {
const onStream = vi.fn();
const state = createStreamTrackingState();
const part: OpenCodeToolPart = {
id: 't1',
type: 'tool',
callID: 'call-1',
tool: 'Bash',
state: { status: 'running', input: { command: 'ls' } },
};
handlePartUpdated(part, undefined, onStream, state);
expect(onStream).toHaveBeenCalledWith({
type: 'tool_use',
data: { tool: 'Bash', input: { command: 'ls' }, id: 'call-1' },
});
expect(state.startedTools.has('call-1')).toBe(true);
});
it('should handle tool part in completed state', () => {
const onStream: StreamCallback = vi.fn();
const state = createStreamTrackingState();
const part: OpenCodeToolPart = {
id: 't1',
type: 'tool',
callID: 'call-1',
tool: 'Bash',
state: {
status: 'completed',
input: { command: 'ls' },
output: 'file.txt',
title: 'List files',
},
};
handlePartUpdated(part, undefined, onStream, state);
expect(onStream).toHaveBeenCalledTimes(2);
expect(onStream).toHaveBeenNthCalledWith(1, {
type: 'tool_use',
data: { tool: 'Bash', input: { command: 'ls' }, id: 'call-1' },
});
expect(onStream).toHaveBeenNthCalledWith(2, {
type: 'tool_result',
data: { content: 'file.txt', isError: false },
});
});
it('should handle tool part in error state', () => {
const onStream: StreamCallback = vi.fn();
const state = createStreamTrackingState();
const part: OpenCodeToolPart = {
id: 't1',
type: 'tool',
callID: 'call-1',
tool: 'Bash',
state: {
status: 'error',
input: { command: 'rm -rf /' },
error: 'Permission denied',
},
};
handlePartUpdated(part, undefined, onStream, state);
expect(onStream).toHaveBeenCalledTimes(2);
expect(onStream).toHaveBeenNthCalledWith(2, {
type: 'tool_result',
data: { content: 'Permission denied', isError: true },
});
});
it('should not emit duplicate tool_use for already-started tool', () => {
const onStream: StreamCallback = vi.fn();
const state = createStreamTrackingState();
state.startedTools.add('call-1');
const part: OpenCodeToolPart = {
id: 't1',
type: 'tool',
callID: 'call-1',
tool: 'Bash',
state: { status: 'running', input: { command: 'ls' } },
};
handlePartUpdated(part, undefined, onStream, state);
expect(onStream).not.toHaveBeenCalled();
});
it('should ignore unknown part types', () => {
const onStream = vi.fn();
const state = createStreamTrackingState();
handlePartUpdated({ id: 'x1', type: 'unknown' }, undefined, onStream, state);
expect(onStream).not.toHaveBeenCalled();
});
it('should not emit when onStream is undefined', () => {
const state = createStreamTrackingState();
const part: OpenCodeTextPart = { id: 'p1', type: 'text', text: 'Hello' };
handlePartUpdated(part, 'Hello', undefined, state);
});
});

View File

@ -0,0 +1,30 @@
/**
* Tests for OpenCode type definitions and permission mapping
*/
import { describe, it, expect } from 'vitest';
import { mapToOpenCodePermissionReply } from '../infra/opencode/types.js';
import type { PermissionMode } from '../core/models/index.js';
describe('mapToOpenCodePermissionReply', () => {
it('should map readonly to reject', () => {
expect(mapToOpenCodePermissionReply('readonly')).toBe('reject');
});
it('should map edit to once', () => {
expect(mapToOpenCodePermissionReply('edit')).toBe('once');
});
it('should map full to always', () => {
expect(mapToOpenCodePermissionReply('full')).toBe('always');
});
it('should handle all PermissionMode values', () => {
const modes: PermissionMode[] = ['readonly', 'edit', 'full'];
const expectedReplies = ['reject', 'once', 'always'];
modes.forEach((mode, index) => {
expect(mapToOpenCodePermissionReply(mode)).toBe(expectedReplies[index]);
});
});
});

View File

@ -13,7 +13,7 @@ export interface RunAgentOptions {
abortSignal?: AbortSignal;
sessionId?: string;
model?: string;
provider?: 'claude' | 'codex' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
/** Resolved path to persona prompt file */
personaPath?: string;
/** Allowed tools for this agent run */

View File

@ -46,7 +46,7 @@ program
.option('-b, --branch <name>', 'Branch name (auto-generated if omitted)')
.option('--auto-pr', 'Create PR after successful execution')
.option('--repo <owner/repo>', 'Repository (defaults to current)')
.option('--provider <name>', 'Override agent provider (claude|codex|mock)')
.option('--provider <name>', 'Override agent provider (claude|codex|opencode|mock)')
.option('--model <name>', 'Override agent model')
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')

View File

@ -10,7 +10,7 @@ export interface CustomAgentConfig {
allowedTools?: string[];
claudeAgent?: string;
claudeSkill?: string;
provider?: 'claude' | 'codex' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
model?: string;
}
@ -52,7 +52,7 @@ export interface GlobalConfig {
language: Language;
defaultPiece: string;
logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
model?: string;
debug?: DebugConfig;
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
@ -67,6 +67,8 @@ export interface GlobalConfig {
anthropicApiKey?: string;
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openaiApiKey?: string;
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencodeApiKey?: string;
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
@ -76,7 +78,7 @@ export interface GlobalConfig {
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
pieceCategoriesFile?: string;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
personaProviders?: Record<string, 'claude' | 'codex' | 'mock'>;
personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>;
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branchNameStrategy?: 'romaji' | 'ai';
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
@ -97,5 +99,5 @@ export interface GlobalConfig {
export interface ProjectConfig {
piece?: string;
agents?: CustomAgentConfig[];
provider?: 'claude' | 'codex' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
}

View File

@ -97,7 +97,7 @@ export interface PieceMovement {
/** Resolved absolute path to persona prompt file (set by loader) */
personaPath?: string;
/** Provider override for this movement */
provider?: 'claude' | 'codex' | 'mock';
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
/** Model override for this movement */
model?: string;
/** Permission mode for tool execution in this movement */

View File

@ -183,7 +183,7 @@ export const ParallelSubMovementRawSchema = z.object({
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
allowed_tools: z.array(z.string()).optional(),
mcp_servers: McpServersSchema,
provider: z.enum(['claude', 'codex', 'mock']).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
model: z.string().optional(),
permission_mode: PermissionModeSchema.optional(),
edit: z.boolean().optional(),
@ -213,7 +213,7 @@ export const PieceMovementRawSchema = z.object({
knowledge: z.union([z.string(), z.array(z.string())]).optional(),
allowed_tools: z.array(z.string()).optional(),
mcp_servers: McpServersSchema,
provider: z.enum(['claude', 'codex', 'mock']).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
model: z.string().optional(),
/** Permission mode for tool execution in this movement */
permission_mode: PermissionModeSchema.optional(),
@ -296,7 +296,7 @@ export const CustomAgentConfigSchema = z.object({
allowed_tools: z.array(z.string()).optional(),
claude_agent: z.string().optional(),
claude_skill: z.string().optional(),
provider: z.enum(['claude', 'codex', 'mock']).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
model: z.string().optional(),
}).refine(
(data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill,
@ -338,7 +338,7 @@ export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
default_piece: z.string().optional().default('default'),
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'),
model: z.string().optional(),
debug: DebugConfigSchema.optional(),
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
@ -353,6 +353,8 @@ export const GlobalConfigSchema = z.object({
anthropic_api_key: z.string().optional(),
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openai_api_key: z.string().optional(),
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencode_api_key: z.string().optional(),
/** Pipeline execution settings */
pipeline: PipelineConfigSchema.optional(),
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
@ -362,7 +364,7 @@ export const GlobalConfigSchema = z.object({
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
piece_categories_file: z.string().optional(),
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'mock'])).optional(),
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(),
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
@ -389,5 +391,5 @@ export const GlobalConfigSchema = z.object({
export const ProjectConfigSchema = z.object({
piece: z.string().optional(),
agents: z.array(CustomAgentConfigSchema).optional(),
provider: z.enum(['claude', 'codex', 'mock']).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
});

View File

@ -8,7 +8,7 @@
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js';
export type ProviderType = 'claude' | 'codex' | 'mock';
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
export interface StreamInitEventData {
model: string;

View File

@ -19,10 +19,10 @@ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
if (!provider || !model) return;
if (provider === 'codex' && CLAUDE_MODEL_ALIASES.has(model)) {
if ((provider === 'codex' || provider === 'opencode') && CLAUDE_MODEL_ALIASES.has(model)) {
throw new Error(
`Configuration error: model '${model}' is a Claude model alias but provider is '${provider}'. ` +
`Either change the provider to 'claude' or specify a Codex-compatible model.`
`Either change the provider to 'claude' or specify a ${provider}-compatible model.`
);
}
}
@ -98,6 +98,7 @@ export class GlobalConfigManager {
enableBuiltinPieces: parsed.enable_builtin_pieces,
anthropicApiKey: parsed.anthropic_api_key,
openaiApiKey: parsed.openai_api_key,
opencodeApiKey: parsed.opencode_api_key,
pipeline: parsed.pipeline ? {
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
commitMessageTemplate: parsed.pipeline.commit_message_template,
@ -162,6 +163,9 @@ export class GlobalConfigManager {
if (config.openaiApiKey) {
raw.openai_api_key = config.openaiApiKey;
}
if (config.opencodeApiKey) {
raw.opencode_api_key = config.opencodeApiKey;
}
if (config.pipeline) {
const pipelineRaw: Record<string, unknown> = {};
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
@ -272,7 +276,7 @@ export function setLanguage(language: Language): void {
saveGlobalConfig(config);
}
export function setProvider(provider: 'claude' | 'codex'): void {
export function setProvider(provider: 'claude' | 'codex' | 'opencode'): void {
const config = loadGlobalConfig();
config.provider = provider;
saveGlobalConfig(config);
@ -310,6 +314,22 @@ export function resolveOpenaiApiKey(): string | undefined {
}
}
/**
* Resolve the OpenCode API key.
* Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined
*/
export function resolveOpencodeApiKey(): string | undefined {
const envKey = process.env['TAKT_OPENCODE_API_KEY'];
if (envKey) return envKey;
try {
const config = loadGlobalConfig();
return config.opencodeApiKey;
} catch {
return undefined;
}
}
/** Load project-level debug configuration (from .takt/config.yaml) */
export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined {
const configPath = getProjectConfigPath(projectDir);

View File

@ -14,6 +14,7 @@ export {
setProvider,
resolveAnthropicApiKey,
resolveOpenaiApiKey,
resolveOpencodeApiKey,
loadProjectDebugConfig,
getEffectiveDebugConfig,
} from './globalConfig.js';

View File

@ -56,14 +56,15 @@ export async function promptLanguageSelection(): Promise<Language> {
* Prompt user to select provider for resources.
* Exits process if cancelled (initial setup is required).
*/
export async function promptProviderSelection(): Promise<'claude' | 'codex'> {
const options: { label: string; value: 'claude' | 'codex' }[] = [
export async function promptProviderSelection(): Promise<'claude' | 'codex' | 'opencode'> {
const options: { label: string; value: 'claude' | 'codex' | 'opencode' }[] = [
{ label: 'Claude Code', value: 'claude' },
{ label: 'Codex', value: 'codex' },
{ label: 'OpenCode', value: 'opencode' },
];
const result = await selectOptionWithDefault(
'Select provider (Claude Code or Codex) / プロバイダーを選択してください:',
'Select provider / プロバイダーを選択してください:',
options,
'claude'
);

View File

@ -17,7 +17,7 @@ export interface ProjectLocalConfig {
/** Current piece name */
piece?: string;
/** Provider selection for agent runtime */
provider?: 'claude' | 'codex';
provider?: 'claude' | 'codex' | 'opencode';
/** Permission mode setting */
permissionMode?: PermissionMode;
/** Verbose output mode */

View File

@ -0,0 +1,222 @@
/**
* OpenCode stream event handling.
*
* Converts OpenCode SDK SSE events into the unified StreamCallback format
* used throughout the takt codebase.
*/
import type { StreamCallback } from '../claude/index.js';
/** Subset of OpenCode Part types relevant for stream handling */
export interface OpenCodeTextPart {
id: string;
type: 'text';
text: string;
}
export interface OpenCodeReasoningPart {
id: string;
type: 'reasoning';
text: string;
}
export interface OpenCodeToolPart {
id: string;
type: 'tool';
callID: string;
tool: string;
state: OpenCodeToolState;
}
export type OpenCodeToolState =
| { status: 'pending'; input: Record<string, unknown> }
| { status: 'running'; input: Record<string, unknown>; title?: string }
| { status: 'completed'; input: Record<string, unknown>; output: string; title: string }
| { status: 'error'; input: Record<string, unknown>; error: string };
export type OpenCodePart = OpenCodeTextPart | OpenCodeReasoningPart | OpenCodeToolPart | { id: string; type: string };
/** OpenCode SSE event types relevant for stream handling */
export interface OpenCodeMessagePartUpdatedEvent {
type: 'message.part.updated';
properties: { part: OpenCodePart; delta?: string };
}
export interface OpenCodeSessionIdleEvent {
type: 'session.idle';
properties: { sessionID: string };
}
export interface OpenCodeSessionErrorEvent {
type: 'session.error';
properties: {
sessionID?: string;
error?: { name: string; data: { message: string } };
};
}
export interface OpenCodePermissionAskedEvent {
type: 'permission.asked';
properties: {
id: string;
sessionID: string;
permission: string;
patterns: string[];
metadata: Record<string, unknown>;
always: string[];
};
}
export type OpenCodeStreamEvent =
| OpenCodeMessagePartUpdatedEvent
| OpenCodeSessionIdleEvent
| OpenCodeSessionErrorEvent
| OpenCodePermissionAskedEvent
| { type: string; properties: Record<string, unknown> };
/** Tracking state for stream offsets during a single OpenCode session */
export interface StreamTrackingState {
textOffsets: Map<string, number>;
thinkingOffsets: Map<string, number>;
startedTools: Set<string>;
}
export function createStreamTrackingState(): StreamTrackingState {
return {
textOffsets: new Map<string, number>(),
thinkingOffsets: new Map<string, number>(),
startedTools: new Set<string>(),
};
}
// ---- Stream emission helpers ----
export function emitInit(
onStream: StreamCallback | undefined,
model: string | undefined,
sessionId: string,
): void {
if (!onStream) return;
onStream({
type: 'init',
data: {
model: model || 'opencode',
sessionId,
},
});
}
export function emitText(onStream: StreamCallback | undefined, text: string): void {
if (!onStream || !text) return;
onStream({ type: 'text', data: { text } });
}
export function emitThinking(onStream: StreamCallback | undefined, thinking: string): void {
if (!onStream || !thinking) return;
onStream({ type: 'thinking', data: { thinking } });
}
export function emitToolUse(
onStream: StreamCallback | undefined,
tool: string,
input: Record<string, unknown>,
id: string,
): void {
if (!onStream) return;
onStream({ type: 'tool_use', data: { tool, input, id } });
}
export function emitToolResult(
onStream: StreamCallback | undefined,
content: string,
isError: boolean,
): void {
if (!onStream) return;
onStream({ type: 'tool_result', data: { content, isError } });
}
export function emitResult(
onStream: StreamCallback | undefined,
success: boolean,
result: string,
sessionId: string,
): void {
if (!onStream) return;
onStream({
type: 'result',
data: {
result,
sessionId,
success,
error: success ? undefined : result || undefined,
},
});
}
/** Process a message.part.updated event and emit appropriate stream events */
export function handlePartUpdated(
part: OpenCodePart,
delta: string | undefined,
onStream: StreamCallback | undefined,
state: StreamTrackingState,
): void {
if (!onStream) return;
switch (part.type) {
case 'text': {
const textPart = part as OpenCodeTextPart;
if (delta) {
emitText(onStream, delta);
} else {
const prev = state.textOffsets.get(textPart.id) ?? 0;
if (textPart.text.length > prev) {
emitText(onStream, textPart.text.slice(prev));
state.textOffsets.set(textPart.id, textPart.text.length);
}
}
break;
}
case 'reasoning': {
const reasoningPart = part as OpenCodeReasoningPart;
if (delta) {
emitThinking(onStream, delta);
} else {
const prev = state.thinkingOffsets.get(reasoningPart.id) ?? 0;
if (reasoningPart.text.length > prev) {
emitThinking(onStream, reasoningPart.text.slice(prev));
state.thinkingOffsets.set(reasoningPart.id, reasoningPart.text.length);
}
}
break;
}
case 'tool': {
const toolPart = part as OpenCodeToolPart;
handleToolPartUpdated(toolPart, onStream, state);
break;
}
default:
break;
}
}
function handleToolPartUpdated(
toolPart: OpenCodeToolPart,
onStream: StreamCallback,
state: StreamTrackingState,
): void {
const toolId = toolPart.callID || toolPart.id;
if (!state.startedTools.has(toolId)) {
emitToolUse(onStream, toolPart.tool, toolPart.state.input, toolId);
state.startedTools.add(toolId);
}
switch (toolPart.state.status) {
case 'completed':
emitToolResult(onStream, toolPart.state.output, false);
break;
case 'error':
emitToolResult(onStream, toolPart.state.error, true);
break;
}
}

View File

@ -0,0 +1,331 @@
/**
* OpenCode SDK integration for agent interactions
*
* Uses @opencode-ai/sdk/v2 for native TypeScript integration.
* Follows the same patterns as the Codex client.
*/
import { createOpencode } from '@opencode-ai/sdk/v2';
import type { AgentResponse } from '../../core/models/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import { mapToOpenCodePermissionReply, type OpenCodeCallOptions } from './types.js';
import {
type OpenCodeStreamEvent,
type OpenCodePart,
type OpenCodeTextPart,
createStreamTrackingState,
emitInit,
emitResult,
handlePartUpdated,
} from './OpenCodeStreamHandler.js';
export type { OpenCodeCallOptions } from './types.js';
const log = createLogger('opencode-sdk');
const OPENCODE_STREAM_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted';
const OPENCODE_RETRY_MAX_ATTEMPTS = 3;
const OPENCODE_RETRY_BASE_DELAY_MS = 250;
const OPENCODE_RETRYABLE_ERROR_PATTERNS = [
'stream disconnected before completion',
'transport error',
'network error',
'error decoding response body',
'econnreset',
'etimedout',
'eai_again',
'fetch failed',
];
/**
* Client for OpenCode SDK agent interactions.
*
* Handles session management, streaming event conversion,
* permission auto-reply, and response processing.
*/
export class OpenCodeClient {
private isRetriableError(message: string, aborted: boolean, abortCause?: 'timeout' | 'external'): boolean {
if (aborted || abortCause) {
return false;
}
const lower = message.toLowerCase();
return OPENCODE_RETRYABLE_ERROR_PATTERNS.some((pattern) => lower.includes(pattern));
}
private async waitForRetryDelay(attempt: number, signal?: AbortSignal): Promise<void> {
const delayMs = OPENCODE_RETRY_BASE_DELAY_MS * (2 ** Math.max(0, attempt - 1));
await new Promise<void>((resolve, reject) => {
const timeoutId = setTimeout(() => {
if (signal) {
signal.removeEventListener('abort', onAbort);
}
resolve();
}, delayMs);
const onAbort = (): void => {
clearTimeout(timeoutId);
if (signal) {
signal.removeEventListener('abort', onAbort);
}
reject(new Error(OPENCODE_STREAM_ABORTED_MESSAGE));
};
if (signal) {
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
});
}
/** Call OpenCode with an agent prompt */
async call(
agentType: string,
prompt: string,
options: OpenCodeCallOptions,
): Promise<AgentResponse> {
const fullPrompt = options.systemPrompt
? `${options.systemPrompt}\n\n${prompt}`
: prompt;
for (let attempt = 1; attempt <= OPENCODE_RETRY_MAX_ATTEMPTS; attempt++) {
let idleTimeoutId: ReturnType<typeof setTimeout> | undefined;
const streamAbortController = new AbortController();
const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`;
let abortCause: 'timeout' | 'external' | undefined;
let serverClose: (() => void) | undefined;
const resetIdleTimeout = (): void => {
if (idleTimeoutId !== undefined) {
clearTimeout(idleTimeoutId);
}
idleTimeoutId = setTimeout(() => {
abortCause = 'timeout';
streamAbortController.abort();
}, OPENCODE_STREAM_IDLE_TIMEOUT_MS);
};
const onExternalAbort = (): void => {
abortCause = 'external';
streamAbortController.abort();
};
if (options.abortSignal) {
if (options.abortSignal.aborted) {
streamAbortController.abort();
} else {
options.abortSignal.addEventListener('abort', onExternalAbort, { once: true });
}
}
try {
log.debug('Starting OpenCode session', {
agentType,
model: options.model,
hasSystemPrompt: !!options.systemPrompt,
attempt,
});
const { client, server } = await createOpencode({
signal: streamAbortController.signal,
...(options.opencodeApiKey
? { config: { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } } }
: {}),
});
serverClose = server.close;
const sessionResult = options.sessionId
? { data: { id: options.sessionId } }
: await client.session.create({ directory: options.cwd });
const sessionId = sessionResult.data?.id;
if (!sessionId) {
throw new Error('Failed to create OpenCode session');
}
const { stream } = await client.event.subscribe({ directory: options.cwd });
resetIdleTimeout();
await client.session.promptAsync({
sessionID: sessionId,
directory: options.cwd,
...(options.model ? { model: { providerID: 'opencode', modelID: options.model } } : {}),
parts: [{ type: 'text' as const, text: fullPrompt }],
});
emitInit(options.onStream, options.model, sessionId);
let content = '';
let success = true;
let failureMessage = '';
const state = createStreamTrackingState();
const textContentParts = new Map<string, string>();
for await (const event of stream) {
if (streamAbortController.signal.aborted) break;
resetIdleTimeout();
const sseEvent = event as OpenCodeStreamEvent;
if (sseEvent.type === 'message.part.updated') {
const props = sseEvent.properties as { part: OpenCodePart; delta?: string };
const part = props.part;
const delta = props.delta;
if (part.type === 'text') {
const textPart = part as OpenCodeTextPart;
textContentParts.set(textPart.id, textPart.text);
}
handlePartUpdated(part, delta, options.onStream, state);
continue;
}
if (sseEvent.type === 'permission.asked') {
const permProps = sseEvent.properties as {
id: string;
sessionID: string;
};
if (permProps.sessionID === sessionId) {
const reply = options.permissionMode
? mapToOpenCodePermissionReply(options.permissionMode)
: 'once';
await client.permission.reply({
requestID: permProps.id,
directory: options.cwd,
reply,
});
}
continue;
}
if (sseEvent.type === 'session.idle') {
const idleProps = sseEvent.properties as { sessionID: string };
if (idleProps.sessionID === sessionId) {
break;
}
continue;
}
if (sseEvent.type === 'session.error') {
const errorProps = sseEvent.properties as {
sessionID?: string;
error?: { name: string; data: { message: string } };
};
if (!errorProps.sessionID || errorProps.sessionID === sessionId) {
success = false;
failureMessage = errorProps.error?.data?.message ?? 'OpenCode session error';
break;
}
continue;
}
}
content = [...textContentParts.values()].join('\n');
if (!success) {
const message = failureMessage || 'OpenCode execution failed';
const retriable = this.isRetriableError(message, streamAbortController.signal.aborted, abortCause);
if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) {
log.info('Retrying OpenCode call after transient failure', { agentType, attempt, message });
await this.waitForRetryDelay(attempt, options.abortSignal);
continue;
}
emitResult(options.onStream, false, message, sessionId);
return {
persona: agentType,
status: 'error',
content: message,
timestamp: new Date(),
sessionId,
};
}
const trimmed = content.trim();
emitResult(options.onStream, true, trimmed, sessionId);
return {
persona: agentType,
status: 'done',
content: trimmed,
timestamp: new Date(),
sessionId,
};
} catch (error) {
const message = getErrorMessage(error);
const errorMessage = streamAbortController.signal.aborted
? abortCause === 'timeout'
? timeoutMessage
: OPENCODE_STREAM_ABORTED_MESSAGE
: message;
const retriable = this.isRetriableError(errorMessage, streamAbortController.signal.aborted, abortCause);
if (retriable && attempt < OPENCODE_RETRY_MAX_ATTEMPTS) {
log.info('Retrying OpenCode call after transient exception', { agentType, attempt, errorMessage });
await this.waitForRetryDelay(attempt, options.abortSignal);
continue;
}
if (options.sessionId) {
emitResult(options.onStream, false, errorMessage, options.sessionId);
}
return {
persona: agentType,
status: 'error',
content: errorMessage,
timestamp: new Date(),
sessionId: options.sessionId,
};
} finally {
if (idleTimeoutId !== undefined) {
clearTimeout(idleTimeoutId);
}
if (options.abortSignal) {
options.abortSignal.removeEventListener('abort', onExternalAbort);
}
if (serverClose) {
serverClose();
}
}
}
throw new Error('Unreachable: OpenCode retry loop exhausted without returning');
}
/** Call OpenCode with a custom agent configuration (system prompt + prompt) */
async callCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: OpenCodeCallOptions,
): Promise<AgentResponse> {
return this.call(agentName, prompt, {
...options,
systemPrompt,
});
}
}
const defaultClient = new OpenCodeClient();
export async function callOpenCode(
agentType: string,
prompt: string,
options: OpenCodeCallOptions,
): Promise<AgentResponse> {
return defaultClient.call(agentType, prompt, options);
}
export async function callOpenCodeCustom(
agentName: string,
prompt: string,
systemPrompt: string,
options: OpenCodeCallOptions,
): Promise<AgentResponse> {
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
}

View File

@ -0,0 +1,7 @@
/**
* OpenCode integration exports
*/
export { OpenCodeClient, callOpenCode, callOpenCodeCustom } from './client.js';
export { mapToOpenCodePermissionReply } from './types.js';
export type { OpenCodeCallOptions, OpenCodePermissionReply } from './types.js';

View File

@ -0,0 +1,34 @@
/**
* Type definitions for OpenCode SDK integration
*/
import type { StreamCallback } from '../claude/index.js';
import type { PermissionMode } from '../../core/models/index.js';
/** OpenCode permission reply values */
export type OpenCodePermissionReply = 'once' | 'always' | 'reject';
/** Map TAKT PermissionMode to OpenCode permission reply */
export function mapToOpenCodePermissionReply(mode: PermissionMode): OpenCodePermissionReply {
const mapping: Record<PermissionMode, OpenCodePermissionReply> = {
readonly: 'reject',
edit: 'once',
full: 'always',
};
return mapping[mode];
}
/** Options for calling OpenCode */
export interface OpenCodeCallOptions {
cwd: string;
abortSignal?: AbortSignal;
sessionId?: string;
model?: string;
systemPrompt?: string;
/** Permission mode for automatic permission handling */
permissionMode?: PermissionMode;
/** Enable streaming mode with callback (best-effort) */
onStream?: StreamCallback;
/** OpenCode API key */
opencodeApiKey?: string;
}

View File

@ -1,12 +1,13 @@
/**
* Provider abstraction layer
*
* Provides a unified interface for different agent providers (Claude, Codex, Mock).
* Provides a unified interface for different agent providers (Claude, Codex, OpenCode, Mock).
* This enables adding new providers without modifying the runner logic.
*/
import { ClaudeProvider } from './claude.js';
import { CodexProvider } from './codex.js';
import { OpenCodeProvider } from './opencode.js';
import { MockProvider } from './mock.js';
import type { Provider, ProviderType } from './types.js';
@ -24,6 +25,7 @@ export class ProviderRegistry {
this.providers = {
claude: new ClaudeProvider(),
codex: new CodexProvider(),
opencode: new OpenCodeProvider(),
mock: new MockProvider(),
};
}

View File

@ -0,0 +1,47 @@
/**
* OpenCode provider implementation
*/
import { callOpenCode, callOpenCodeCustom, type OpenCodeCallOptions } from '../opencode/index.js';
import { resolveOpencodeApiKey } from '../config/index.js';
import type { AgentResponse } from '../../core/models/index.js';
import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
function toOpenCodeOptions(options: ProviderCallOptions): OpenCodeCallOptions {
return {
cwd: options.cwd,
abortSignal: options.abortSignal,
sessionId: options.sessionId,
model: options.model,
permissionMode: options.permissionMode,
onStream: options.onStream,
opencodeApiKey: options.opencodeApiKey ?? resolveOpencodeApiKey(),
};
}
/** OpenCode provider — delegates to OpenCode SDK */
export class OpenCodeProvider implements Provider {
setup(config: AgentSetup): ProviderAgent {
if (config.claudeAgent) {
throw new Error('Claude Code agent calls are not supported by the OpenCode provider');
}
if (config.claudeSkill) {
throw new Error('Claude Code skill calls are not supported by the OpenCode provider');
}
const { name, systemPrompt } = config;
if (systemPrompt) {
return {
call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
return callOpenCodeCustom(name, prompt, systemPrompt, toOpenCodeOptions(options));
},
};
}
return {
call: async (prompt: string, options: ProviderCallOptions): Promise<AgentResponse> => {
return callOpenCode(name, prompt, toOpenCodeOptions(options));
},
};
}
}

View File

@ -38,6 +38,8 @@ export interface ProviderCallOptions {
anthropicApiKey?: string;
/** OpenAI API key for Codex provider */
openaiApiKey?: string;
/** OpenCode API key for OpenCode provider */
opencodeApiKey?: string;
}
/** A configured agent ready to be called */
@ -51,4 +53,4 @@ export interface Provider {
}
/** Provider type */
export type ProviderType = 'claude' | 'codex' | 'mock';
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';