support for codex
This commit is contained in:
parent
65a9553bb9
commit
c1fccaaf37
10
package-lock.json
generated
10
package-lock.json
generated
@ -10,6 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.19",
|
||||
"@openai/codex-sdk": "^0.91.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"yaml": "^2.4.5",
|
||||
@ -979,6 +980,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@openai/codex-sdk": {
|
||||
"version": "0.91.0",
|
||||
"resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.91.0.tgz",
|
||||
"integrity": "sha512-YYf8QNkpQyuzNgn9Mf9D3G1pp0ObI98ADCNqASBpdlpxqykMyABgQdMRdc4c/l1KdoTnGVkUw0ljXaCHurs5vA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz",
|
||||
|
||||
@ -49,6 +49,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.19",
|
||||
"@openai/codex-sdk": "^0.91.0",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.1.0",
|
||||
"yaml": "^2.4.5",
|
||||
|
||||
@ -13,6 +13,9 @@ default_workflow: default
|
||||
# Log level: debug, info, warn, error
|
||||
log_level: info
|
||||
|
||||
# Provider runtime: claude or codex
|
||||
provider: claude
|
||||
|
||||
# Debug settings (optional)
|
||||
# debug:
|
||||
# enabled: false
|
||||
|
||||
@ -13,6 +13,9 @@ default_workflow: default
|
||||
# ログレベル: debug, info, warn, error
|
||||
log_level: info
|
||||
|
||||
# プロバイダー: claude または codex
|
||||
provider: claude
|
||||
|
||||
# デバッグ設定 (オプション)
|
||||
# debug:
|
||||
# enabled: false
|
||||
|
||||
@ -119,6 +119,17 @@ describe('CustomAgentConfigSchema', () => {
|
||||
expect(result.claude_agent).toBe('architect');
|
||||
});
|
||||
|
||||
it('should accept agent with provider override', () => {
|
||||
const config = {
|
||||
name: 'my-agent',
|
||||
prompt: 'You are a helpful assistant.',
|
||||
provider: 'codex',
|
||||
};
|
||||
|
||||
const result = CustomAgentConfigSchema.parse(config);
|
||||
expect(result.provider).toBe('codex');
|
||||
});
|
||||
|
||||
it('should reject agent without any prompt source', () => {
|
||||
const config = {
|
||||
name: 'my-agent',
|
||||
@ -136,6 +147,7 @@ describe('GlobalConfigSchema', () => {
|
||||
expect(result.trusted_directories).toEqual([]);
|
||||
expect(result.default_workflow).toBe('default');
|
||||
expect(result.log_level).toBe('info');
|
||||
expect(result.provider).toBe('claude');
|
||||
});
|
||||
|
||||
it('should accept valid config', () => {
|
||||
@ -175,4 +187,3 @@ describe('GENERIC_STATUS_PATTERNS', () => {
|
||||
expect(new RegExp(GENERIC_STATUS_PATTERNS.improve).test('[MAGI:IMPROVE]')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -13,7 +13,10 @@ import {
|
||||
ClaudeCallOptions,
|
||||
} from '../claude/client.js';
|
||||
import { type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from '../claude/process.js';
|
||||
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js';
|
||||
import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js';
|
||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
||||
import { loadProjectConfig } from '../config/projectConfig.js';
|
||||
import type { AgentResponse, CustomAgentConfig } from '../models/types.js';
|
||||
|
||||
export type { StreamCallback };
|
||||
@ -23,6 +26,7 @@ export interface RunAgentOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
provider?: 'claude' | 'codex';
|
||||
/** Resolved path to agent prompt file */
|
||||
agentPath?: string;
|
||||
onStream?: StreamCallback;
|
||||
@ -40,6 +44,22 @@ const DEFAULT_AGENT_TOOLS: Record<string, string[]> = {
|
||||
planner: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
|
||||
};
|
||||
|
||||
type AgentProvider = 'claude' | 'codex';
|
||||
|
||||
function resolveProvider(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): AgentProvider {
|
||||
if (options?.provider) return options.provider;
|
||||
if (agentConfig?.provider) return agentConfig.provider;
|
||||
const projectConfig = loadProjectConfig(cwd);
|
||||
if (projectConfig.provider) return projectConfig.provider;
|
||||
try {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
if (globalConfig.provider) return globalConfig.provider;
|
||||
} catch {
|
||||
// Ignore missing global config; fallback below
|
||||
}
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
/** Get git diff for review context */
|
||||
export function getGitDiff(cwd: string): string {
|
||||
try {
|
||||
@ -102,6 +122,18 @@ export async function runCustomAgent(
|
||||
// Custom agent with prompt
|
||||
const systemPrompt = loadAgentPrompt(agentConfig);
|
||||
const tools = agentConfig.allowedTools || ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch'];
|
||||
const provider = resolveProvider(options.cwd, options, agentConfig);
|
||||
if (provider === 'codex') {
|
||||
const callOptions: CodexCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
model: options.model || agentConfig.model,
|
||||
statusPatterns: agentConfig.statusPatterns,
|
||||
onStream: options.onStream,
|
||||
};
|
||||
return callCodexCustom(agentConfig.name, task, systemPrompt, callOptions);
|
||||
}
|
||||
|
||||
const callOptions: ClaudeCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
@ -167,6 +199,18 @@ export async function runAgent(
|
||||
}
|
||||
const systemPrompt = loadAgentPromptFromPath(options.agentPath);
|
||||
const tools = DEFAULT_AGENT_TOOLS[agentName] || ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch'];
|
||||
const provider = resolveProvider(options.cwd, options);
|
||||
|
||||
if (provider === 'codex') {
|
||||
const callOptions: CodexCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
model: options.model,
|
||||
systemPrompt,
|
||||
onStream: options.onStream,
|
||||
};
|
||||
return callCodex(agentName, task, callOptions);
|
||||
}
|
||||
|
||||
const callOptions: ClaudeCallOptions = {
|
||||
cwd: options.cwd,
|
||||
|
||||
184
src/codex/client.ts
Normal file
184
src/codex/client.ts
Normal file
@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Codex SDK integration for agent interactions
|
||||
*
|
||||
* Uses @openai/codex-sdk for native TypeScript integration.
|
||||
*/
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
import type { AgentResponse, Status } from '../models/types.js';
|
||||
import { GENERIC_STATUS_PATTERNS } from '../models/schemas.js';
|
||||
import { detectStatus } from '../claude/client.js';
|
||||
import type { StreamCallback } from '../claude/process.js';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
|
||||
const log = createLogger('codex-sdk');
|
||||
|
||||
/** Options for calling Codex */
|
||||
export interface CodexCallOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
statusPatterns?: Record<string, string>;
|
||||
/** Enable streaming mode with callback (best-effort) */
|
||||
onStream?: StreamCallback;
|
||||
}
|
||||
|
||||
function extractThreadId(value: unknown): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
const id = record.id ?? record.thread_id ?? record.threadId;
|
||||
return typeof id === 'string' ? id : undefined;
|
||||
}
|
||||
|
||||
function normalizeCodexResult(result: unknown): string {
|
||||
if (result == null) return '';
|
||||
if (typeof result === 'string') return result;
|
||||
if (typeof result !== 'object') return String(result);
|
||||
|
||||
const record = result as Record<string, unknown>;
|
||||
const directFields = ['output_text', 'output', 'content', 'text', 'message'];
|
||||
for (const field of directFields) {
|
||||
const value = record[field];
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(record.output)) {
|
||||
const first = record.output[0];
|
||||
if (typeof first === 'string') return first;
|
||||
if (first && typeof first === 'object') {
|
||||
const text = (first as Record<string, unknown>).text;
|
||||
if (typeof text === 'string') return text;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(record.choices)) {
|
||||
const firstChoice = record.choices[0] as Record<string, unknown> | undefined;
|
||||
const message = firstChoice?.message as Record<string, unknown> | undefined;
|
||||
const content = message?.content;
|
||||
if (typeof content === 'string') return content;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(result, null, 2);
|
||||
} catch {
|
||||
return String(result);
|
||||
}
|
||||
}
|
||||
|
||||
function emitInit(
|
||||
onStream: StreamCallback | undefined,
|
||||
model: string | undefined,
|
||||
sessionId: string | undefined
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({
|
||||
type: 'init',
|
||||
data: {
|
||||
model: model || 'codex',
|
||||
sessionId: sessionId || 'unknown',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function emitText(onStream: StreamCallback | undefined, text: string): void {
|
||||
if (!onStream || !text) return;
|
||||
onStream({ type: 'text', data: { text } });
|
||||
}
|
||||
|
||||
function emitResult(
|
||||
onStream: StreamCallback | undefined,
|
||||
success: boolean,
|
||||
result: string,
|
||||
sessionId: string | undefined
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({
|
||||
type: 'result',
|
||||
data: {
|
||||
result,
|
||||
sessionId: sessionId || 'unknown',
|
||||
success,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function determineStatus(content: string, patterns: Record<string, string>): Status {
|
||||
return detectStatus(content, patterns);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Codex with an agent prompt.
|
||||
*/
|
||||
export async function callCodex(
|
||||
agentType: string,
|
||||
prompt: string,
|
||||
options: CodexCallOptions
|
||||
): Promise<AgentResponse> {
|
||||
const codex = new Codex();
|
||||
const thread = options.sessionId
|
||||
? await codex.resumeThread(options.sessionId)
|
||||
: await codex.startThread();
|
||||
const threadId = extractThreadId(thread) || options.sessionId;
|
||||
|
||||
const fullPrompt = options.systemPrompt
|
||||
? `${options.systemPrompt}\n\n${prompt}`
|
||||
: prompt;
|
||||
|
||||
emitInit(options.onStream, options.model, threadId);
|
||||
|
||||
try {
|
||||
log.debug('Executing Codex thread', {
|
||||
agentType,
|
||||
model: options.model,
|
||||
hasSystemPrompt: !!options.systemPrompt,
|
||||
});
|
||||
|
||||
const runOptions = options.model ? { model: options.model } : undefined;
|
||||
const result = await (thread as { run: (p: string, o?: unknown) => Promise<unknown> })
|
||||
.run(fullPrompt, runOptions);
|
||||
|
||||
const content = normalizeCodexResult(result).trim();
|
||||
emitText(options.onStream, content);
|
||||
emitResult(options.onStream, true, content, threadId);
|
||||
|
||||
const patterns = options.statusPatterns || GENERIC_STATUS_PATTERNS;
|
||||
const status = determineStatus(content, patterns);
|
||||
|
||||
return {
|
||||
agent: agentType,
|
||||
status,
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
sessionId: threadId,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
emitResult(options.onStream, false, message, threadId);
|
||||
|
||||
return {
|
||||
agent: agentType,
|
||||
status: 'blocked',
|
||||
content: message,
|
||||
timestamp: new Date(),
|
||||
sessionId: threadId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Codex with a custom agent configuration (system prompt + prompt).
|
||||
*/
|
||||
export async function callCodexCustom(
|
||||
agentName: string,
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
options: CodexCallOptions
|
||||
): Promise<AgentResponse> {
|
||||
return callCodex(agentName, prompt, {
|
||||
...options,
|
||||
systemPrompt,
|
||||
});
|
||||
}
|
||||
5
src/codex/index.ts
Normal file
5
src/codex/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Codex integration exports
|
||||
*/
|
||||
|
||||
export * from './client.js';
|
||||
@ -29,6 +29,7 @@ export function loadGlobalConfig(): GlobalConfig {
|
||||
trustedDirectories: parsed.trusted_directories,
|
||||
defaultWorkflow: parsed.default_workflow,
|
||||
logLevel: parsed.log_level,
|
||||
provider: parsed.provider,
|
||||
debug: parsed.debug ? {
|
||||
enabled: parsed.debug.enabled,
|
||||
logFile: parsed.debug.log_file,
|
||||
@ -44,6 +45,7 @@ export function saveGlobalConfig(config: GlobalConfig): void {
|
||||
trusted_directories: config.trustedDirectories,
|
||||
default_workflow: config.defaultWorkflow,
|
||||
log_level: config.logLevel,
|
||||
provider: config.provider,
|
||||
};
|
||||
if (config.debug) {
|
||||
raw.debug = {
|
||||
@ -71,6 +73,13 @@ export function setLanguage(language: Language): void {
|
||||
saveGlobalConfig(config);
|
||||
}
|
||||
|
||||
/** Set provider setting */
|
||||
export function setProvider(provider: 'claude' | 'codex'): void {
|
||||
const config = loadGlobalConfig();
|
||||
config.provider = provider;
|
||||
saveGlobalConfig(config);
|
||||
}
|
||||
|
||||
/** Add a trusted directory */
|
||||
export function addTrustedDirectory(dir: string): void {
|
||||
const config = loadGlobalConfig();
|
||||
|
||||
@ -22,7 +22,7 @@ import {
|
||||
copyLanguageResourcesToDir,
|
||||
copyProjectResourcesToDir,
|
||||
} from '../resources/index.js';
|
||||
import { setLanguage } from './globalConfig.js';
|
||||
import { setLanguage, setProvider } from './globalConfig.js';
|
||||
|
||||
/**
|
||||
* Check if language-specific resources need to be initialized.
|
||||
@ -51,6 +51,22 @@ export async function promptLanguageSelection(): Promise<Language> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to select provider for resources.
|
||||
*/
|
||||
export async function promptProviderSelection(): Promise<'claude' | 'codex'> {
|
||||
const options: { label: string; value: 'claude' | 'codex' }[] = [
|
||||
{ label: 'Claude Code', value: 'claude' },
|
||||
{ label: 'Codex', value: 'codex' },
|
||||
];
|
||||
|
||||
return await selectOptionWithDefault(
|
||||
'Select provider (Claude Code or Codex) / プロバイダーを選択してください:',
|
||||
options,
|
||||
'claude'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize global takt directory structure with language selection.
|
||||
* If agents/workflows don't exist, prompts user for language preference.
|
||||
@ -65,12 +81,14 @@ export async function initGlobalDirs(): Promise<void> {
|
||||
if (needsSetup) {
|
||||
// Ask user for language preference
|
||||
const lang = await promptLanguageSelection();
|
||||
const provider = await promptProviderSelection();
|
||||
|
||||
// Copy language-specific resources (agents, workflows, config.yaml)
|
||||
copyLanguageResourcesToDir(getGlobalConfigDir(), lang);
|
||||
|
||||
// Explicitly save the selected language (handles case where config.yaml existed)
|
||||
setLanguage(lang);
|
||||
setProvider(provider);
|
||||
} else {
|
||||
// Just copy base global resources (won't overwrite existing)
|
||||
copyGlobalResourcesToDir(getGlobalConfigDir());
|
||||
|
||||
@ -24,6 +24,8 @@ export type ProjectPermissionMode = PermissionMode;
|
||||
export interface ProjectLocalConfig {
|
||||
/** Current workflow name */
|
||||
workflow?: string;
|
||||
/** Provider selection for agent runtime */
|
||||
provider?: 'claude' | 'codex';
|
||||
/** Permission mode setting */
|
||||
permissionMode?: PermissionMode;
|
||||
/** @deprecated Use permissionMode instead. Auto-approve all permissions in this project */
|
||||
|
||||
@ -66,6 +66,7 @@ function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowCon
|
||||
agent: step.agent,
|
||||
agentDisplayName: step.agent_name || extractAgentDisplayName(step.agent),
|
||||
agentPath: resolveAgentPathForWorkflow(step.agent, workflowDir),
|
||||
provider: step.provider,
|
||||
instructionTemplate: step.instruction_template || step.instruction || '{task}',
|
||||
transitions: step.transitions.map((t) => ({
|
||||
condition: t.condition,
|
||||
|
||||
@ -13,6 +13,9 @@ export * from './config/index.js';
|
||||
// Claude integration
|
||||
export * from './claude/index.js';
|
||||
|
||||
// Codex integration
|
||||
export * from './codex/index.js';
|
||||
|
||||
// Agent execution
|
||||
export * from './agents/index.js';
|
||||
|
||||
|
||||
@ -59,6 +59,7 @@ export const WorkflowStepRawSchema = z.object({
|
||||
agent: z.string().min(1),
|
||||
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
|
||||
agent_name: z.string().optional(),
|
||||
provider: z.enum(['claude', 'codex']).optional(),
|
||||
instruction: z.string().optional(),
|
||||
instruction_template: z.string().optional(),
|
||||
pass_previous_response: z.boolean().optional().default(true),
|
||||
@ -90,6 +91,7 @@ export const CustomAgentConfigSchema = z.object({
|
||||
status_patterns: z.record(z.string(), z.string()).optional(),
|
||||
claude_agent: z.string().optional(),
|
||||
claude_skill: z.string().optional(),
|
||||
provider: z.enum(['claude', 'codex']).optional(),
|
||||
model: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill,
|
||||
@ -111,6 +113,7 @@ export const GlobalConfigSchema = z.object({
|
||||
trusted_directories: z.array(z.string()).optional().default([]),
|
||||
default_workflow: z.string().optional().default('default'),
|
||||
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
|
||||
provider: z.enum(['claude', 'codex']).optional().default('claude'),
|
||||
debug: DebugConfigSchema.optional(),
|
||||
});
|
||||
|
||||
@ -118,6 +121,7 @@ export const GlobalConfigSchema = z.object({
|
||||
export const ProjectConfigSchema = z.object({
|
||||
workflow: z.string().optional(),
|
||||
agents: z.array(CustomAgentConfigSchema).optional(),
|
||||
provider: z.enum(['claude', 'codex']).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@ -134,5 +138,3 @@ export const GENERIC_STATUS_PATTERNS: Record<string, string> = {
|
||||
done: '\\[\\w+:(DONE|FIXED)\\]',
|
||||
blocked: '\\[\\w+:BLOCKED\\]',
|
||||
};
|
||||
|
||||
|
||||
|
||||
@ -62,6 +62,8 @@ export interface WorkflowStep {
|
||||
agentDisplayName: string;
|
||||
/** Resolved absolute path to agent prompt file (set by loader) */
|
||||
agentPath?: string;
|
||||
/** Provider override for this step */
|
||||
provider?: 'claude' | 'codex';
|
||||
instructionTemplate: string;
|
||||
transitions: WorkflowTransition[];
|
||||
passPreviousResponse: boolean;
|
||||
@ -119,6 +121,7 @@ export interface CustomAgentConfig {
|
||||
statusPatterns?: Record<string, string>;
|
||||
claudeAgent?: string;
|
||||
claudeSkill?: string;
|
||||
provider?: 'claude' | 'codex';
|
||||
model?: string;
|
||||
}
|
||||
|
||||
@ -137,6 +140,7 @@ export interface GlobalConfig {
|
||||
trustedDirectories: string[];
|
||||
defaultWorkflow: string;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
provider?: 'claude' | 'codex';
|
||||
debug?: DebugConfig;
|
||||
}
|
||||
|
||||
@ -144,4 +148,5 @@ export interface GlobalConfig {
|
||||
export interface ProjectConfig {
|
||||
workflow?: string;
|
||||
agents?: CustomAgentConfig[];
|
||||
provider?: 'claude' | 'codex';
|
||||
}
|
||||
|
||||
@ -147,6 +147,7 @@ export class WorkflowEngine extends EventEmitter {
|
||||
cwd: this.cwd,
|
||||
sessionId,
|
||||
agentPath: step.agentPath,
|
||||
provider: step.provider,
|
||||
onStream: this.options.onStream,
|
||||
onPermissionRequest: this.options.onPermissionRequest,
|
||||
onAskUserQuestion: this.options.onAskUserQuestion,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user