structuring
This commit is contained in:
parent
24e12b6c85
commit
710d108f53
@ -8,7 +8,7 @@ import * as path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
vi.mock('../commands/interactive.js', () => ({
|
||||
vi.mock('../commands/interactive/interactive.js', () => ({
|
||||
interactiveMode: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -16,7 +16,7 @@ vi.mock('../providers/index.js', () => ({
|
||||
getProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })),
|
||||
}));
|
||||
|
||||
@ -33,6 +33,7 @@ vi.mock('../task/summarize.js', () => ({
|
||||
vi.mock('../utils/ui.js', () => ({
|
||||
success: vi.fn(),
|
||||
info: vi.fn(),
|
||||
blankLine: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/debug.js', () => ({
|
||||
@ -43,11 +44,11 @@ vi.mock('../utils/debug.js', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../config/workflowLoader.js', () => ({
|
||||
vi.mock('../config/loaders/workflowLoader.js', () => ({
|
||||
listWorkflows: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/paths.js', () => ({
|
||||
vi.mock('../config/paths.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()),
|
||||
getCurrentWorkflow: vi.fn(() => 'default'),
|
||||
}));
|
||||
|
||||
@ -66,13 +67,13 @@ vi.mock('../github/issue.js', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { interactiveMode } from '../commands/interactive.js';
|
||||
import { interactiveMode } from '../commands/interactive/interactive.js';
|
||||
import { getProvider } from '../providers/index.js';
|
||||
import { promptInput, confirm, selectOption } from '../prompt/index.js';
|
||||
import { summarizeTaskName } from '../task/summarize.js';
|
||||
import { listWorkflows } from '../config/workflowLoader.js';
|
||||
import { listWorkflows } from '../config/loaders/workflowLoader.js';
|
||||
import { resolveIssueTask } from '../github/issue.js';
|
||||
import { addTask, summarizeConversation } from '../commands/addTask.js';
|
||||
import { addTask, summarizeConversation } from '../commands/management/addTask.js';
|
||||
|
||||
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ vi.mock('../config/paths.js', async (importOriginal) => {
|
||||
});
|
||||
|
||||
// Import after mocking
|
||||
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../config/globalConfig.js');
|
||||
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../config/global/globalConfig.js');
|
||||
|
||||
describe('GlobalConfigSchema API key fields', () => {
|
||||
it('should accept config without API keys', () => {
|
||||
|
||||
@ -67,7 +67,7 @@ vi.mock('../commands/index.js', () => ({
|
||||
interactiveMode: vi.fn(() => Promise.resolve({ confirmed: false, task: '' })),
|
||||
}));
|
||||
|
||||
vi.mock('../config/workflowLoader.js', () => ({
|
||||
vi.mock('../config/loaders/workflowLoader.js', () => ({
|
||||
listWorkflows: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
@ -92,7 +92,7 @@ import { confirm } from '../prompt/index.js';
|
||||
import { createSharedClone } from '../task/clone.js';
|
||||
import { summarizeTaskName } from '../task/summarize.js';
|
||||
import { info } from '../utils/ui.js';
|
||||
import { confirmAndCreateWorktree } from '../commands/selectAndExecute.js';
|
||||
import { confirmAndCreateWorktree } from '../commands/execution/selectAndExecute.js';
|
||||
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
const mockCreateSharedClone = vi.mocked(createSharedClone);
|
||||
|
||||
@ -29,7 +29,7 @@ vi.mock('../utils/debug.js', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
loadWorkflow,
|
||||
listWorkflows,
|
||||
loadAgentPromptFromPath,
|
||||
} from '../config/loader.js';
|
||||
} from '../config/loaders/loader.js';
|
||||
import {
|
||||
getCurrentWorkflow,
|
||||
setCurrentWorkflow,
|
||||
@ -36,8 +36,8 @@ import {
|
||||
loadWorktreeSessions,
|
||||
updateWorktreeSession,
|
||||
} from '../config/paths.js';
|
||||
import { getLanguage } from '../config/globalConfig.js';
|
||||
import { loadProjectConfig } from '../config/projectConfig.js';
|
||||
import { getLanguage } from '../config/global/globalConfig.js';
|
||||
import { loadProjectConfig } from '../config/project/projectConfig.js';
|
||||
|
||||
describe('getBuiltinWorkflow', () => {
|
||||
it('should return builtin workflow when it exists in resources', () => {
|
||||
|
||||
@ -18,7 +18,7 @@ vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../workflow/rule-evaluator.js', () => ({
|
||||
vi.mock('../workflow/evaluation/index.js', () => ({
|
||||
detectMatchedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -38,7 +38,7 @@ vi.mock('../claude/query-manager.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import { interruptAllQueries } from '../claude/query-manager.js';
|
||||
import {
|
||||
|
||||
@ -11,7 +11,7 @@ vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../workflow/rule-evaluator.js', () => ({
|
||||
vi.mock('../workflow/evaluation/index.js', () => ({
|
||||
detectMatchedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -25,7 +25,7 @@ vi.mock('../utils/session.js', () => ({
|
||||
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
|
||||
}));
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import type { WorkflowConfig } from '../models/types.js';
|
||||
import {
|
||||
|
||||
@ -16,7 +16,7 @@ vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../workflow/rule-evaluator.js', () => ({
|
||||
vi.mock('../workflow/evaluation/index.js', () => ({
|
||||
detectMatchedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -32,7 +32,7 @@ vi.mock('../utils/session.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
import {
|
||||
makeResponse,
|
||||
buildDefaultWorkflowConfig,
|
||||
|
||||
@ -17,7 +17,7 @@ vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../workflow/rule-evaluator.js', () => ({
|
||||
vi.mock('../workflow/evaluation/index.js', () => ({
|
||||
detectMatchedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -33,9 +33,9 @@ vi.mock('../utils/session.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import { detectMatchedRule } from '../workflow/rule-evaluator.js';
|
||||
import { detectMatchedRule } from '../workflow/evaluation/index.js';
|
||||
import {
|
||||
makeResponse,
|
||||
makeStep,
|
||||
|
||||
@ -21,7 +21,7 @@ vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../workflow/rule-evaluator.js', () => ({
|
||||
vi.mock('../workflow/evaluation/index.js', () => ({
|
||||
detectMatchedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -37,7 +37,7 @@ vi.mock('../utils/session.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import {
|
||||
makeResponse,
|
||||
|
||||
@ -16,7 +16,7 @@ vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../workflow/rule-evaluator.js', () => ({
|
||||
vi.mock('../workflow/evaluation/index.js', () => ({
|
||||
detectMatchedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -32,7 +32,7 @@ vi.mock('../utils/session.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import {
|
||||
makeResponse,
|
||||
|
||||
@ -8,7 +8,7 @@ import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { isReportObjectConfig } from '../workflow/instruction-builder.js';
|
||||
import { isReportObjectConfig } from '../workflow/instruction/InstructionBuilder.js';
|
||||
import type { WorkflowStep, ReportObjectConfig, ReportConfig } from '../models/types.js';
|
||||
|
||||
/**
|
||||
|
||||
@ -15,8 +15,8 @@ import type { WorkflowConfig, WorkflowStep, AgentResponse, WorkflowRule } from '
|
||||
// --- Mock imports (consumers must call vi.mock before importing this) ---
|
||||
|
||||
import { runAgent } from '../agents/runner.js';
|
||||
import { detectMatchedRule } from '../workflow/rule-evaluator.js';
|
||||
import type { RuleMatch } from '../workflow/rule-evaluator.js';
|
||||
import { detectMatchedRule } from '../workflow/evaluation/index.js';
|
||||
import type { RuleMatch } from '../workflow/evaluation/index.js';
|
||||
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../workflow/phase-runner.js';
|
||||
import { generateReportDir } from '../utils/session.js';
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ vi.mock('../agents/runner.js', () => ({
|
||||
runAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../workflow/rule-evaluator.js', () => ({
|
||||
vi.mock('../workflow/evaluation/index.js', () => ({
|
||||
detectMatchedRule: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -33,7 +33,7 @@ vi.mock('../utils/session.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
import { runReportPhase } from '../workflow/phase-runner.js';
|
||||
import {
|
||||
makeResponse,
|
||||
|
||||
@ -20,7 +20,7 @@ vi.mock('node:os', async () => {
|
||||
});
|
||||
|
||||
// Import after mocks are set up
|
||||
const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../config/globalConfig.js');
|
||||
const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../config/global/globalConfig.js');
|
||||
const { getGlobalConfigPath } = await import('../config/paths.js');
|
||||
|
||||
describe('loadGlobalConfig', () => {
|
||||
|
||||
@ -25,7 +25,7 @@ vi.mock('../prompt/index.js', () => ({
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
const { initGlobalDirs, needsLanguageSetup } = await import('../config/initialization.js');
|
||||
const { initGlobalDirs, needsLanguageSetup } = await import('../config/global/initialization.js');
|
||||
const { getGlobalConfigPath, getGlobalConfigDir } = await import('../config/paths.js');
|
||||
|
||||
describe('initGlobalDirs with non-interactive mode', () => {
|
||||
|
||||
@ -25,7 +25,7 @@ vi.mock('../prompt/index.js', () => ({
|
||||
}));
|
||||
|
||||
// Import after mocks are set up
|
||||
const { needsLanguageSetup } = await import('../config/initialization.js');
|
||||
const { needsLanguageSetup } = await import('../config/global/initialization.js');
|
||||
const { getGlobalConfigPath } = await import('../config/paths.js');
|
||||
const { copyProjectResourcesToDir, getLanguageResourcesDir, getProjectResourcesDir } = await import('../resources/index.js');
|
||||
|
||||
|
||||
@ -4,17 +4,28 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildInstruction,
|
||||
buildReportInstruction,
|
||||
buildStatusJudgmentInstruction,
|
||||
InstructionBuilder,
|
||||
isReportObjectConfig,
|
||||
} from '../workflow/instruction/InstructionBuilder.js';
|
||||
import { ReportInstructionBuilder, type ReportInstructionContext } from '../workflow/instruction/ReportInstructionBuilder.js';
|
||||
import { StatusJudgmentBuilder, type StatusJudgmentContext } from '../workflow/instruction/StatusJudgmentBuilder.js';
|
||||
import {
|
||||
buildExecutionMetadata,
|
||||
renderExecutionMetadata,
|
||||
generateStatusRulesFromRules,
|
||||
isReportObjectConfig,
|
||||
type InstructionContext,
|
||||
type ReportInstructionContext,
|
||||
type StatusJudgmentContext,
|
||||
} from '../workflow/instruction-builder.js';
|
||||
} from '../workflow/instruction-context.js';
|
||||
import { generateStatusRulesFromRules } from '../workflow/status-rules.js';
|
||||
|
||||
// Backward-compatible function wrappers for test readability
|
||||
function buildInstruction(step: WorkflowStep, ctx: InstructionContext): string {
|
||||
return new InstructionBuilder(step, ctx).build();
|
||||
}
|
||||
function buildReportInstruction(step: WorkflowStep, ctx: ReportInstructionContext): string {
|
||||
return new ReportInstructionBuilder(step, ctx).build();
|
||||
}
|
||||
function buildStatusJudgmentInstruction(step: WorkflowStep, ctx: StatusJudgmentContext): string {
|
||||
return new StatusJudgmentBuilder(step, ctx).build();
|
||||
}
|
||||
import type { WorkflowStep, WorkflowRule } from '../models/types.js';
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
|
||||
}));
|
||||
|
||||
@ -46,7 +46,7 @@ vi.mock('node:readline', () => ({
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { getProvider } from '../providers/index.js';
|
||||
import { interactiveMode } from '../commands/interactive.js';
|
||||
import { interactiveMode } from '../commands/interactive/interactive.js';
|
||||
|
||||
const mockGetProvider = vi.mocked(getProvider);
|
||||
const mockCreateInterface = vi.mocked(createInterface);
|
||||
|
||||
@ -36,19 +36,19 @@ vi.mock('../utils/session.js', () => ({
|
||||
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
}));
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
getLanguage: vi.fn().mockReturnValue('en'),
|
||||
getDisabledBuiltins: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../config/projectConfig.js', () => ({
|
||||
vi.mock('../config/project/projectConfig.js', () => ({
|
||||
loadProjectConfig: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
|
||||
@ -10,17 +10,26 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { WorkflowStep, WorkflowRule, AgentResponse } from '../models/types.js';
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
getLanguage: vi.fn().mockReturnValue('en'),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildInstruction,
|
||||
buildReportInstruction,
|
||||
buildStatusJudgmentInstruction,
|
||||
} from '../workflow/instruction-builder.js';
|
||||
import type { InstructionContext } from '../workflow/instruction-builder.js';
|
||||
import { InstructionBuilder } from '../workflow/instruction/InstructionBuilder.js';
|
||||
import { ReportInstructionBuilder, type ReportInstructionContext } from '../workflow/instruction/ReportInstructionBuilder.js';
|
||||
import { StatusJudgmentBuilder, type StatusJudgmentContext } from '../workflow/instruction/StatusJudgmentBuilder.js';
|
||||
import type { InstructionContext } from '../workflow/instruction-context.js';
|
||||
|
||||
// Function wrappers for test readability
|
||||
function buildInstruction(step: WorkflowStep, ctx: InstructionContext): string {
|
||||
return new InstructionBuilder(step, ctx).build();
|
||||
}
|
||||
function buildReportInstruction(step: WorkflowStep, ctx: ReportInstructionContext): string {
|
||||
return new ReportInstructionBuilder(step, ctx).build();
|
||||
}
|
||||
function buildStatusJudgmentInstruction(step: WorkflowStep, ctx: StatusJudgmentContext): string {
|
||||
return new StatusJudgmentBuilder(step, ctx).build();
|
||||
}
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
|
||||
@ -104,8 +104,8 @@ vi.mock('../config/paths.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../config/globalConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../config/globalConfig.js')>();
|
||||
vi.mock('../config/global/globalConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../config/global/globalConfig.js')>();
|
||||
return {
|
||||
...original,
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
@ -114,8 +114,8 @@ vi.mock('../config/globalConfig.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../config/projectConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../config/projectConfig.js')>();
|
||||
vi.mock('../config/project/projectConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../config/project/projectConfig.js')>();
|
||||
return {
|
||||
...original,
|
||||
loadProjectConfig: vi.fn().mockReturnValue({}),
|
||||
@ -139,7 +139,7 @@ vi.mock('../workflow/phase-runner.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { executePipeline } from '../commands/pipelineExecution.js';
|
||||
import { executePipeline } from '../commands/execution/pipelineExecution.js';
|
||||
import {
|
||||
EXIT_ISSUE_FETCH_FAILED,
|
||||
EXIT_WORKFLOW_FAILED,
|
||||
|
||||
@ -87,8 +87,8 @@ vi.mock('../config/paths.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../config/globalConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../config/globalConfig.js')>();
|
||||
vi.mock('../config/global/globalConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../config/global/globalConfig.js')>();
|
||||
return {
|
||||
...original,
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
@ -96,8 +96,8 @@ vi.mock('../config/globalConfig.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../config/projectConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../config/projectConfig.js')>();
|
||||
vi.mock('../config/project/projectConfig.js', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('../config/project/projectConfig.js')>();
|
||||
return {
|
||||
...original,
|
||||
loadProjectConfig: vi.fn().mockReturnValue({}),
|
||||
@ -121,7 +121,7 @@ vi.mock('../workflow/phase-runner.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { executePipeline } from '../commands/pipelineExecution.js';
|
||||
import { executePipeline } from '../commands/execution/pipelineExecution.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
|
||||
@ -29,19 +29,19 @@ vi.mock('../claude/client.js', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
getLanguage: vi.fn().mockReturnValue('en'),
|
||||
}));
|
||||
|
||||
vi.mock('../config/projectConfig.js', () => ({
|
||||
vi.mock('../config/project/projectConfig.js', () => ({
|
||||
loadProjectConfig: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { detectMatchedRule, evaluateAggregateConditions } from '../workflow/rule-evaluator.js';
|
||||
import type { RuleMatch, RuleEvaluatorContext } from '../workflow/rule-evaluator.js';
|
||||
import { detectMatchedRule, evaluateAggregateConditions } from '../workflow/evaluation/index.js';
|
||||
import type { RuleMatch, RuleEvaluatorContext } from '../workflow/evaluation/index.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
|
||||
@ -41,19 +41,19 @@ vi.mock('../utils/session.js', () => ({
|
||||
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
}));
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
getLanguage: vi.fn().mockReturnValue('en'),
|
||||
getDisabledBuiltins: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../config/projectConfig.js', () => ({
|
||||
vi.mock('../config/project/projectConfig.js', () => ({
|
||||
loadProjectConfig: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
|
||||
@ -40,18 +40,18 @@ vi.mock('../utils/session.js', () => ({
|
||||
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
}));
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
getLanguage: vi.fn().mockReturnValue('en'),
|
||||
}));
|
||||
|
||||
vi.mock('../config/projectConfig.js', () => ({
|
||||
vi.mock('../config/project/projectConfig.js', () => ({
|
||||
loadProjectConfig: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@ import { tmpdir } from 'node:os';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
getLanguage: vi.fn().mockReturnValue('en'),
|
||||
getDisabledBuiltins: vi.fn().mockReturnValue([]),
|
||||
@ -23,7 +23,7 @@ vi.mock('../config/globalConfig.js', () => ({
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { loadWorkflow } from '../config/workflowLoader.js';
|
||||
import { loadWorkflow } from '../config/loaders/workflowLoader.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
|
||||
@ -35,20 +35,20 @@ vi.mock('../utils/session.js', () => ({
|
||||
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
}));
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn().mockReturnValue({}),
|
||||
getLanguage: vi.fn().mockReturnValue('en'),
|
||||
getDisabledBuiltins: vi.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
vi.mock('../config/projectConfig.js', () => ({
|
||||
vi.mock('../config/project/projectConfig.js', () => ({
|
||||
loadProjectConfig: vi.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// --- Imports (after mocks) ---
|
||||
|
||||
import { WorkflowEngine } from '../workflow/engine.js';
|
||||
import { loadWorkflow } from '../config/workflowLoader.js';
|
||||
import { WorkflowEngine } from '../workflow/engine/WorkflowEngine.js';
|
||||
import { loadWorkflow } from '../config/loaders/workflowLoader.js';
|
||||
import type { WorkflowConfig } from '../models/types.js';
|
||||
|
||||
// --- Test helpers ---
|
||||
|
||||
@ -9,7 +9,7 @@ import {
|
||||
buildListItems,
|
||||
type BranchInfo,
|
||||
} from '../task/branchList.js';
|
||||
import { isBranchMerged, showFullDiff, type ListAction } from '../commands/listTasks.js';
|
||||
import { isBranchMerged, showFullDiff, type ListAction } from '../commands/management/listTasks.js';
|
||||
|
||||
describe('parseTaktBranches', () => {
|
||||
it('should parse takt/ branches from git branch output', () => {
|
||||
|
||||
@ -27,13 +27,13 @@ vi.mock('../github/pr.js', () => ({
|
||||
}));
|
||||
|
||||
const mockExecuteTask = vi.fn();
|
||||
vi.mock('../commands/taskExecution.js', () => ({
|
||||
vi.mock('../commands/execution/taskExecution.js', () => ({
|
||||
executeTask: mockExecuteTask,
|
||||
}));
|
||||
|
||||
// Mock loadGlobalConfig
|
||||
const mockLoadGlobalConfig = vi.fn();
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()),
|
||||
loadGlobalConfig: mockLoadGlobalConfig,
|
||||
}));
|
||||
|
||||
@ -50,8 +50,11 @@ vi.mock('../utils/ui.js', () => ({
|
||||
success: vi.fn(),
|
||||
status: vi.fn(),
|
||||
blankLine: vi.fn(),
|
||||
header: vi.fn(),
|
||||
section: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock debug logger
|
||||
vi.mock('../utils/debug.js', () => ({
|
||||
createLogger: () => ({
|
||||
@ -61,7 +64,7 @@ vi.mock('../utils/debug.js', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { executePipeline } = await import('../commands/pipelineExecution.js');
|
||||
const { executePipeline } = await import('../commands/execution/pipelineExecution.js');
|
||||
|
||||
describe('executePipeline', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@ -8,7 +8,7 @@ vi.mock('../providers/index.js', () => ({
|
||||
getProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
vi.mock('../config/global/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
@ -21,7 +21,7 @@ vi.mock('../utils/debug.js', () => ({
|
||||
}));
|
||||
|
||||
import { getProvider } from '../providers/index.js';
|
||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
||||
import { loadGlobalConfig } from '../config/global/globalConfig.js';
|
||||
import { summarizeTaskName } from '../task/summarize.js';
|
||||
|
||||
const mockGetProvider = vi.mocked(getProvider);
|
||||
|
||||
@ -65,7 +65,7 @@ vi.mock('../constants.js', () => ({
|
||||
import { createSharedClone } from '../task/clone.js';
|
||||
import { summarizeTaskName } from '../task/summarize.js';
|
||||
import { info } from '../utils/ui.js';
|
||||
import { resolveTaskExecution } from '../commands/taskExecution.js';
|
||||
import { resolveTaskExecution } from '../commands/execution/taskExecution.js';
|
||||
import type { TaskInfo } from '../task/index.js';
|
||||
|
||||
const mockCreateSharedClone = vi.mocked(createSharedClone);
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { loadWorkflow } from '../config/loader.js';
|
||||
import { loadWorkflow } from '../config/loaders/loader.js';
|
||||
|
||||
describe('expert workflow parallel structure', () => {
|
||||
const workflow = loadWorkflow('expert', process.cwd());
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
loadWorkflowByIdentifier,
|
||||
listWorkflows,
|
||||
loadAllWorkflows,
|
||||
} from '../config/workflowLoader.js';
|
||||
} from '../config/loaders/workflowLoader.js';
|
||||
|
||||
const SAMPLE_WORKFLOW = `name: test-workflow
|
||||
description: Test workflow
|
||||
|
||||
@ -2,4 +2,5 @@
|
||||
* Agents module - exports agent execution utilities
|
||||
*/
|
||||
|
||||
export * from './runner.js';
|
||||
export { AgentRunner, runAgent, runCustomAgent } from './runner.js';
|
||||
export type { RunAgentOptions, StreamCallback } from './types.js';
|
||||
|
||||
@ -9,200 +9,143 @@ import {
|
||||
callClaudeSkill,
|
||||
type ClaudeCallOptions,
|
||||
} from '../claude/client.js';
|
||||
import { type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from '../claude/process.js';
|
||||
import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js';
|
||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
||||
import { loadProjectConfig } from '../config/projectConfig.js';
|
||||
import { loadCustomAgents, loadAgentPrompt } from '../config/loaders/loader.js';
|
||||
import { loadGlobalConfig } from '../config/global/globalConfig.js';
|
||||
import { loadProjectConfig } from '../config/project/projectConfig.js';
|
||||
import { getProvider, type ProviderType, type ProviderCallOptions } from '../providers/index.js';
|
||||
import type { AgentResponse, CustomAgentConfig, PermissionMode } from '../models/types.js';
|
||||
import type { AgentResponse, CustomAgentConfig } from '../models/types.js';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
import type { RunAgentOptions } from './types.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { RunAgentOptions, StreamCallback } from './types.js';
|
||||
|
||||
const log = createLogger('runner');
|
||||
|
||||
export type { StreamCallback };
|
||||
|
||||
/** Common options for running agents */
|
||||
export interface RunAgentOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
/** Resolved path to agent prompt file */
|
||||
agentPath?: string;
|
||||
/** Allowed tools for this agent run */
|
||||
allowedTools?: string[];
|
||||
/** Maximum number of agentic turns */
|
||||
maxTurns?: number;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
onStream?: StreamCallback;
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
/** Bypass all permission checks (sacrifice-my-pc mode) */
|
||||
bypassPermissions?: boolean;
|
||||
}
|
||||
|
||||
function resolveProvider(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): ProviderType {
|
||||
// Mock provider must be explicitly specified (no fallback)
|
||||
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';
|
||||
}
|
||||
|
||||
function resolveModel(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): string | undefined {
|
||||
if (options?.model) return options.model;
|
||||
if (agentConfig?.model) return agentConfig.model;
|
||||
try {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
if (globalConfig.model) return globalConfig.model;
|
||||
} catch {
|
||||
// Ignore missing global config
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
/** Run a custom agent */
|
||||
export async function runCustomAgent(
|
||||
agentConfig: CustomAgentConfig,
|
||||
task: string,
|
||||
options: RunAgentOptions
|
||||
): Promise<AgentResponse> {
|
||||
const allowedTools = options.allowedTools ?? agentConfig.allowedTools;
|
||||
|
||||
// If agent references a Claude Code agent
|
||||
if (agentConfig.claudeAgent) {
|
||||
const callOptions: ClaudeCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: resolveModel(options.cwd, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
};
|
||||
return callClaudeAgent(agentConfig.claudeAgent, task, callOptions);
|
||||
}
|
||||
|
||||
// If agent references a Claude Code skill
|
||||
if (agentConfig.claudeSkill) {
|
||||
const callOptions: ClaudeCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: resolveModel(options.cwd, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
};
|
||||
return callClaudeSkill(agentConfig.claudeSkill, task, callOptions);
|
||||
}
|
||||
|
||||
// Custom agent with prompt
|
||||
const systemPrompt = loadAgentPrompt(agentConfig);
|
||||
|
||||
const providerType = resolveProvider(options.cwd, options, agentConfig);
|
||||
const provider = getProvider(providerType);
|
||||
|
||||
const callOptions: ProviderCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: resolveModel(options.cwd, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
};
|
||||
|
||||
return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load agent prompt from file path.
|
||||
* Agent execution runner.
|
||||
*
|
||||
* Resolves agent configuration (provider, model, prompt) and
|
||||
* delegates execution to the appropriate provider.
|
||||
*/
|
||||
function loadAgentPromptFromPath(agentPath: string): string {
|
||||
if (!existsSync(agentPath)) {
|
||||
throw new Error(`Agent file not found: ${agentPath}`);
|
||||
}
|
||||
return readFileSync(agentPath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent name from path or spec.
|
||||
* For agents in subdirectories, includes parent dir for pattern matching.
|
||||
* - "~/.takt/agents/default/coder.md" -> "coder"
|
||||
* - "~/.takt/agents/research/supervisor.md" -> "research/supervisor"
|
||||
* - "./coder.md" -> "coder"
|
||||
* - "coder" -> "coder"
|
||||
*/
|
||||
function extractAgentName(agentSpec: string): string {
|
||||
if (!agentSpec.endsWith('.md')) {
|
||||
return agentSpec;
|
||||
}
|
||||
|
||||
const name = basename(agentSpec, '.md');
|
||||
const dir = basename(dirname(agentSpec));
|
||||
|
||||
// If in 'default' directory, just use the agent name
|
||||
// Otherwise, include the directory for disambiguation (e.g., 'research/supervisor')
|
||||
if (dir === 'default' || dir === 'agents' || dir === '.') {
|
||||
return name;
|
||||
}
|
||||
|
||||
return `${dir}/${name}`;
|
||||
}
|
||||
|
||||
/** Run an agent by name or path */
|
||||
export async function runAgent(
|
||||
agentSpec: string,
|
||||
task: string,
|
||||
options: RunAgentOptions
|
||||
): Promise<AgentResponse> {
|
||||
const agentName = extractAgentName(agentSpec);
|
||||
log.debug('Running agent', {
|
||||
agentSpec,
|
||||
agentName,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
hasAgentPath: !!options.agentPath,
|
||||
hasSession: !!options.sessionId,
|
||||
permissionMode: options.permissionMode,
|
||||
});
|
||||
|
||||
// If agentPath is provided (from workflow), use it to load prompt
|
||||
if (options.agentPath) {
|
||||
if (!existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent file not found: ${options.agentPath}`);
|
||||
export class AgentRunner {
|
||||
/** Resolve provider type from options, agent config, project config, global config */
|
||||
private static resolveProvider(
|
||||
cwd: string,
|
||||
options?: RunAgentOptions,
|
||||
agentConfig?: CustomAgentConfig,
|
||||
): ProviderType {
|
||||
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
|
||||
}
|
||||
const systemPrompt = loadAgentPromptFromPath(options.agentPath);
|
||||
return 'claude';
|
||||
}
|
||||
|
||||
const providerType = resolveProvider(options.cwd, options);
|
||||
/** Resolve model from options, agent config, global config */
|
||||
private static resolveModel(
|
||||
cwd: string,
|
||||
options?: RunAgentOptions,
|
||||
agentConfig?: CustomAgentConfig,
|
||||
): string | undefined {
|
||||
if (options?.model) return options.model;
|
||||
if (agentConfig?.model) return agentConfig.model;
|
||||
try {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
if (globalConfig.model) return globalConfig.model;
|
||||
} catch {
|
||||
// Ignore missing global config
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Load agent prompt from file path */
|
||||
private static loadAgentPromptFromPath(agentPath: string): string {
|
||||
if (!existsSync(agentPath)) {
|
||||
throw new Error(`Agent file not found: ${agentPath}`);
|
||||
}
|
||||
return readFileSync(agentPath, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent name from path or spec.
|
||||
* For agents in subdirectories, includes parent dir for pattern matching.
|
||||
*/
|
||||
private static extractAgentName(agentSpec: string): string {
|
||||
if (!agentSpec.endsWith('.md')) {
|
||||
return agentSpec;
|
||||
}
|
||||
|
||||
const name = basename(agentSpec, '.md');
|
||||
const dir = basename(dirname(agentSpec));
|
||||
|
||||
if (dir === 'default' || dir === 'agents' || dir === '.') {
|
||||
return name;
|
||||
}
|
||||
|
||||
return `${dir}/${name}`;
|
||||
}
|
||||
|
||||
/** Run a custom agent */
|
||||
async runCustom(
|
||||
agentConfig: CustomAgentConfig,
|
||||
task: string,
|
||||
options: RunAgentOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const allowedTools = options.allowedTools ?? agentConfig.allowedTools;
|
||||
|
||||
// If agent references a Claude Code agent
|
||||
if (agentConfig.claudeAgent) {
|
||||
const callOptions: ClaudeCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
};
|
||||
return callClaudeAgent(agentConfig.claudeAgent, task, callOptions);
|
||||
}
|
||||
|
||||
// If agent references a Claude Code skill
|
||||
if (agentConfig.claudeSkill) {
|
||||
const callOptions: ClaudeCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
};
|
||||
return callClaudeSkill(agentConfig.claudeSkill, task, callOptions);
|
||||
}
|
||||
|
||||
// Custom agent with prompt
|
||||
const systemPrompt = loadAgentPrompt(agentConfig);
|
||||
|
||||
const providerType = AgentRunner.resolveProvider(options.cwd, options, agentConfig);
|
||||
const provider = getProvider(providerType);
|
||||
|
||||
const callOptions: ProviderCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: resolveModel(options.cwd, options),
|
||||
systemPrompt,
|
||||
model: AgentRunner.resolveModel(options.cwd, options, agentConfig),
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
@ -210,16 +153,81 @@ export async function runAgent(
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
};
|
||||
|
||||
return provider.call(agentName, task, callOptions);
|
||||
return provider.callCustom(agentConfig.name, task, systemPrompt, callOptions);
|
||||
}
|
||||
|
||||
// Fallback: Look for custom agent by name
|
||||
const customAgents = loadCustomAgents();
|
||||
const agentConfig = customAgents.get(agentName);
|
||||
/** Run an agent by name or path */
|
||||
async run(
|
||||
agentSpec: string,
|
||||
task: string,
|
||||
options: RunAgentOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const agentName = AgentRunner.extractAgentName(agentSpec);
|
||||
log.debug('Running agent', {
|
||||
agentSpec,
|
||||
agentName,
|
||||
provider: options.provider,
|
||||
model: options.model,
|
||||
hasAgentPath: !!options.agentPath,
|
||||
hasSession: !!options.sessionId,
|
||||
permissionMode: options.permissionMode,
|
||||
});
|
||||
|
||||
if (agentConfig) {
|
||||
return runCustomAgent(agentConfig, task, options);
|
||||
// If agentPath is provided (from workflow), use it to load prompt
|
||||
if (options.agentPath) {
|
||||
if (!existsSync(options.agentPath)) {
|
||||
throw new Error(`Agent file not found: ${options.agentPath}`);
|
||||
}
|
||||
const systemPrompt = AgentRunner.loadAgentPromptFromPath(options.agentPath);
|
||||
|
||||
const providerType = AgentRunner.resolveProvider(options.cwd, options);
|
||||
const provider = getProvider(providerType);
|
||||
|
||||
const callOptions: ProviderCallOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
maxTurns: options.maxTurns,
|
||||
model: AgentRunner.resolveModel(options.cwd, options),
|
||||
systemPrompt,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
};
|
||||
|
||||
return provider.call(agentName, task, callOptions);
|
||||
}
|
||||
|
||||
// Fallback: Look for custom agent by name
|
||||
const customAgents = loadCustomAgents();
|
||||
const agentConfig = customAgents.get(agentName);
|
||||
|
||||
if (agentConfig) {
|
||||
return this.runCustom(agentConfig, task, options);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown agent: ${agentSpec}`);
|
||||
}
|
||||
|
||||
throw new Error(`Unknown agent: ${agentSpec}`);
|
||||
}
|
||||
|
||||
// ---- Backward-compatible module-level functions ----
|
||||
|
||||
const defaultRunner = new AgentRunner();
|
||||
|
||||
export async function runAgent(
|
||||
agentSpec: string,
|
||||
task: string,
|
||||
options: RunAgentOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return defaultRunner.run(agentSpec, task, options);
|
||||
}
|
||||
|
||||
export async function runCustomAgent(
|
||||
agentConfig: CustomAgentConfig,
|
||||
task: string,
|
||||
options: RunAgentOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return defaultRunner.runCustom(agentConfig, task, options);
|
||||
}
|
||||
|
||||
29
src/agents/types.ts
Normal file
29
src/agents/types.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Type definitions for agent execution
|
||||
*/
|
||||
|
||||
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/types.js';
|
||||
import type { PermissionMode } from '../models/types.js';
|
||||
|
||||
export type { StreamCallback };
|
||||
|
||||
/** Common options for running agents */
|
||||
export interface RunAgentOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
/** Resolved path to agent prompt file */
|
||||
agentPath?: string;
|
||||
/** Allowed tools for this agent run */
|
||||
allowedTools?: string[];
|
||||
/** Maximum number of agentic turns */
|
||||
maxTurns?: number;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
onStream?: StreamCallback;
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
/** Bypass all permission checks (sacrifice-my-pc mode) */
|
||||
bypassPermissions?: boolean;
|
||||
}
|
||||
@ -4,36 +4,15 @@
|
||||
* Uses the Claude Agent SDK for native TypeScript integration.
|
||||
*/
|
||||
|
||||
import { executeClaudeCli, type ClaudeSpawnOptions, type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from './process.js';
|
||||
import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { AgentResponse, Status, PermissionMode } from '../models/types.js';
|
||||
import { executeClaudeCli } from './process.js';
|
||||
import type { ClaudeSpawnOptions, ClaudeCallOptions } from './types.js';
|
||||
import type { AgentResponse, Status } from '../models/types.js';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
|
||||
const log = createLogger('client');
|
||||
// Re-export for backward compatibility
|
||||
export type { ClaudeCallOptions } from './types.js';
|
||||
|
||||
/** Options for calling Claude */
|
||||
export interface ClaudeCallOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
allowedTools?: string[];
|
||||
model?: string;
|
||||
maxTurns?: number;
|
||||
systemPrompt?: string;
|
||||
/** SDK agents to register for sub-agent execution */
|
||||
agents?: Record<string, AgentDefinition>;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Enable streaming mode with callback for real-time output */
|
||||
onStream?: StreamCallback;
|
||||
/** Custom permission handler for interactive permission prompts */
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
/** Custom handler for AskUserQuestion tool */
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
/** Bypass all permission checks (sacrifice-my-pc mode) */
|
||||
bypassPermissions?: boolean;
|
||||
/** Anthropic API key to inject via env (bypasses CLI auth) */
|
||||
anthropicApiKey?: string;
|
||||
}
|
||||
const log = createLogger('client');
|
||||
|
||||
/**
|
||||
* Detect rule index from numbered tag pattern [STEP_NAME:N].
|
||||
@ -55,12 +34,10 @@ export function detectRuleIndex(content: string, stepName: string): number {
|
||||
|
||||
/** Validate regex pattern for ReDoS safety */
|
||||
export function isRegexSafe(pattern: string): boolean {
|
||||
// Limit pattern length
|
||||
if (pattern.length > 200) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Dangerous patterns that can cause ReDoS
|
||||
const dangerousPatterns = [
|
||||
/\(\.\*\)\+/, // (.*)+
|
||||
/\(\.\+\)\*/, // (.+)*
|
||||
@ -79,219 +56,270 @@ export function isRegexSafe(pattern: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Determine status from result */
|
||||
function determineStatus(
|
||||
result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string },
|
||||
): Status {
|
||||
if (!result.success) {
|
||||
if (result.interrupted) {
|
||||
return 'interrupted';
|
||||
/**
|
||||
* High-level Claude client for calling Claude with various configurations.
|
||||
*
|
||||
* Handles agent prompts, custom agents, skills, and AI judge evaluation.
|
||||
*/
|
||||
export class ClaudeClient {
|
||||
/** Determine status from execution result */
|
||||
private static determineStatus(
|
||||
result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string },
|
||||
): Status {
|
||||
if (!result.success) {
|
||||
if (result.interrupted) {
|
||||
return 'interrupted';
|
||||
}
|
||||
return 'blocked';
|
||||
}
|
||||
return 'blocked';
|
||||
return 'done';
|
||||
}
|
||||
|
||||
/** Convert ClaudeCallOptions to ClaudeSpawnOptions */
|
||||
private static toSpawnOptions(options: ClaudeCallOptions): ClaudeSpawnOptions {
|
||||
return {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
systemPrompt: options.systemPrompt,
|
||||
agents: options.agents,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
anthropicApiKey: options.anthropicApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** Call Claude with an agent prompt */
|
||||
async call(
|
||||
agentType: string,
|
||||
prompt: string,
|
||||
options: ClaudeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const spawnOptions = ClaudeClient.toSpawnOptions(options);
|
||||
const result = await executeClaudeCli(prompt, spawnOptions);
|
||||
const status = ClaudeClient.determineStatus(result);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
log.error('Agent query failed', { agent: agentType, error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
agent: agentType,
|
||||
status,
|
||||
content: result.content,
|
||||
timestamp: new Date(),
|
||||
sessionId: result.sessionId,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
/** Call Claude with a custom agent configuration */
|
||||
async callCustom(
|
||||
agentName: string,
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
options: ClaudeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
...ClaudeClient.toSpawnOptions(options),
|
||||
systemPrompt,
|
||||
};
|
||||
const result = await executeClaudeCli(prompt, spawnOptions);
|
||||
const status = ClaudeClient.determineStatus(result);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
log.error('Agent query failed', { agent: agentName, error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
agent: agentName,
|
||||
status,
|
||||
content: result.content,
|
||||
timestamp: new Date(),
|
||||
sessionId: result.sessionId,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
/** Call a Claude Code built-in agent */
|
||||
async callAgent(
|
||||
claudeAgentName: string,
|
||||
prompt: string,
|
||||
options: ClaudeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const systemPrompt = `You are the ${claudeAgentName} agent. Follow the standard ${claudeAgentName} workflow.`;
|
||||
return this.callCustom(claudeAgentName, prompt, systemPrompt, options);
|
||||
}
|
||||
|
||||
/** Call a Claude Code skill (using /skill command) */
|
||||
async callSkill(
|
||||
skillName: string,
|
||||
prompt: string,
|
||||
options: ClaudeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const fullPrompt = `/${skillName}\n\n${prompt}`;
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
anthropicApiKey: options.anthropicApiKey,
|
||||
};
|
||||
|
||||
const result = await executeClaudeCli(fullPrompt, spawnOptions);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
log.error('Skill query failed', { skill: skillName, error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
agent: `skill:${skillName}`,
|
||||
status: result.success ? 'done' : 'blocked',
|
||||
content: result.content,
|
||||
timestamp: new Date(),
|
||||
sessionId: result.sessionId,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect judge rule index from [JUDGE:N] tag pattern.
|
||||
* Returns 0-based rule index, or -1 if no match.
|
||||
*/
|
||||
static detectJudgeIndex(content: string): number {
|
||||
const regex = /\[JUDGE:(\d+)\]/i;
|
||||
const match = content.match(regex);
|
||||
if (match?.[1]) {
|
||||
const index = Number.parseInt(match[1], 10) - 1;
|
||||
return index >= 0 ? index : -1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for the AI judge that evaluates agent output against ai() conditions.
|
||||
*/
|
||||
static buildJudgePrompt(
|
||||
agentOutput: string,
|
||||
aiConditions: { index: number; text: string }[],
|
||||
): string {
|
||||
const conditionList = aiConditions
|
||||
.map((c) => `| ${c.index + 1} | ${c.text} |`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'# Judge Task',
|
||||
'',
|
||||
'You are a judge evaluating an agent\'s output against a set of conditions.',
|
||||
'Read the agent output below, then determine which condition best matches.',
|
||||
'',
|
||||
'## Agent Output',
|
||||
'```',
|
||||
agentOutput,
|
||||
'```',
|
||||
'',
|
||||
'## Conditions',
|
||||
'| # | Condition |',
|
||||
'|---|-----------|',
|
||||
conditionList,
|
||||
'',
|
||||
'## Instructions',
|
||||
'Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.',
|
||||
'Do not output anything else.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI judge to evaluate agent output against ai() conditions.
|
||||
* Uses a lightweight model (haiku) for cost efficiency.
|
||||
* Returns 0-based index of the matched ai() condition, or -1 if no match.
|
||||
*/
|
||||
async callAiJudge(
|
||||
agentOutput: string,
|
||||
aiConditions: { index: number; text: string }[],
|
||||
options: { cwd: string },
|
||||
): Promise<number> {
|
||||
const prompt = ClaudeClient.buildJudgePrompt(agentOutput, aiConditions);
|
||||
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
cwd: options.cwd,
|
||||
model: 'haiku',
|
||||
maxTurns: 1,
|
||||
};
|
||||
|
||||
const result = await executeClaudeCli(prompt, spawnOptions);
|
||||
if (!result.success) {
|
||||
log.error('AI judge call failed', { error: result.error });
|
||||
return -1;
|
||||
}
|
||||
|
||||
return ClaudeClient.detectJudgeIndex(result.content);
|
||||
}
|
||||
return 'done';
|
||||
}
|
||||
|
||||
/** Call Claude with an agent prompt */
|
||||
// ---- Backward-compatible module-level functions ----
|
||||
|
||||
const defaultClient = new ClaudeClient();
|
||||
|
||||
export async function callClaude(
|
||||
agentType: string,
|
||||
prompt: string,
|
||||
options: ClaudeCallOptions
|
||||
options: ClaudeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
systemPrompt: options.systemPrompt,
|
||||
agents: options.agents,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
anthropicApiKey: options.anthropicApiKey,
|
||||
};
|
||||
|
||||
const result = await executeClaudeCli(prompt, spawnOptions);
|
||||
const status = determineStatus(result);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
log.error('Agent query failed', { agent: agentType, error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
agent: agentType,
|
||||
status,
|
||||
content: result.content,
|
||||
timestamp: new Date(),
|
||||
sessionId: result.sessionId,
|
||||
error: result.error,
|
||||
};
|
||||
return defaultClient.call(agentType, prompt, options);
|
||||
}
|
||||
|
||||
/** Call Claude with a custom agent configuration */
|
||||
export async function callClaudeCustom(
|
||||
agentName: string,
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
options: ClaudeCallOptions
|
||||
options: ClaudeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
systemPrompt,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
anthropicApiKey: options.anthropicApiKey,
|
||||
};
|
||||
|
||||
const result = await executeClaudeCli(prompt, spawnOptions);
|
||||
const status = determineStatus(result);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
log.error('Agent query failed', { agent: agentName, error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
agent: agentName,
|
||||
status,
|
||||
content: result.content,
|
||||
timestamp: new Date(),
|
||||
sessionId: result.sessionId,
|
||||
error: result.error,
|
||||
};
|
||||
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
|
||||
}
|
||||
|
||||
export async function callClaudeAgent(
|
||||
claudeAgentName: string,
|
||||
prompt: string,
|
||||
options: ClaudeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return defaultClient.callAgent(claudeAgentName, prompt, options);
|
||||
}
|
||||
|
||||
export async function callClaudeSkill(
|
||||
skillName: string,
|
||||
prompt: string,
|
||||
options: ClaudeCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return defaultClient.callSkill(skillName, prompt, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect judge rule index from [JUDGE:N] tag pattern.
|
||||
* Returns 0-based rule index, or -1 if no match.
|
||||
*/
|
||||
export function detectJudgeIndex(content: string): number {
|
||||
const regex = /\[JUDGE:(\d+)\]/i;
|
||||
const match = content.match(regex);
|
||||
if (match?.[1]) {
|
||||
const index = Number.parseInt(match[1], 10) - 1;
|
||||
return index >= 0 ? index : -1;
|
||||
}
|
||||
return -1;
|
||||
return ClaudeClient.detectJudgeIndex(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prompt for the AI judge that evaluates agent output against ai() conditions.
|
||||
*/
|
||||
export function buildJudgePrompt(
|
||||
agentOutput: string,
|
||||
aiConditions: { index: number; text: string }[],
|
||||
): string {
|
||||
const conditionList = aiConditions
|
||||
.map((c) => `| ${c.index + 1} | ${c.text} |`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'# Judge Task',
|
||||
'',
|
||||
'You are a judge evaluating an agent\'s output against a set of conditions.',
|
||||
'Read the agent output below, then determine which condition best matches.',
|
||||
'',
|
||||
'## Agent Output',
|
||||
'```',
|
||||
agentOutput,
|
||||
'```',
|
||||
'',
|
||||
'## Conditions',
|
||||
'| # | Condition |',
|
||||
'|---|-----------|',
|
||||
conditionList,
|
||||
'',
|
||||
'## Instructions',
|
||||
'Output ONLY the tag `[JUDGE:N]` where N is the number of the best matching condition.',
|
||||
'Do not output anything else.',
|
||||
].join('\n');
|
||||
return ClaudeClient.buildJudgePrompt(agentOutput, aiConditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI judge to evaluate agent output against ai() conditions.
|
||||
* Uses a lightweight model (haiku) for cost efficiency.
|
||||
* Returns 0-based index of the matched ai() condition, or -1 if no match.
|
||||
*/
|
||||
export async function callAiJudge(
|
||||
agentOutput: string,
|
||||
aiConditions: { index: number; text: string }[],
|
||||
options: { cwd: string },
|
||||
): Promise<number> {
|
||||
const prompt = buildJudgePrompt(agentOutput, aiConditions);
|
||||
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
cwd: options.cwd,
|
||||
model: 'haiku',
|
||||
maxTurns: 1,
|
||||
};
|
||||
|
||||
const result = await executeClaudeCli(prompt, spawnOptions);
|
||||
if (!result.success) {
|
||||
log.error('AI judge call failed', { error: result.error });
|
||||
return -1;
|
||||
}
|
||||
|
||||
return detectJudgeIndex(result.content);
|
||||
}
|
||||
|
||||
/** Call a Claude Code built-in agent (using claude --agent flag if available) */
|
||||
export async function callClaudeAgent(
|
||||
claudeAgentName: string,
|
||||
prompt: string,
|
||||
options: ClaudeCallOptions
|
||||
): Promise<AgentResponse> {
|
||||
// For now, use system prompt approach
|
||||
// In future, could use --agent flag if Claude CLI supports it
|
||||
const systemPrompt = `You are the ${claudeAgentName} agent. Follow the standard ${claudeAgentName} workflow.`;
|
||||
|
||||
return callClaudeCustom(claudeAgentName, prompt, systemPrompt, options);
|
||||
}
|
||||
|
||||
/** Call a Claude Code skill (using /skill command) */
|
||||
export async function callClaudeSkill(
|
||||
skillName: string,
|
||||
prompt: string,
|
||||
options: ClaudeCallOptions
|
||||
): Promise<AgentResponse> {
|
||||
// Prepend skill invocation to prompt
|
||||
const fullPrompt = `/${skillName}\n\n${prompt}`;
|
||||
|
||||
const spawnOptions: ClaudeSpawnOptions = {
|
||||
cwd: options.cwd,
|
||||
sessionId: options.sessionId,
|
||||
allowedTools: options.allowedTools,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
permissionMode: options.permissionMode,
|
||||
onStream: options.onStream,
|
||||
onPermissionRequest: options.onPermissionRequest,
|
||||
onAskUserQuestion: options.onAskUserQuestion,
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
anthropicApiKey: options.anthropicApiKey,
|
||||
};
|
||||
|
||||
const result = await executeClaudeCli(fullPrompt, spawnOptions);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
log.error('Skill query failed', { skill: skillName, error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
agent: `skill:${skillName}`,
|
||||
status: result.success ? 'done' : 'blocked',
|
||||
content: result.content,
|
||||
timestamp: new Date(),
|
||||
sessionId: result.sessionId,
|
||||
error: result.error,
|
||||
};
|
||||
return defaultClient.callAiJudge(agentOutput, aiConditions, options);
|
||||
}
|
||||
|
||||
@ -8,11 +8,8 @@
|
||||
import {
|
||||
query,
|
||||
AbortError,
|
||||
type Options,
|
||||
type SDKResultMessage,
|
||||
type SDKAssistantMessage,
|
||||
type AgentDefinition,
|
||||
type PermissionMode,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
import { getErrorMessage } from '../utils/error.js';
|
||||
@ -22,233 +19,168 @@ import {
|
||||
unregisterQuery,
|
||||
} from './query-manager.js';
|
||||
import { sdkMessageToStreamEvent } from './stream-converter.js';
|
||||
import {
|
||||
createCanUseToolCallback,
|
||||
createAskUserQuestionHooks,
|
||||
} from './options-builder.js';
|
||||
import { SdkOptionsBuilder } from './options-builder.js';
|
||||
import type {
|
||||
StreamCallback,
|
||||
PermissionHandler,
|
||||
AskUserQuestionHandler,
|
||||
ClaudeSpawnOptions,
|
||||
ClaudeResult,
|
||||
} from './types.js';
|
||||
|
||||
const log = createLogger('claude-sdk');
|
||||
|
||||
/** Options for executing Claude queries */
|
||||
export interface ExecuteOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
allowedTools?: string[];
|
||||
model?: string;
|
||||
maxTurns?: number;
|
||||
systemPrompt?: string;
|
||||
onStream?: StreamCallback;
|
||||
agents?: Record<string, AgentDefinition>;
|
||||
permissionMode?: PermissionMode;
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
/** Bypass all permission checks (sacrifice-my-pc mode) */
|
||||
bypassPermissions?: boolean;
|
||||
/** Anthropic API key to inject via env (bypasses CLI auth) */
|
||||
anthropicApiKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SDK options from ExecuteOptions.
|
||||
* Executes Claude queries using the Agent SDK.
|
||||
*
|
||||
* Handles query lifecycle (register/unregister), streaming,
|
||||
* assistant text accumulation, and error classification.
|
||||
*/
|
||||
function buildSdkOptions(options: ExecuteOptions): Options {
|
||||
const canUseTool = options.onPermissionRequest
|
||||
? createCanUseToolCallback(options.onPermissionRequest)
|
||||
: undefined;
|
||||
export class QueryExecutor {
|
||||
/**
|
||||
* Execute a Claude query.
|
||||
*/
|
||||
async execute(
|
||||
prompt: string,
|
||||
options: ClaudeSpawnOptions,
|
||||
): Promise<ClaudeResult> {
|
||||
const queryId = generateQueryId();
|
||||
|
||||
const hooks = options.onAskUserQuestion
|
||||
? createAskUserQuestionHooks(options.onAskUserQuestion)
|
||||
: undefined;
|
||||
log.debug('Executing Claude query via SDK', {
|
||||
queryId,
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
hasSystemPrompt: !!options.systemPrompt,
|
||||
allowedTools: options.allowedTools,
|
||||
});
|
||||
|
||||
// Determine permission mode
|
||||
// Priority: bypassPermissions > explicit permissionMode > callback-based default
|
||||
let permissionMode: PermissionMode;
|
||||
if (options.bypassPermissions) {
|
||||
permissionMode = 'bypassPermissions';
|
||||
} else if (options.permissionMode) {
|
||||
permissionMode = options.permissionMode;
|
||||
} else if (options.onPermissionRequest) {
|
||||
permissionMode = 'default';
|
||||
} else {
|
||||
permissionMode = 'acceptEdits';
|
||||
}
|
||||
const sdkOptions = new SdkOptionsBuilder(options).build();
|
||||
|
||||
// Only include defined values — the SDK treats key-present-but-undefined
|
||||
// differently from key-absent for some options (e.g. model), causing hangs.
|
||||
const sdkOptions: Options = {
|
||||
cwd: options.cwd,
|
||||
permissionMode,
|
||||
};
|
||||
let sessionId: string | undefined;
|
||||
let success = false;
|
||||
let resultContent: string | undefined;
|
||||
let hasResultMessage = false;
|
||||
let accumulatedAssistantText = '';
|
||||
|
||||
if (options.model) sdkOptions.model = options.model;
|
||||
if (options.maxTurns != null) sdkOptions.maxTurns = options.maxTurns;
|
||||
if (options.allowedTools) sdkOptions.allowedTools = options.allowedTools;
|
||||
if (options.agents) sdkOptions.agents = options.agents;
|
||||
if (options.systemPrompt) sdkOptions.systemPrompt = options.systemPrompt;
|
||||
if (canUseTool) sdkOptions.canUseTool = canUseTool;
|
||||
if (hooks) sdkOptions.hooks = hooks;
|
||||
try {
|
||||
const q = query({ prompt, options: sdkOptions });
|
||||
registerQuery(queryId, q);
|
||||
|
||||
if (options.anthropicApiKey) {
|
||||
sdkOptions.env = {
|
||||
...process.env as Record<string, string>,
|
||||
ANTHROPIC_API_KEY: options.anthropicApiKey,
|
||||
};
|
||||
}
|
||||
for await (const message of q) {
|
||||
if ('session_id' in message) {
|
||||
sessionId = message.session_id;
|
||||
}
|
||||
|
||||
if (options.onStream) {
|
||||
sdkOptions.includePartialMessages = true;
|
||||
}
|
||||
if (options.onStream) {
|
||||
sdkMessageToStreamEvent(message, options.onStream, true);
|
||||
}
|
||||
|
||||
if (options.sessionId) {
|
||||
sdkOptions.resume = options.sessionId;
|
||||
} else {
|
||||
sdkOptions.continue = false;
|
||||
}
|
||||
if (message.type === 'assistant') {
|
||||
const assistantMsg = message as SDKAssistantMessage;
|
||||
for (const block of assistantMsg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
accumulatedAssistantText += block.text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sdkOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Claude query using the Agent SDK.
|
||||
*/
|
||||
export async function executeClaudeQuery(
|
||||
prompt: string,
|
||||
options: ExecuteOptions
|
||||
): Promise<ClaudeResult> {
|
||||
const queryId = generateQueryId();
|
||||
|
||||
log.debug('Executing Claude query via SDK', {
|
||||
queryId,
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
hasSystemPrompt: !!options.systemPrompt,
|
||||
allowedTools: options.allowedTools,
|
||||
});
|
||||
|
||||
const sdkOptions = buildSdkOptions(options);
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let success = false;
|
||||
let resultContent: string | undefined;
|
||||
let hasResultMessage = false;
|
||||
let accumulatedAssistantText = '';
|
||||
|
||||
try {
|
||||
const q = query({ prompt, options: sdkOptions });
|
||||
registerQuery(queryId, q);
|
||||
|
||||
for await (const message of q) {
|
||||
if ('session_id' in message) {
|
||||
sessionId = message.session_id;
|
||||
}
|
||||
|
||||
if (options.onStream) {
|
||||
sdkMessageToStreamEvent(message, options.onStream, true);
|
||||
}
|
||||
|
||||
if (message.type === 'assistant') {
|
||||
const assistantMsg = message as SDKAssistantMessage;
|
||||
for (const block of assistantMsg.message.content) {
|
||||
if (block.type === 'text') {
|
||||
accumulatedAssistantText += block.text;
|
||||
if (message.type === 'result') {
|
||||
hasResultMessage = true;
|
||||
const resultMsg = message as SDKResultMessage;
|
||||
if (resultMsg.subtype === 'success') {
|
||||
resultContent = resultMsg.result;
|
||||
success = true;
|
||||
} else {
|
||||
success = false;
|
||||
if (resultMsg.errors && resultMsg.errors.length > 0) {
|
||||
resultContent = resultMsg.errors.join('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === 'result') {
|
||||
hasResultMessage = true;
|
||||
const resultMsg = message as SDKResultMessage;
|
||||
if (resultMsg.subtype === 'success') {
|
||||
resultContent = resultMsg.result;
|
||||
success = true;
|
||||
} else {
|
||||
success = false;
|
||||
if (resultMsg.errors && resultMsg.errors.length > 0) {
|
||||
resultContent = resultMsg.errors.join('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
unregisterQuery(queryId);
|
||||
|
||||
const finalContent = resultContent || accumulatedAssistantText;
|
||||
|
||||
log.info('Claude query completed', {
|
||||
queryId,
|
||||
sessionId,
|
||||
contentLength: finalContent.length,
|
||||
success,
|
||||
hasResultMessage,
|
||||
});
|
||||
|
||||
return {
|
||||
success,
|
||||
content: finalContent.trim(),
|
||||
sessionId,
|
||||
fullContent: accumulatedAssistantText.trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
unregisterQuery(queryId);
|
||||
return QueryExecutor.handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query execution errors.
|
||||
* Classifies errors (abort, rate limit, auth, timeout) and returns appropriate ClaudeResult.
|
||||
*/
|
||||
private static handleQueryError(
|
||||
error: unknown,
|
||||
queryId: string,
|
||||
sessionId: string | undefined,
|
||||
hasResultMessage: boolean,
|
||||
success: boolean,
|
||||
resultContent: string | undefined,
|
||||
): ClaudeResult {
|
||||
if (error instanceof AbortError) {
|
||||
log.info('Claude query was interrupted', { queryId });
|
||||
return {
|
||||
success: false,
|
||||
content: '',
|
||||
error: 'Query interrupted',
|
||||
interrupted: true,
|
||||
};
|
||||
}
|
||||
|
||||
unregisterQuery(queryId);
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
const finalContent = resultContent || accumulatedAssistantText;
|
||||
if (hasResultMessage && success) {
|
||||
log.info('Claude query completed with post-completion error (ignoring)', {
|
||||
queryId,
|
||||
sessionId,
|
||||
error: errorMessage,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
content: (resultContent ?? '').trim(),
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
log.info('Claude query completed', {
|
||||
queryId,
|
||||
sessionId,
|
||||
contentLength: finalContent.length,
|
||||
success,
|
||||
hasResultMessage,
|
||||
});
|
||||
log.error('Claude query failed', { queryId, error: errorMessage });
|
||||
|
||||
return {
|
||||
success,
|
||||
content: finalContent.trim(),
|
||||
sessionId,
|
||||
fullContent: accumulatedAssistantText.trim(),
|
||||
};
|
||||
} catch (error) {
|
||||
unregisterQuery(queryId);
|
||||
return handleQueryError(error, queryId, sessionId, hasResultMessage, success, resultContent);
|
||||
if (errorMessage.includes('rate_limit') || errorMessage.includes('rate limit')) {
|
||||
return { success: false, content: '', error: 'Rate limit exceeded. Please try again later.' };
|
||||
}
|
||||
|
||||
if (errorMessage.includes('authentication') || errorMessage.includes('unauthorized')) {
|
||||
return { success: false, content: '', error: 'Authentication failed. Please check your API credentials.' };
|
||||
}
|
||||
|
||||
if (errorMessage.includes('timeout')) {
|
||||
return { success: false, content: '', error: 'Request timed out. Please try again.' };
|
||||
}
|
||||
|
||||
return { success: false, content: '', error: errorMessage };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle query execution errors.
|
||||
*/
|
||||
function handleQueryError(
|
||||
error: unknown,
|
||||
queryId: string,
|
||||
sessionId: string | undefined,
|
||||
hasResultMessage: boolean,
|
||||
success: boolean,
|
||||
resultContent: string | undefined
|
||||
): ClaudeResult {
|
||||
if (error instanceof AbortError) {
|
||||
log.info('Claude query was interrupted', { queryId });
|
||||
return {
|
||||
success: false,
|
||||
content: '',
|
||||
error: 'Query interrupted',
|
||||
interrupted: true,
|
||||
};
|
||||
}
|
||||
// ---- Backward-compatible module-level function ----
|
||||
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
if (hasResultMessage && success) {
|
||||
log.info('Claude query completed with post-completion error (ignoring)', {
|
||||
queryId,
|
||||
sessionId,
|
||||
error: errorMessage,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
content: (resultContent ?? '').trim(),
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
log.error('Claude query failed', { queryId, error: errorMessage });
|
||||
|
||||
if (errorMessage.includes('rate_limit') || errorMessage.includes('rate limit')) {
|
||||
return { success: false, content: '', error: 'Rate limit exceeded. Please try again later.' };
|
||||
}
|
||||
|
||||
if (errorMessage.includes('authentication') || errorMessage.includes('unauthorized')) {
|
||||
return { success: false, content: '', error: 'Authentication failed. Please check your API credentials.' };
|
||||
}
|
||||
|
||||
if (errorMessage.includes('timeout')) {
|
||||
return { success: false, content: '', error: 'Request timed out. Please try again.' };
|
||||
}
|
||||
|
||||
return { success: false, content: '', error: errorMessage };
|
||||
/** @deprecated Use QueryExecutor.execute() instead */
|
||||
export async function executeClaudeQuery(
|
||||
prompt: string,
|
||||
options: ClaudeSpawnOptions,
|
||||
): Promise<ClaudeResult> {
|
||||
return new QueryExecutor().execute(prompt, options);
|
||||
}
|
||||
|
||||
@ -5,11 +5,18 @@
|
||||
* from the Claude integration module.
|
||||
*/
|
||||
|
||||
// Main process and execution
|
||||
export { ClaudeProcess, executeClaudeCli, type ClaudeSpawnOptions } from './process.js';
|
||||
export { executeClaudeQuery, type ExecuteOptions } from './executor.js';
|
||||
// Classes
|
||||
export { ClaudeClient } from './client.js';
|
||||
export { ClaudeProcess } from './process.js';
|
||||
export { QueryExecutor } from './executor.js';
|
||||
export { QueryRegistry } from './query-manager.js';
|
||||
export { SdkOptionsBuilder } from './options-builder.js';
|
||||
|
||||
// Query management (only from query-manager, process.ts re-exports these)
|
||||
// Main process and execution
|
||||
export { executeClaudeCli } from './process.js';
|
||||
export { executeClaudeQuery } from './executor.js';
|
||||
|
||||
// Query management
|
||||
export {
|
||||
generateQueryId,
|
||||
hasActiveProcess,
|
||||
@ -22,7 +29,7 @@ export {
|
||||
interruptCurrentProcess,
|
||||
} from './query-manager.js';
|
||||
|
||||
// Types (only from types.ts, avoiding duplicates from process.ts)
|
||||
// Types
|
||||
export type {
|
||||
StreamEvent,
|
||||
StreamCallback,
|
||||
@ -32,6 +39,8 @@ export type {
|
||||
AskUserQuestionHandler,
|
||||
ClaudeResult,
|
||||
ClaudeResultWithQueryId,
|
||||
ClaudeCallOptions,
|
||||
ClaudeSpawnOptions,
|
||||
InitEventData,
|
||||
ToolUseEventData,
|
||||
ToolResultEventData,
|
||||
@ -45,19 +54,22 @@ export type {
|
||||
// Stream conversion
|
||||
export { sdkMessageToStreamEvent } from './stream-converter.js';
|
||||
|
||||
// Options building
|
||||
// Options building (backward-compatible functions)
|
||||
export {
|
||||
createCanUseToolCallback,
|
||||
createAskUserQuestionHooks,
|
||||
buildSdkOptions,
|
||||
} from './options-builder.js';
|
||||
|
||||
// Client functions and types
|
||||
// Client functions (backward-compatible)
|
||||
export {
|
||||
callClaude,
|
||||
callClaudeCustom,
|
||||
callClaudeAgent,
|
||||
callClaudeSkill,
|
||||
callAiJudge,
|
||||
detectRuleIndex,
|
||||
detectJudgeIndex,
|
||||
buildJudgePrompt,
|
||||
isRegexSafe,
|
||||
type ClaudeCallOptions,
|
||||
} from './client.js';
|
||||
|
||||
@ -14,7 +14,6 @@ import type {
|
||||
HookInput,
|
||||
HookJSONOutput,
|
||||
PreToolUseHookInput,
|
||||
AgentDefinition,
|
||||
PermissionMode,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
@ -22,131 +21,165 @@ import type {
|
||||
PermissionHandler,
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionHandler,
|
||||
ClaudeSpawnOptions,
|
||||
} from './types.js';
|
||||
|
||||
const log = createLogger('claude-sdk');
|
||||
|
||||
/** Options for calling Claude via SDK */
|
||||
export interface ClaudeSpawnOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
allowedTools?: string[];
|
||||
model?: string;
|
||||
maxTurns?: number;
|
||||
systemPrompt?: string;
|
||||
/** Enable streaming mode */
|
||||
hasStream?: boolean;
|
||||
/** Custom agents to register */
|
||||
agents?: Record<string, AgentDefinition>;
|
||||
/** Permission mode for tool execution */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Custom permission handler for interactive permission prompts */
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
/** Custom handler for AskUserQuestion tool */
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
/**
|
||||
* Builds SDK options from ClaudeSpawnOptions.
|
||||
*
|
||||
* Handles permission mode resolution, canUseTool callback creation,
|
||||
* and AskUserQuestion hook setup.
|
||||
*/
|
||||
export class SdkOptionsBuilder {
|
||||
private readonly options: ClaudeSpawnOptions;
|
||||
|
||||
constructor(options: ClaudeSpawnOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/** Build the full SDK Options object */
|
||||
build(): Options {
|
||||
const canUseTool = this.options.onPermissionRequest
|
||||
? SdkOptionsBuilder.createCanUseToolCallback(this.options.onPermissionRequest)
|
||||
: undefined;
|
||||
|
||||
const hooks = this.options.onAskUserQuestion
|
||||
? SdkOptionsBuilder.createAskUserQuestionHooks(this.options.onAskUserQuestion)
|
||||
: undefined;
|
||||
|
||||
const permissionMode = this.resolvePermissionMode();
|
||||
|
||||
// Only include defined values — the SDK treats key-present-but-undefined
|
||||
// differently from key-absent for some options (e.g. model), causing hangs.
|
||||
const sdkOptions: Options = {
|
||||
cwd: this.options.cwd,
|
||||
permissionMode,
|
||||
};
|
||||
|
||||
if (this.options.model) sdkOptions.model = this.options.model;
|
||||
if (this.options.maxTurns != null) sdkOptions.maxTurns = this.options.maxTurns;
|
||||
if (this.options.allowedTools) sdkOptions.allowedTools = this.options.allowedTools;
|
||||
if (this.options.agents) sdkOptions.agents = this.options.agents;
|
||||
if (this.options.systemPrompt) sdkOptions.systemPrompt = this.options.systemPrompt;
|
||||
if (canUseTool) sdkOptions.canUseTool = canUseTool;
|
||||
if (hooks) sdkOptions.hooks = hooks;
|
||||
|
||||
if (this.options.anthropicApiKey) {
|
||||
sdkOptions.env = {
|
||||
...process.env as Record<string, string>,
|
||||
ANTHROPIC_API_KEY: this.options.anthropicApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
if (this.options.onStream) {
|
||||
sdkOptions.includePartialMessages = true;
|
||||
}
|
||||
|
||||
if (this.options.sessionId) {
|
||||
sdkOptions.resume = this.options.sessionId;
|
||||
} else {
|
||||
sdkOptions.continue = false;
|
||||
}
|
||||
|
||||
return sdkOptions;
|
||||
}
|
||||
|
||||
/** Resolve permission mode with priority: bypassPermissions > explicit > callback-based > default */
|
||||
private resolvePermissionMode(): PermissionMode {
|
||||
if (this.options.bypassPermissions) {
|
||||
return 'bypassPermissions';
|
||||
}
|
||||
if (this.options.permissionMode) {
|
||||
return this.options.permissionMode;
|
||||
}
|
||||
if (this.options.onPermissionRequest) {
|
||||
return 'default';
|
||||
}
|
||||
return 'acceptEdits';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create canUseTool callback from permission handler.
|
||||
*/
|
||||
static createCanUseToolCallback(
|
||||
handler: PermissionHandler
|
||||
): CanUseTool {
|
||||
return async (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
callbackOptions: {
|
||||
signal: AbortSignal;
|
||||
suggestions?: PermissionUpdate[];
|
||||
blockedPath?: string;
|
||||
decisionReason?: string;
|
||||
}
|
||||
): Promise<PermissionResult> => {
|
||||
return handler({
|
||||
toolName,
|
||||
input,
|
||||
suggestions: callbackOptions.suggestions,
|
||||
blockedPath: callbackOptions.blockedPath,
|
||||
decisionReason: callbackOptions.decisionReason,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create hooks for AskUserQuestion handling.
|
||||
*/
|
||||
static createAskUserQuestionHooks(
|
||||
askUserHandler: AskUserQuestionHandler
|
||||
): Partial<Record<string, HookCallbackMatcher[]>> {
|
||||
const preToolUseHook = async (
|
||||
input: HookInput,
|
||||
_toolUseID: string | undefined,
|
||||
_options: { signal: AbortSignal }
|
||||
): Promise<HookJSONOutput> => {
|
||||
const preToolInput = input as PreToolUseHookInput;
|
||||
if (preToolInput.tool_name === 'AskUserQuestion') {
|
||||
const toolInput = preToolInput.tool_input as AskUserQuestionInput;
|
||||
try {
|
||||
const answers = await askUserHandler(toolInput);
|
||||
return {
|
||||
continue: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: JSON.stringify(answers),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
log.error('AskUserQuestion handler failed', { error: err });
|
||||
return { continue: true };
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
return {
|
||||
PreToolUse: [{
|
||||
matcher: 'AskUserQuestion',
|
||||
hooks: [preToolUseHook],
|
||||
}],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create canUseTool callback from permission handler.
|
||||
*/
|
||||
// ---- Backward-compatible module-level functions ----
|
||||
|
||||
export function createCanUseToolCallback(
|
||||
handler: PermissionHandler
|
||||
): CanUseTool {
|
||||
return async (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
callbackOptions: {
|
||||
signal: AbortSignal;
|
||||
suggestions?: PermissionUpdate[];
|
||||
blockedPath?: string;
|
||||
decisionReason?: string;
|
||||
}
|
||||
): Promise<PermissionResult> => {
|
||||
return handler({
|
||||
toolName,
|
||||
input,
|
||||
suggestions: callbackOptions.suggestions,
|
||||
blockedPath: callbackOptions.blockedPath,
|
||||
decisionReason: callbackOptions.decisionReason,
|
||||
});
|
||||
};
|
||||
return SdkOptionsBuilder.createCanUseToolCallback(handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create hooks for AskUserQuestion handling.
|
||||
*/
|
||||
export function createAskUserQuestionHooks(
|
||||
askUserHandler: AskUserQuestionHandler
|
||||
): Partial<Record<string, HookCallbackMatcher[]>> {
|
||||
const preToolUseHook = async (
|
||||
input: HookInput,
|
||||
_toolUseID: string | undefined,
|
||||
_options: { signal: AbortSignal }
|
||||
): Promise<HookJSONOutput> => {
|
||||
const preToolInput = input as PreToolUseHookInput;
|
||||
if (preToolInput.tool_name === 'AskUserQuestion') {
|
||||
const toolInput = preToolInput.tool_input as AskUserQuestionInput;
|
||||
try {
|
||||
const answers = await askUserHandler(toolInput);
|
||||
return {
|
||||
continue: true,
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PreToolUse',
|
||||
additionalContext: JSON.stringify(answers),
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
log.error('AskUserQuestion handler failed', { error: err });
|
||||
return { continue: true };
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
return {
|
||||
PreToolUse: [{
|
||||
matcher: 'AskUserQuestion',
|
||||
hooks: [preToolUseHook],
|
||||
}],
|
||||
};
|
||||
return SdkOptionsBuilder.createAskUserQuestionHooks(askUserHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SDK options from ClaudeSpawnOptions.
|
||||
*/
|
||||
export function buildSdkOptions(options: ClaudeSpawnOptions): Options {
|
||||
// Create canUseTool callback if permission handler is provided
|
||||
const canUseTool = options.onPermissionRequest
|
||||
? createCanUseToolCallback(options.onPermissionRequest)
|
||||
: undefined;
|
||||
|
||||
// Create hooks for AskUserQuestion handling
|
||||
const hooks = options.onAskUserQuestion
|
||||
? createAskUserQuestionHooks(options.onAskUserQuestion)
|
||||
: undefined;
|
||||
|
||||
const sdkOptions: Options = {
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
allowedTools: options.allowedTools,
|
||||
agents: options.agents,
|
||||
permissionMode: options.permissionMode ?? (options.onPermissionRequest ? 'default' : 'acceptEdits'),
|
||||
includePartialMessages: options.hasStream,
|
||||
canUseTool,
|
||||
hooks,
|
||||
};
|
||||
|
||||
if (options.systemPrompt) {
|
||||
sdkOptions.systemPrompt = options.systemPrompt;
|
||||
}
|
||||
|
||||
// Session management
|
||||
if (options.sessionId) {
|
||||
sdkOptions.resume = options.sessionId;
|
||||
} else {
|
||||
sdkOptions.continue = false;
|
||||
}
|
||||
|
||||
return sdkOptions;
|
||||
return new SdkOptionsBuilder(options).build();
|
||||
}
|
||||
|
||||
@ -5,16 +5,13 @@
|
||||
* instead of spawning CLI processes.
|
||||
*/
|
||||
|
||||
import type { AgentDefinition, PermissionMode } from '@anthropic-ai/claude-agent-sdk';
|
||||
import {
|
||||
hasActiveProcess,
|
||||
interruptCurrentProcess,
|
||||
} from './query-manager.js';
|
||||
import { executeClaudeQuery } from './executor.js';
|
||||
import type {
|
||||
StreamCallback,
|
||||
PermissionHandler,
|
||||
AskUserQuestionHandler,
|
||||
ClaudeSpawnOptions,
|
||||
ClaudeResult,
|
||||
} from './types.js';
|
||||
|
||||
@ -28,6 +25,7 @@ export type {
|
||||
AskUserQuestionHandler,
|
||||
ClaudeResult,
|
||||
ClaudeResultWithQueryId,
|
||||
ClaudeSpawnOptions,
|
||||
InitEventData,
|
||||
ToolUseEventData,
|
||||
ToolResultEventData,
|
||||
@ -49,37 +47,13 @@ export {
|
||||
interruptCurrentProcess,
|
||||
} from './query-manager.js';
|
||||
|
||||
/** Options for calling Claude via SDK */
|
||||
export interface ClaudeSpawnOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
allowedTools?: string[];
|
||||
model?: string;
|
||||
maxTurns?: number;
|
||||
systemPrompt?: string;
|
||||
/** Enable streaming mode with callback */
|
||||
onStream?: StreamCallback;
|
||||
/** Custom agents to register */
|
||||
agents?: Record<string, AgentDefinition>;
|
||||
/** Permission mode for tool execution (default: 'default' for interactive) */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Custom permission handler for interactive permission prompts */
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
/** Custom handler for AskUserQuestion tool */
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
/** Bypass all permission checks (sacrifice-my-pc mode) */
|
||||
bypassPermissions?: boolean;
|
||||
/** Anthropic API key to inject via env (bypasses CLI auth) */
|
||||
anthropicApiKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a Claude query using the Agent SDK.
|
||||
* Supports concurrent execution with query ID tracking.
|
||||
*/
|
||||
export async function executeClaudeCli(
|
||||
prompt: string,
|
||||
options: ClaudeSpawnOptions
|
||||
options: ClaudeSpawnOptions,
|
||||
): Promise<ClaudeResult> {
|
||||
return executeClaudeQuery(prompt, options);
|
||||
}
|
||||
|
||||
@ -3,83 +3,134 @@
|
||||
*
|
||||
* Handles tracking and lifecycle management of active Claude queries.
|
||||
* Supports concurrent query execution with interrupt capabilities.
|
||||
*
|
||||
* QueryRegistry is a singleton that encapsulates the global activeQueries Map.
|
||||
*/
|
||||
|
||||
import type { Query } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
/**
|
||||
* Active query registry for interrupt support.
|
||||
* Uses a Map to support concurrent query execution.
|
||||
* Registry for tracking active Claude queries.
|
||||
* Singleton — use QueryRegistry.getInstance().
|
||||
*/
|
||||
const activeQueries = new Map<string, Query>();
|
||||
export class QueryRegistry {
|
||||
private static instance: QueryRegistry | null = null;
|
||||
private readonly activeQueries = new Map<string, Query>();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): QueryRegistry {
|
||||
if (!QueryRegistry.instance) {
|
||||
QueryRegistry.instance = new QueryRegistry();
|
||||
}
|
||||
return QueryRegistry.instance;
|
||||
}
|
||||
|
||||
/** Reset singleton for testing */
|
||||
static resetInstance(): void {
|
||||
QueryRegistry.instance = null;
|
||||
}
|
||||
|
||||
/** Check if there is an active Claude process */
|
||||
hasActiveProcess(): boolean {
|
||||
return this.activeQueries.size > 0;
|
||||
}
|
||||
|
||||
/** Check if a specific query is active */
|
||||
isQueryActive(queryId: string): boolean {
|
||||
return this.activeQueries.has(queryId);
|
||||
}
|
||||
|
||||
/** Get count of active queries */
|
||||
getActiveQueryCount(): number {
|
||||
return this.activeQueries.size;
|
||||
}
|
||||
|
||||
/** Register an active query */
|
||||
registerQuery(queryId: string, queryInstance: Query): void {
|
||||
this.activeQueries.set(queryId, queryInstance);
|
||||
}
|
||||
|
||||
/** Unregister an active query */
|
||||
unregisterQuery(queryId: string): void {
|
||||
this.activeQueries.delete(queryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt a specific Claude query by ID.
|
||||
* @returns true if the query was interrupted, false if not found
|
||||
*/
|
||||
interruptQuery(queryId: string): boolean {
|
||||
const queryInstance = this.activeQueries.get(queryId);
|
||||
if (queryInstance) {
|
||||
queryInstance.interrupt();
|
||||
this.activeQueries.delete(queryId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt all active Claude queries.
|
||||
* @returns number of queries that were interrupted
|
||||
*/
|
||||
interruptAllQueries(): number {
|
||||
const count = this.activeQueries.size;
|
||||
for (const [id, queryInstance] of this.activeQueries) {
|
||||
queryInstance.interrupt();
|
||||
this.activeQueries.delete(id);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt the most recently started Claude query (backward compatibility).
|
||||
* @returns true if a query was interrupted, false if no query was running
|
||||
*/
|
||||
interruptCurrentProcess(): boolean {
|
||||
if (this.activeQueries.size === 0) {
|
||||
return false;
|
||||
}
|
||||
this.interruptAllQueries();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Backward-compatible module-level functions ----
|
||||
|
||||
/** Generate a unique query ID */
|
||||
export function generateQueryId(): string {
|
||||
return `q-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
/** Check if there is an active Claude process */
|
||||
export function hasActiveProcess(): boolean {
|
||||
return activeQueries.size > 0;
|
||||
return QueryRegistry.getInstance().hasActiveProcess();
|
||||
}
|
||||
|
||||
/** Check if a specific query is active */
|
||||
export function isQueryActive(queryId: string): boolean {
|
||||
return activeQueries.has(queryId);
|
||||
return QueryRegistry.getInstance().isQueryActive(queryId);
|
||||
}
|
||||
|
||||
/** Get count of active queries */
|
||||
export function getActiveQueryCount(): number {
|
||||
return activeQueries.size;
|
||||
return QueryRegistry.getInstance().getActiveQueryCount();
|
||||
}
|
||||
|
||||
/** Register an active query */
|
||||
export function registerQuery(queryId: string, queryInstance: Query): void {
|
||||
activeQueries.set(queryId, queryInstance);
|
||||
QueryRegistry.getInstance().registerQuery(queryId, queryInstance);
|
||||
}
|
||||
|
||||
/** Unregister an active query */
|
||||
export function unregisterQuery(queryId: string): void {
|
||||
activeQueries.delete(queryId);
|
||||
QueryRegistry.getInstance().unregisterQuery(queryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt a specific Claude query by ID.
|
||||
* @returns true if the query was interrupted, false if not found
|
||||
*/
|
||||
export function interruptQuery(queryId: string): boolean {
|
||||
const queryInstance = activeQueries.get(queryId);
|
||||
if (queryInstance) {
|
||||
queryInstance.interrupt();
|
||||
activeQueries.delete(queryId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return QueryRegistry.getInstance().interruptQuery(queryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt all active Claude queries.
|
||||
* @returns number of queries that were interrupted
|
||||
*/
|
||||
export function interruptAllQueries(): number {
|
||||
const count = activeQueries.size;
|
||||
for (const [id, queryInstance] of activeQueries) {
|
||||
queryInstance.interrupt();
|
||||
activeQueries.delete(id);
|
||||
}
|
||||
return count;
|
||||
return QueryRegistry.getInstance().interruptAllQueries();
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt the most recently started Claude query (backward compatibility).
|
||||
* @returns true if a query was interrupted, false if no query was running
|
||||
*/
|
||||
export function interruptCurrentProcess(): boolean {
|
||||
if (activeQueries.size === 0) {
|
||||
return false;
|
||||
}
|
||||
// Interrupt all queries for backward compatibility
|
||||
// In the old design, there was only one query
|
||||
interruptAllQueries();
|
||||
return true;
|
||||
return QueryRegistry.getInstance().interruptCurrentProcess();
|
||||
}
|
||||
|
||||
@ -5,7 +5,8 @@
|
||||
* used throughout the Claude integration layer.
|
||||
*/
|
||||
|
||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { PermissionResult, PermissionUpdate, AgentDefinition, PermissionMode as SdkPermissionMode } from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { PermissionMode } from '../models/types.js';
|
||||
|
||||
// Re-export PermissionResult for convenience
|
||||
export type { PermissionResult, PermissionUpdate };
|
||||
@ -113,3 +114,51 @@ export interface ClaudeResult {
|
||||
export interface ClaudeResultWithQueryId extends ClaudeResult {
|
||||
queryId: string;
|
||||
}
|
||||
|
||||
/** Options for calling Claude (high-level, used by client/providers/agents) */
|
||||
export interface ClaudeCallOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
allowedTools?: string[];
|
||||
model?: string;
|
||||
maxTurns?: number;
|
||||
systemPrompt?: string;
|
||||
/** SDK agents to register for sub-agent execution */
|
||||
agents?: Record<string, AgentDefinition>;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Enable streaming mode with callback for real-time output */
|
||||
onStream?: StreamCallback;
|
||||
/** Custom permission handler for interactive permission prompts */
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
/** Custom handler for AskUserQuestion tool */
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
/** Bypass all permission checks (sacrifice-my-pc mode) */
|
||||
bypassPermissions?: boolean;
|
||||
/** Anthropic API key to inject via env (bypasses CLI auth) */
|
||||
anthropicApiKey?: string;
|
||||
}
|
||||
|
||||
/** Options for spawning a Claude SDK query (low-level, used by executor/process) */
|
||||
export interface ClaudeSpawnOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
allowedTools?: string[];
|
||||
model?: string;
|
||||
maxTurns?: number;
|
||||
systemPrompt?: string;
|
||||
/** Enable streaming mode with callback */
|
||||
onStream?: StreamCallback;
|
||||
/** Custom agents to register */
|
||||
agents?: Record<string, AgentDefinition>;
|
||||
/** Permission mode for tool execution (default: 'default' for interactive) */
|
||||
permissionMode?: SdkPermissionMode;
|
||||
/** Custom permission handler for interactive permission prompts */
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
/** Custom handler for AskUserQuestion tool */
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
/** Bypass all permission checks (sacrifice-my-pc mode) */
|
||||
bypassPermissions?: boolean;
|
||||
/** Anthropic API key to inject via env (bypasses CLI auth) */
|
||||
anthropicApiKey?: string;
|
||||
}
|
||||
|
||||
@ -48,8 +48,8 @@ import { resolveIssueTask, isIssueReference } from './github/issue.js';
|
||||
import {
|
||||
selectAndExecuteTask,
|
||||
type SelectAndExecuteOptions,
|
||||
} from './commands/selectAndExecute.js';
|
||||
import type { TaskExecutionOptions } from './commands/taskExecution.js';
|
||||
} from './commands/execution/selectAndExecute.js';
|
||||
import type { TaskExecutionOptions } from './commands/execution/taskExecution.js';
|
||||
import type { ProviderType } from './providers/index.js';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
@ -6,106 +6,16 @@
|
||||
|
||||
import { Codex } from '@openai/codex-sdk';
|
||||
import type { AgentResponse, Status } from '../models/types.js';
|
||||
import type { StreamCallback } from '../claude/process.js';
|
||||
import type { StreamCallback } from '../claude/types.js';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
import { getErrorMessage } from '../utils/error.js';
|
||||
import type { CodexCallOptions } from './types.js';
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export type { CodexCallOptions } from './types.js';
|
||||
|
||||
const log = createLogger('codex-sdk');
|
||||
|
||||
/** Options for calling Codex */
|
||||
export interface CodexCallOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
/** Enable streaming mode with callback (best-effort) */
|
||||
onStream?: StreamCallback;
|
||||
/** OpenAI API key (bypasses CLI auth) */
|
||||
openaiApiKey?: string;
|
||||
}
|
||||
|
||||
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 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 emitThinking(onStream: StreamCallback | undefined, thinking: string): void {
|
||||
if (!onStream || !thinking) return;
|
||||
onStream({ type: 'thinking', data: { thinking } });
|
||||
}
|
||||
|
||||
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 } });
|
||||
}
|
||||
|
||||
function emitToolResult(
|
||||
onStream: StreamCallback | undefined,
|
||||
content: string,
|
||||
isError: boolean
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({ type: 'tool_result', data: { content, isError } });
|
||||
}
|
||||
|
||||
function emitToolOutput(
|
||||
onStream: StreamCallback | undefined,
|
||||
tool: string,
|
||||
output: string
|
||||
): void {
|
||||
if (!onStream || !output) return;
|
||||
onStream({ type: 'tool_output', data: { tool, output } });
|
||||
}
|
||||
|
||||
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,
|
||||
error: success ? undefined : result || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function determineStatus(success: boolean): Status {
|
||||
return success ? 'done' : 'blocked';
|
||||
}
|
||||
|
||||
type CodexEvent = {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
@ -117,354 +27,454 @@ type CodexItem = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string {
|
||||
if (!changes.length) return '';
|
||||
return changes
|
||||
.map((change) => {
|
||||
const kind = change.kind ? `${change.kind}: ` : '';
|
||||
return `${kind}${change.path ?? ''}`.trim();
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function emitCodexItemStart(
|
||||
item: CodexItem,
|
||||
onStream: StreamCallback | undefined,
|
||||
startedItems: Set<string>
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
|
||||
if (startedItems.has(id)) return;
|
||||
|
||||
switch (item.type) {
|
||||
case 'command_execution': {
|
||||
const command = typeof item.command === 'string' ? item.command : '';
|
||||
emitToolUse(onStream, 'Bash', { command }, id);
|
||||
startedItems.add(id);
|
||||
break;
|
||||
}
|
||||
case 'mcp_tool_call': {
|
||||
const tool = typeof item.tool === 'string' ? item.tool : 'Tool';
|
||||
const args = (item.arguments ?? {}) as Record<string, unknown>;
|
||||
emitToolUse(onStream, tool, args, id);
|
||||
startedItems.add(id);
|
||||
break;
|
||||
}
|
||||
case 'web_search': {
|
||||
const query = typeof item.query === 'string' ? item.query : '';
|
||||
emitToolUse(onStream, 'WebSearch', { query }, id);
|
||||
startedItems.add(id);
|
||||
break;
|
||||
}
|
||||
case 'file_change': {
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
|
||||
emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id);
|
||||
startedItems.add(id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function emitCodexItemCompleted(
|
||||
item: CodexItem,
|
||||
onStream: StreamCallback | undefined,
|
||||
startedItems: Set<string>,
|
||||
outputOffsets: Map<string, number>,
|
||||
textOffsets: Map<string, number>,
|
||||
thinkingOffsets: Map<string, number>
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
switch (item.type) {
|
||||
case 'reasoning': {
|
||||
const text = typeof item.text === 'string' ? item.text : '';
|
||||
if (text) {
|
||||
const prev = thinkingOffsets.get(id) ?? 0;
|
||||
if (text.length > prev) {
|
||||
emitThinking(onStream, text.slice(prev) + '\n');
|
||||
thinkingOffsets.set(id, text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_message': {
|
||||
const text = typeof item.text === 'string' ? item.text : '';
|
||||
if (text) {
|
||||
const prev = textOffsets.get(id) ?? 0;
|
||||
if (text.length > prev) {
|
||||
emitText(onStream, text.slice(prev));
|
||||
textOffsets.set(id, text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'command_execution': {
|
||||
if (!startedItems.has(id)) {
|
||||
emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
|
||||
if (output) {
|
||||
const prev = outputOffsets.get(id) ?? 0;
|
||||
if (output.length > prev) {
|
||||
emitToolOutput(onStream, 'Bash', output.slice(prev));
|
||||
outputOffsets.set(id, output.length);
|
||||
}
|
||||
}
|
||||
const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;
|
||||
const status = typeof item.status === 'string' ? item.status : '';
|
||||
const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0);
|
||||
const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : '');
|
||||
emitToolResult(onStream, content, isError);
|
||||
break;
|
||||
}
|
||||
case 'mcp_tool_call': {
|
||||
if (!startedItems.has(id)) {
|
||||
emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
const status = typeof item.status === 'string' ? item.status : '';
|
||||
const isError = status === 'failed' || !!item.error;
|
||||
const errorMessage =
|
||||
item.error && typeof item.error === 'object' && 'message' in item.error
|
||||
? String((item.error as { message?: unknown }).message ?? '')
|
||||
: '';
|
||||
let content = errorMessage;
|
||||
if (!content && item.result && typeof item.result === 'object') {
|
||||
try {
|
||||
content = JSON.stringify(item.result);
|
||||
} catch {
|
||||
content = '';
|
||||
}
|
||||
}
|
||||
emitToolResult(onStream, content, isError);
|
||||
break;
|
||||
}
|
||||
case 'web_search': {
|
||||
if (!startedItems.has(id)) {
|
||||
emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
emitToolResult(onStream, 'Search completed', false);
|
||||
break;
|
||||
}
|
||||
case 'file_change': {
|
||||
if (!startedItems.has(id)) {
|
||||
emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
const status = typeof item.status === 'string' ? item.status : '';
|
||||
const isError = status === 'failed';
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const summary = formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
|
||||
emitToolResult(onStream, summary || 'Applied patch', isError);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function emitCodexItemUpdate(
|
||||
item: CodexItem,
|
||||
onStream: StreamCallback | undefined,
|
||||
startedItems: Set<string>,
|
||||
outputOffsets: Map<string, number>,
|
||||
textOffsets: Map<string, number>,
|
||||
thinkingOffsets: Map<string, number>
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
switch (item.type) {
|
||||
case 'command_execution': {
|
||||
if (!startedItems.has(id)) {
|
||||
emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
|
||||
if (output) {
|
||||
const prev = outputOffsets.get(id) ?? 0;
|
||||
if (output.length > prev) {
|
||||
emitToolOutput(onStream, 'Bash', output.slice(prev));
|
||||
outputOffsets.set(id, output.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_message': {
|
||||
const text = typeof item.text === 'string' ? item.text : '';
|
||||
if (text) {
|
||||
const prev = textOffsets.get(id) ?? 0;
|
||||
if (text.length > prev) {
|
||||
emitText(onStream, text.slice(prev));
|
||||
textOffsets.set(id, text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning': {
|
||||
const text = typeof item.text === 'string' ? item.text : '';
|
||||
if (text) {
|
||||
const prev = thinkingOffsets.get(id) ?? 0;
|
||||
if (text.length > prev) {
|
||||
emitThinking(onStream, text.slice(prev));
|
||||
thinkingOffsets.set(id, text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'file_change': {
|
||||
if (!startedItems.has(id)) {
|
||||
emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'mcp_tool_call': {
|
||||
if (!startedItems.has(id)) {
|
||||
emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'web_search': {
|
||||
if (!startedItems.has(id)) {
|
||||
emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Codex with an agent prompt.
|
||||
* Client for Codex SDK agent interactions.
|
||||
*
|
||||
* Handles thread management, streaming event conversion,
|
||||
* and response processing.
|
||||
*/
|
||||
export async function callCodex(
|
||||
agentType: string,
|
||||
prompt: string,
|
||||
options: CodexCallOptions
|
||||
): Promise<AgentResponse> {
|
||||
const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
|
||||
const threadOptions = {
|
||||
model: options.model,
|
||||
workingDirectory: options.cwd,
|
||||
};
|
||||
const thread = options.sessionId
|
||||
? await codex.resumeThread(options.sessionId, threadOptions)
|
||||
: await codex.startThread(threadOptions);
|
||||
let threadId = extractThreadId(thread) || options.sessionId;
|
||||
export class CodexClient {
|
||||
// ---- Stream emission helpers (private) ----
|
||||
|
||||
const fullPrompt = options.systemPrompt
|
||||
? `${options.systemPrompt}\n\n${prompt}`
|
||||
: prompt;
|
||||
private static 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;
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('Executing Codex thread', {
|
||||
agentType,
|
||||
model: options.model,
|
||||
hasSystemPrompt: !!options.systemPrompt,
|
||||
private static emitInit(
|
||||
onStream: StreamCallback | undefined,
|
||||
model: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({
|
||||
type: 'init',
|
||||
data: {
|
||||
model: model || 'codex',
|
||||
sessionId: sessionId || 'unknown',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { events } = await thread.runStreamed(fullPrompt);
|
||||
let content = '';
|
||||
const contentOffsets = new Map<string, number>();
|
||||
let success = true;
|
||||
let failureMessage = '';
|
||||
const startedItems = new Set<string>();
|
||||
const outputOffsets = new Map<string, number>();
|
||||
const textOffsets = new Map<string, number>();
|
||||
const thinkingOffsets = new Map<string, number>();
|
||||
private static emitText(onStream: StreamCallback | undefined, text: string): void {
|
||||
if (!onStream || !text) return;
|
||||
onStream({ type: 'text', data: { text } });
|
||||
}
|
||||
|
||||
for await (const event of events as AsyncGenerator<CodexEvent>) {
|
||||
if (event.type === 'thread.started') {
|
||||
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
|
||||
emitInit(options.onStream, options.model, threadId);
|
||||
continue;
|
||||
}
|
||||
private static emitThinking(onStream: StreamCallback | undefined, thinking: string): void {
|
||||
if (!onStream || !thinking) return;
|
||||
onStream({ type: 'thinking', data: { thinking } });
|
||||
}
|
||||
|
||||
if (event.type === 'turn.failed') {
|
||||
success = false;
|
||||
if (event.error && typeof event.error === 'object' && 'message' in event.error) {
|
||||
failureMessage = String((event.error as { message?: unknown }).message ?? '');
|
||||
}
|
||||
private static emitToolUse(
|
||||
onStream: StreamCallback | undefined,
|
||||
tool: string,
|
||||
input: Record<string, unknown>,
|
||||
id: string,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({ type: 'tool_use', data: { tool, input, id } });
|
||||
}
|
||||
|
||||
private static emitToolResult(
|
||||
onStream: StreamCallback | undefined,
|
||||
content: string,
|
||||
isError: boolean,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({ type: 'tool_result', data: { content, isError } });
|
||||
}
|
||||
|
||||
private static emitToolOutput(
|
||||
onStream: StreamCallback | undefined,
|
||||
tool: string,
|
||||
output: string,
|
||||
): void {
|
||||
if (!onStream || !output) return;
|
||||
onStream({ type: 'tool_output', data: { tool, output } });
|
||||
}
|
||||
|
||||
private static emitResult(
|
||||
onStream: StreamCallback | undefined,
|
||||
success: boolean,
|
||||
result: string,
|
||||
sessionId: string | undefined,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
onStream({
|
||||
type: 'result',
|
||||
data: {
|
||||
result,
|
||||
sessionId: sessionId || 'unknown',
|
||||
success,
|
||||
error: success ? undefined : result || undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static formatFileChangeSummary(changes: Array<{ path?: string; kind?: string }>): string {
|
||||
if (!changes.length) return '';
|
||||
return changes
|
||||
.map((change) => {
|
||||
const kind = change.kind ? `${change.kind}: ` : '';
|
||||
return `${kind}${change.path ?? ''}`.trim();
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private static emitCodexItemStart(
|
||||
item: CodexItem,
|
||||
onStream: StreamCallback | undefined,
|
||||
startedItems: Set<string>,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
|
||||
if (startedItems.has(id)) return;
|
||||
|
||||
switch (item.type) {
|
||||
case 'command_execution': {
|
||||
const command = typeof item.command === 'string' ? item.command : '';
|
||||
CodexClient.emitToolUse(onStream, 'Bash', { command }, id);
|
||||
startedItems.add(id);
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
success = false;
|
||||
failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error';
|
||||
case 'mcp_tool_call': {
|
||||
const tool = typeof item.tool === 'string' ? item.tool : 'Tool';
|
||||
const args = (item.arguments ?? {}) as Record<string, unknown>;
|
||||
CodexClient.emitToolUse(onStream, tool, args, id);
|
||||
startedItems.add(id);
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.type === 'item.started') {
|
||||
const item = event.item as CodexItem | undefined;
|
||||
if (item) {
|
||||
emitCodexItemStart(item, options.onStream, startedItems);
|
||||
}
|
||||
continue;
|
||||
case 'web_search': {
|
||||
const query = typeof item.query === 'string' ? item.query : '';
|
||||
CodexClient.emitToolUse(onStream, 'WebSearch', { query }, id);
|
||||
startedItems.add(id);
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.type === 'item.updated') {
|
||||
const item = event.item as CodexItem | undefined;
|
||||
if (item) {
|
||||
if (item.type === 'agent_message' && typeof item.text === 'string') {
|
||||
const itemId = item.id;
|
||||
const text = item.text;
|
||||
if (itemId) {
|
||||
const prev = contentOffsets.get(itemId) ?? 0;
|
||||
if (text.length > prev) {
|
||||
if (prev === 0 && content.length > 0) {
|
||||
content += '\n';
|
||||
}
|
||||
content += text.slice(prev);
|
||||
contentOffsets.set(itemId, text.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'item.completed') {
|
||||
const item = event.item as CodexItem | undefined;
|
||||
if (item) {
|
||||
if (item.type === 'agent_message' && typeof item.text === 'string') {
|
||||
const itemId = item.id;
|
||||
const text = item.text;
|
||||
if (itemId) {
|
||||
const prev = contentOffsets.get(itemId) ?? 0;
|
||||
if (text.length > prev) {
|
||||
if (prev === 0 && content.length > 0) {
|
||||
content += '\n';
|
||||
}
|
||||
content += text.slice(prev);
|
||||
contentOffsets.set(itemId, text.length);
|
||||
}
|
||||
} else if (text) {
|
||||
if (content.length > 0) {
|
||||
content += '\n';
|
||||
}
|
||||
content += text;
|
||||
}
|
||||
}
|
||||
emitCodexItemCompleted(
|
||||
item,
|
||||
options.onStream,
|
||||
startedItems,
|
||||
outputOffsets,
|
||||
textOffsets,
|
||||
thinkingOffsets
|
||||
);
|
||||
}
|
||||
continue;
|
||||
case 'file_change': {
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
|
||||
CodexClient.emitToolUse(onStream, 'Edit', { file_path: summary || 'patch' }, id);
|
||||
startedItems.add(id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static emitCodexItemCompleted(
|
||||
item: CodexItem,
|
||||
onStream: StreamCallback | undefined,
|
||||
startedItems: Set<string>,
|
||||
outputOffsets: Map<string, number>,
|
||||
textOffsets: Map<string, number>,
|
||||
thinkingOffsets: Map<string, number>,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
switch (item.type) {
|
||||
case 'reasoning': {
|
||||
const text = typeof item.text === 'string' ? item.text : '';
|
||||
if (text) {
|
||||
const prev = thinkingOffsets.get(id) ?? 0;
|
||||
if (text.length > prev) {
|
||||
CodexClient.emitThinking(onStream, text.slice(prev) + '\n');
|
||||
thinkingOffsets.set(id, text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_message': {
|
||||
const text = typeof item.text === 'string' ? item.text : '';
|
||||
if (text) {
|
||||
const prev = textOffsets.get(id) ?? 0;
|
||||
if (text.length > prev) {
|
||||
CodexClient.emitText(onStream, text.slice(prev));
|
||||
textOffsets.set(id, text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'command_execution': {
|
||||
if (!startedItems.has(id)) {
|
||||
CodexClient.emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
|
||||
if (output) {
|
||||
const prev = outputOffsets.get(id) ?? 0;
|
||||
if (output.length > prev) {
|
||||
CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev));
|
||||
outputOffsets.set(id, output.length);
|
||||
}
|
||||
}
|
||||
const exitCode = typeof item.exit_code === 'number' ? item.exit_code : undefined;
|
||||
const status = typeof item.status === 'string' ? item.status : '';
|
||||
const isError = status === 'failed' || (exitCode !== undefined && exitCode !== 0);
|
||||
const content = output || (exitCode !== undefined ? `Exit code: ${exitCode}` : '');
|
||||
CodexClient.emitToolResult(onStream, content, isError);
|
||||
break;
|
||||
}
|
||||
case 'mcp_tool_call': {
|
||||
if (!startedItems.has(id)) {
|
||||
CodexClient.emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
const status = typeof item.status === 'string' ? item.status : '';
|
||||
const isError = status === 'failed' || !!item.error;
|
||||
const errorMessage =
|
||||
item.error && typeof item.error === 'object' && 'message' in item.error
|
||||
? String((item.error as { message?: unknown }).message ?? '')
|
||||
: '';
|
||||
let content = errorMessage;
|
||||
if (!content && item.result && typeof item.result === 'object') {
|
||||
try {
|
||||
content = JSON.stringify(item.result);
|
||||
} catch {
|
||||
content = '';
|
||||
}
|
||||
}
|
||||
CodexClient.emitToolResult(onStream, content, isError);
|
||||
break;
|
||||
}
|
||||
case 'web_search': {
|
||||
if (!startedItems.has(id)) {
|
||||
CodexClient.emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
CodexClient.emitToolResult(onStream, 'Search completed', false);
|
||||
break;
|
||||
}
|
||||
case 'file_change': {
|
||||
if (!startedItems.has(id)) {
|
||||
CodexClient.emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
const status = typeof item.status === 'string' ? item.status : '';
|
||||
const isError = status === 'failed';
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const summary = CodexClient.formatFileChangeSummary(changes as Array<{ path?: string; kind?: string }>);
|
||||
CodexClient.emitToolResult(onStream, summary || 'Applied patch', isError);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static emitCodexItemUpdate(
|
||||
item: CodexItem,
|
||||
onStream: StreamCallback | undefined,
|
||||
startedItems: Set<string>,
|
||||
outputOffsets: Map<string, number>,
|
||||
textOffsets: Map<string, number>,
|
||||
thinkingOffsets: Map<string, number>,
|
||||
): void {
|
||||
if (!onStream) return;
|
||||
const id = item.id || `item_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
switch (item.type) {
|
||||
case 'command_execution': {
|
||||
if (!startedItems.has(id)) {
|
||||
CodexClient.emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
const output = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
|
||||
if (output) {
|
||||
const prev = outputOffsets.get(id) ?? 0;
|
||||
if (output.length > prev) {
|
||||
CodexClient.emitToolOutput(onStream, 'Bash', output.slice(prev));
|
||||
outputOffsets.set(id, output.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'agent_message': {
|
||||
const text = typeof item.text === 'string' ? item.text : '';
|
||||
if (text) {
|
||||
const prev = textOffsets.get(id) ?? 0;
|
||||
if (text.length > prev) {
|
||||
CodexClient.emitText(onStream, text.slice(prev));
|
||||
textOffsets.set(id, text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'reasoning': {
|
||||
const text = typeof item.text === 'string' ? item.text : '';
|
||||
if (text) {
|
||||
const prev = thinkingOffsets.get(id) ?? 0;
|
||||
if (text.length > prev) {
|
||||
CodexClient.emitThinking(onStream, text.slice(prev));
|
||||
thinkingOffsets.set(id, text.length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'file_change':
|
||||
case 'mcp_tool_call':
|
||||
case 'web_search': {
|
||||
if (!startedItems.has(id)) {
|
||||
CodexClient.emitCodexItemStart(item, onStream, startedItems);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Public API ----
|
||||
|
||||
/** Call Codex with an agent prompt */
|
||||
async call(
|
||||
agentType: string,
|
||||
prompt: string,
|
||||
options: CodexCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
|
||||
const threadOptions = {
|
||||
model: options.model,
|
||||
workingDirectory: options.cwd,
|
||||
};
|
||||
const thread = options.sessionId
|
||||
? await codex.resumeThread(options.sessionId, threadOptions)
|
||||
: await codex.startThread(threadOptions);
|
||||
let threadId = CodexClient.extractThreadId(thread) || options.sessionId;
|
||||
|
||||
const fullPrompt = options.systemPrompt
|
||||
? `${options.systemPrompt}\n\n${prompt}`
|
||||
: prompt;
|
||||
|
||||
try {
|
||||
log.debug('Executing Codex thread', {
|
||||
agentType,
|
||||
model: options.model,
|
||||
hasSystemPrompt: !!options.systemPrompt,
|
||||
});
|
||||
|
||||
const { events } = await thread.runStreamed(fullPrompt);
|
||||
let content = '';
|
||||
const contentOffsets = new Map<string, number>();
|
||||
let success = true;
|
||||
let failureMessage = '';
|
||||
const startedItems = new Set<string>();
|
||||
const outputOffsets = new Map<string, number>();
|
||||
const textOffsets = new Map<string, number>();
|
||||
const thinkingOffsets = new Map<string, number>();
|
||||
|
||||
for await (const event of events as AsyncGenerator<CodexEvent>) {
|
||||
if (event.type === 'thread.started') {
|
||||
threadId = typeof event.thread_id === 'string' ? event.thread_id : threadId;
|
||||
CodexClient.emitInit(options.onStream, options.model, threadId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'turn.failed') {
|
||||
success = false;
|
||||
if (event.error && typeof event.error === 'object' && 'message' in event.error) {
|
||||
failureMessage = String((event.error as { message?: unknown }).message ?? '');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
success = false;
|
||||
failureMessage = typeof event.message === 'string' ? event.message : 'Unknown error';
|
||||
break;
|
||||
}
|
||||
|
||||
if (event.type === 'item.started') {
|
||||
const item = event.item as CodexItem | undefined;
|
||||
if (item) {
|
||||
CodexClient.emitCodexItemStart(item, options.onStream, startedItems);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'item.updated') {
|
||||
const item = event.item as CodexItem | undefined;
|
||||
if (item) {
|
||||
if (item.type === 'agent_message' && typeof item.text === 'string') {
|
||||
const itemId = item.id;
|
||||
const text = item.text;
|
||||
if (itemId) {
|
||||
const prev = contentOffsets.get(itemId) ?? 0;
|
||||
if (text.length > prev) {
|
||||
if (prev === 0 && content.length > 0) {
|
||||
content += '\n';
|
||||
}
|
||||
content += text.slice(prev);
|
||||
contentOffsets.set(itemId, text.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
CodexClient.emitCodexItemUpdate(item, options.onStream, startedItems, outputOffsets, textOffsets, thinkingOffsets);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'item.completed') {
|
||||
const item = event.item as CodexItem | undefined;
|
||||
if (item) {
|
||||
if (item.type === 'agent_message' && typeof item.text === 'string') {
|
||||
const itemId = item.id;
|
||||
const text = item.text;
|
||||
if (itemId) {
|
||||
const prev = contentOffsets.get(itemId) ?? 0;
|
||||
if (text.length > prev) {
|
||||
if (prev === 0 && content.length > 0) {
|
||||
content += '\n';
|
||||
}
|
||||
content += text.slice(prev);
|
||||
contentOffsets.set(itemId, text.length);
|
||||
}
|
||||
} else if (text) {
|
||||
if (content.length > 0) {
|
||||
content += '\n';
|
||||
}
|
||||
content += text;
|
||||
}
|
||||
}
|
||||
CodexClient.emitCodexItemCompleted(
|
||||
item,
|
||||
options.onStream,
|
||||
startedItems,
|
||||
outputOffsets,
|
||||
textOffsets,
|
||||
thinkingOffsets,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
const message = failureMessage || 'Codex execution failed';
|
||||
CodexClient.emitResult(options.onStream, false, message, threadId);
|
||||
return {
|
||||
agent: agentType,
|
||||
status: 'blocked',
|
||||
content: message,
|
||||
timestamp: new Date(),
|
||||
sessionId: threadId,
|
||||
};
|
||||
}
|
||||
|
||||
const trimmed = content.trim();
|
||||
CodexClient.emitResult(options.onStream, true, trimmed, threadId);
|
||||
|
||||
return {
|
||||
agent: agentType,
|
||||
status: 'done',
|
||||
content: trimmed,
|
||||
timestamp: new Date(),
|
||||
sessionId: threadId,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
CodexClient.emitResult(options.onStream, false, message, threadId);
|
||||
|
||||
if (!success) {
|
||||
const message = failureMessage || 'Codex execution failed';
|
||||
emitResult(options.onStream, false, message, threadId);
|
||||
return {
|
||||
agent: agentType,
|
||||
status: 'blocked',
|
||||
@ -473,44 +483,39 @@ export async function callCodex(
|
||||
sessionId: threadId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = content.trim();
|
||||
emitResult(options.onStream, true, trimmed, threadId);
|
||||
|
||||
const status = determineStatus(true);
|
||||
|
||||
return {
|
||||
agent: agentType,
|
||||
status,
|
||||
content: trimmed,
|
||||
timestamp: new Date(),
|
||||
sessionId: threadId,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(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) */
|
||||
async callCustom(
|
||||
agentName: string,
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
options: CodexCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return this.call(agentName, prompt, {
|
||||
...options,
|
||||
systemPrompt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call Codex with a custom agent configuration (system prompt + prompt).
|
||||
*/
|
||||
// ---- Backward-compatible module-level functions ----
|
||||
|
||||
const defaultClient = new CodexClient();
|
||||
|
||||
export async function callCodex(
|
||||
agentType: string,
|
||||
prompt: string,
|
||||
options: CodexCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return defaultClient.call(agentType, prompt, options);
|
||||
}
|
||||
|
||||
export async function callCodexCustom(
|
||||
agentName: string,
|
||||
prompt: string,
|
||||
systemPrompt: string,
|
||||
options: CodexCallOptions
|
||||
options: CodexCallOptions,
|
||||
): Promise<AgentResponse> {
|
||||
return callCodex(agentName, prompt, {
|
||||
...options,
|
||||
systemPrompt,
|
||||
});
|
||||
return defaultClient.callCustom(agentName, prompt, systemPrompt, options);
|
||||
}
|
||||
|
||||
@ -2,4 +2,5 @@
|
||||
* Codex integration exports
|
||||
*/
|
||||
|
||||
export * from './client.js';
|
||||
export { CodexClient, callCodex, callCodexCustom } from './client.js';
|
||||
export type { CodexCallOptions } from './types.js';
|
||||
|
||||
17
src/codex/types.ts
Normal file
17
src/codex/types.ts
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Type definitions for Codex SDK integration
|
||||
*/
|
||||
|
||||
import type { StreamCallback } from '../claude/types.js';
|
||||
|
||||
/** Options for calling Codex */
|
||||
export interface CodexCallOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
/** Enable streaming mode with callback (best-effort) */
|
||||
onStream?: StreamCallback;
|
||||
/** OpenAI API key (bypasses CLI auth) */
|
||||
openaiApiKey?: string;
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in management/addTask.ts */
|
||||
export { addTask, summarizeConversation } from './management/addTask.js';
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in management/config.ts */
|
||||
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './management/config.js';
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in management/eject.ts */
|
||||
export { ejectBuiltin } from './management/eject.js';
|
||||
@ -13,8 +13,8 @@ import { execFileSync } from 'node:child_process';
|
||||
import { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../../github/issue.js';
|
||||
import { createPullRequest, pushBranch, buildPrBody } from '../../github/pr.js';
|
||||
import { stageAndCommit } from '../../task/git.js';
|
||||
import { executeTask, type TaskExecutionOptions } from '../taskExecution.js';
|
||||
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
||||
import { executeTask, type TaskExecutionOptions } from './taskExecution.js';
|
||||
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
|
||||
import { info, error, success, status, blankLine } from '../../utils/ui.js';
|
||||
import { createLogger } from '../../utils/debug.js';
|
||||
import { getErrorMessage } from '../../utils/error.js';
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { getCurrentWorkflow } from '../../config/paths.js';
|
||||
import { listWorkflows, isWorkflowPath } from '../../config/workflowLoader.js';
|
||||
import { listWorkflows, isWorkflowPath } from '../../config/loaders/workflowLoader.js';
|
||||
import { selectOptionWithDefault, confirm } from '../../prompt/index.js';
|
||||
import { createSharedClone } from '../../task/clone.js';
|
||||
import { autoCommitAndPush } from '../../task/autoCommit.js';
|
||||
@ -16,8 +16,8 @@ import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
|
||||
import { info, error, success } from '../../utils/ui.js';
|
||||
import { createLogger } from '../../utils/debug.js';
|
||||
import { createPullRequest, buildPrBody } from '../../github/pr.js';
|
||||
import { executeTask } from '../taskExecution.js';
|
||||
import type { TaskExecutionOptions } from '../taskExecution.js';
|
||||
import { executeTask } from './taskExecution.js';
|
||||
import type { TaskExecutionOptions } from './taskExecution.js';
|
||||
|
||||
const log = createLogger('selectAndExecute');
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { loadAgentSessions, updateAgentSession } from '../../config/paths.js';
|
||||
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
||||
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
|
||||
import type { AgentResponse } from '../../models/types.js';
|
||||
|
||||
/**
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
} from '../../utils/ui.js';
|
||||
import { createLogger } from '../../utils/debug.js';
|
||||
import { getErrorMessage } from '../../utils/error.js';
|
||||
import { executeWorkflow } from '../workflowExecution.js';
|
||||
import { executeWorkflow } from './workflowExecution.js';
|
||||
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
|
||||
import type { ProviderType } from '../../providers/index.js';
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { WorkflowEngine } from '../../workflow/engine.js';
|
||||
import { WorkflowEngine } from '../../workflow/engine/WorkflowEngine.js';
|
||||
import type { WorkflowConfig, Language } from '../../models/types.js';
|
||||
import type { IterationLimitRequest } from '../../workflow/types.js';
|
||||
import type { ProviderType } from '../../providers/index.js';
|
||||
@ -13,7 +13,7 @@ import {
|
||||
loadWorktreeSessions,
|
||||
updateWorktreeSession,
|
||||
} from '../../config/paths.js';
|
||||
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
||||
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
|
||||
import { isQuietMode } from '../../context.js';
|
||||
import {
|
||||
header,
|
||||
|
||||
@ -2,20 +2,20 @@
|
||||
* Command exports
|
||||
*/
|
||||
|
||||
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js';
|
||||
export { executeTask, runAllTasks } from './taskExecution.js';
|
||||
export { addTask } from './addTask.js';
|
||||
export { ejectBuiltin } from './eject.js';
|
||||
export { watchTasks } from './watchTasks.js';
|
||||
export { withAgentSession } from './session.js';
|
||||
export { switchWorkflow } from './workflow.js';
|
||||
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js';
|
||||
export { listTasks } from './listTasks.js';
|
||||
export { interactiveMode } from './interactive.js';
|
||||
export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js';
|
||||
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './execution/workflowExecution.js';
|
||||
export { executeTask, runAllTasks, type TaskExecutionOptions } from './execution/taskExecution.js';
|
||||
export { addTask } from './management/addTask.js';
|
||||
export { ejectBuiltin } from './management/eject.js';
|
||||
export { watchTasks } from './management/watchTasks.js';
|
||||
export { withAgentSession } from './execution/session.js';
|
||||
export { switchWorkflow } from './management/workflow.js';
|
||||
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './management/config.js';
|
||||
export { listTasks } from './management/listTasks.js';
|
||||
export { interactiveMode } from './interactive/interactive.js';
|
||||
export { executePipeline, type PipelineExecutionOptions } from './execution/pipelineExecution.js';
|
||||
export {
|
||||
selectAndExecuteTask,
|
||||
confirmAndCreateWorktree,
|
||||
type SelectAndExecuteOptions,
|
||||
type WorktreeConfirmationResult,
|
||||
} from './selectAndExecute.js';
|
||||
} from './execution/selectAndExecute.js';
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in interactive/interactive.ts */
|
||||
export { interactiveMode } from './interactive/interactive.js';
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
import * as readline from 'node:readline';
|
||||
import chalk from 'chalk';
|
||||
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
||||
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
|
||||
import { isQuietMode } from '../../context.js';
|
||||
import { loadAgentSessions, updateAgentSession } from '../../config/paths.js';
|
||||
import { getProvider, type ProviderType } from '../../providers/index.js';
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in management/listTasks.ts */
|
||||
export { listTasks, isBranchMerged, showFullDiff, type ListAction } from './management/listTasks.js';
|
||||
@ -11,13 +11,13 @@ import { stringify as stringifyYaml } from 'yaml';
|
||||
import { promptInput, confirm, selectOption } from '../../prompt/index.js';
|
||||
import { success, info } from '../../utils/ui.js';
|
||||
import { summarizeTaskName } from '../../task/summarize.js';
|
||||
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
||||
import { loadGlobalConfig } from '../../config/global/globalConfig.js';
|
||||
import { getProvider, type ProviderType } from '../../providers/index.js';
|
||||
import { createLogger } from '../../utils/debug.js';
|
||||
import { getErrorMessage } from '../../utils/error.js';
|
||||
import { listWorkflows } from '../../config/workflowLoader.js';
|
||||
import { listWorkflows } from '../../config/loaders/workflowLoader.js';
|
||||
import { getCurrentWorkflow } from '../../config/paths.js';
|
||||
import { interactiveMode } from '../interactive.js';
|
||||
import { interactiveMode } from '../interactive/interactive.js';
|
||||
import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../github/issue.js';
|
||||
import type { TaskFileData } from '../../task/schema.js';
|
||||
|
||||
|
||||
@ -12,10 +12,10 @@ import {
|
||||
loadProjectConfig,
|
||||
updateProjectConfig,
|
||||
type PermissionMode,
|
||||
} from '../../config/projectConfig.js';
|
||||
} from '../../config/project/projectConfig.js';
|
||||
|
||||
// Re-export for convenience
|
||||
export type { PermissionMode } from '../../config/projectConfig.js';
|
||||
export type { PermissionMode } from '../../config/project/projectConfig.js';
|
||||
|
||||
/**
|
||||
* Get permission mode options for selection
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { getGlobalWorkflowsDir, getGlobalAgentsDir, getBuiltinWorkflowsDir, getBuiltinAgentsDir } from '../../config/paths.js';
|
||||
import { getLanguage } from '../../config/globalConfig.js';
|
||||
import { getLanguage } from '../../config/global/globalConfig.js';
|
||||
import { header, success, info, warn, error, blankLine } from '../../utils/ui.js';
|
||||
|
||||
/**
|
||||
|
||||
@ -25,8 +25,8 @@ import { selectOption, confirm, promptInput } from '../../prompt/index.js';
|
||||
import { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js';
|
||||
import { createLogger } from '../../utils/debug.js';
|
||||
import { getErrorMessage } from '../../utils/error.js';
|
||||
import { executeTask, type TaskExecutionOptions } from '../taskExecution.js';
|
||||
import { listWorkflows } from '../../config/workflowLoader.js';
|
||||
import { executeTask, type TaskExecutionOptions } from '../execution/taskExecution.js';
|
||||
import { listWorkflows } from '../../config/loaders/workflowLoader.js';
|
||||
import { getCurrentWorkflow } from '../../config/paths.js';
|
||||
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
|
||||
|
||||
|
||||
@ -15,9 +15,9 @@ import {
|
||||
status,
|
||||
blankLine,
|
||||
} from '../../utils/ui.js';
|
||||
import { executeAndCompleteTask } from '../taskExecution.js';
|
||||
import { executeAndCompleteTask } from '../execution/taskExecution.js';
|
||||
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
|
||||
import type { TaskExecutionOptions } from '../taskExecution.js';
|
||||
import type { TaskExecutionOptions } from '../execution/taskExecution.js';
|
||||
|
||||
/**
|
||||
* Watch for tasks and execute them as they appear.
|
||||
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in execution/pipelineExecution.ts */
|
||||
export { executePipeline, type PipelineExecutionOptions } from './execution/pipelineExecution.js';
|
||||
@ -1,7 +0,0 @@
|
||||
/** Re-export shim — actual implementation in execution/selectAndExecute.ts */
|
||||
export {
|
||||
selectAndExecuteTask,
|
||||
confirmAndCreateWorktree,
|
||||
type SelectAndExecuteOptions,
|
||||
type WorktreeConfirmationResult,
|
||||
} from './execution/selectAndExecute.js';
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in execution/session.ts */
|
||||
export { withAgentSession } from './execution/session.js';
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in execution/taskExecution.ts */
|
||||
export { executeTask, runAllTasks, executeAndCompleteTask, resolveTaskExecution, type TaskExecutionOptions } from './execution/taskExecution.js';
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in management/watchTasks.ts */
|
||||
export { watchTasks } from './management/watchTasks.js';
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in management/workflow.ts */
|
||||
export { switchWorkflow } from './management/workflow.js';
|
||||
@ -1,2 +0,0 @@
|
||||
/** Re-export shim — actual implementation in execution/workflowExecution.ts */
|
||||
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './execution/workflowExecution.js';
|
||||
@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Re-export shim — actual implementation in loaders/agentLoader.ts
|
||||
*/
|
||||
export {
|
||||
loadAgentsFromDir,
|
||||
loadCustomAgents,
|
||||
listCustomAgents,
|
||||
loadAgentPrompt,
|
||||
loadAgentPromptFromPath,
|
||||
} from './loaders/agentLoader.js';
|
||||
@ -18,7 +18,7 @@ import {
|
||||
ensureDir,
|
||||
} from '../paths.js';
|
||||
import { copyProjectResourcesToDir, getLanguageResourcesDir } from '../../resources/index.js';
|
||||
import { setLanguage, setProvider } from '../globalConfig.js';
|
||||
import { setLanguage, setProvider } from './globalConfig.js';
|
||||
|
||||
/**
|
||||
* Check if initial setup is needed.
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Re-export shim — actual implementation in global/globalConfig.ts
|
||||
*/
|
||||
export {
|
||||
invalidateGlobalConfigCache,
|
||||
loadGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
getDisabledBuiltins,
|
||||
getLanguage,
|
||||
setLanguage,
|
||||
setProvider,
|
||||
addTrustedDirectory,
|
||||
isDirectoryTrusted,
|
||||
resolveAnthropicApiKey,
|
||||
resolveOpenaiApiKey,
|
||||
loadProjectDebugConfig,
|
||||
getEffectiveDebugConfig,
|
||||
} from './global/globalConfig.js';
|
||||
@ -3,5 +3,5 @@
|
||||
*/
|
||||
|
||||
export * from './paths.js';
|
||||
export * from './loader.js';
|
||||
export * from './initialization.js';
|
||||
export * from './loaders/loader.js';
|
||||
export * from './global/initialization.js';
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Re-export shim — actual implementation in global/initialization.ts
|
||||
*/
|
||||
export {
|
||||
needsLanguageSetup,
|
||||
promptLanguageSelection,
|
||||
promptProviderSelection,
|
||||
initGlobalDirs,
|
||||
initProjectDirs,
|
||||
type InitGlobalDirsOptions,
|
||||
} from './global/initialization.js';
|
||||
@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Re-export shim — actual implementation in loaders/loader.ts
|
||||
*
|
||||
* Re-exports from specialized loaders for backward compatibility.
|
||||
*/
|
||||
|
||||
// Workflow loading
|
||||
export {
|
||||
getBuiltinWorkflow,
|
||||
loadWorkflow,
|
||||
loadWorkflowByIdentifier,
|
||||
isWorkflowPath,
|
||||
loadAllWorkflows,
|
||||
listWorkflows,
|
||||
} from './loaders/workflowLoader.js';
|
||||
|
||||
// Agent loading
|
||||
export {
|
||||
loadAgentsFromDir,
|
||||
loadCustomAgents,
|
||||
listCustomAgents,
|
||||
loadAgentPrompt,
|
||||
loadAgentPromptFromPath,
|
||||
} from './loaders/agentLoader.js';
|
||||
|
||||
// Global configuration
|
||||
export {
|
||||
loadGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
invalidateGlobalConfigCache,
|
||||
addTrustedDirectory,
|
||||
isDirectoryTrusted,
|
||||
loadProjectDebugConfig,
|
||||
getEffectiveDebugConfig,
|
||||
} from './global/globalConfig.js';
|
||||
@ -16,7 +16,7 @@ import {
|
||||
getBuiltinWorkflowsDir,
|
||||
isPathSafe,
|
||||
} from '../paths.js';
|
||||
import { getLanguage } from '../globalConfig.js';
|
||||
import { getLanguage } from '../global/globalConfig.js';
|
||||
|
||||
/** Get all allowed base directories for agent prompt files */
|
||||
function getAllowedAgentBases(): string[] {
|
||||
|
||||
@ -12,7 +12,7 @@ export {
|
||||
isWorkflowPath,
|
||||
loadAllWorkflows,
|
||||
listWorkflows,
|
||||
} from '../workflowLoader.js';
|
||||
} from './workflowLoader.js';
|
||||
|
||||
// Agent loading
|
||||
export {
|
||||
@ -21,7 +21,7 @@ export {
|
||||
listCustomAgents,
|
||||
loadAgentPrompt,
|
||||
loadAgentPromptFromPath,
|
||||
} from '../agentLoader.js';
|
||||
} from './agentLoader.js';
|
||||
|
||||
// Global configuration
|
||||
export {
|
||||
@ -32,4 +32,4 @@ export {
|
||||
isDirectoryTrusted,
|
||||
loadProjectDebugConfig,
|
||||
getEffectiveDebugConfig,
|
||||
} from '../globalConfig.js';
|
||||
} from '../global/globalConfig.js';
|
||||
|
||||
@ -15,7 +15,7 @@ import { parse as parseYaml } from 'yaml';
|
||||
import { WorkflowConfigRawSchema } from '../../models/schemas.js';
|
||||
import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../models/types.js';
|
||||
import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.js';
|
||||
import { getLanguage, getDisabledBuiltins } from '../globalConfig.js';
|
||||
import { getLanguage, getDisabledBuiltins } from '../global/globalConfig.js';
|
||||
|
||||
/** Get builtin workflow by name */
|
||||
export function getBuiltinWorkflow(name: string): WorkflowConfig | null {
|
||||
|
||||
@ -94,7 +94,7 @@ export {
|
||||
setCurrentWorkflow,
|
||||
isVerboseMode,
|
||||
type ProjectLocalConfig,
|
||||
} from './projectConfig.js';
|
||||
} from './project/projectConfig.js';
|
||||
|
||||
// Re-export session storage functions for backward compatibility
|
||||
export {
|
||||
@ -116,4 +116,4 @@ export {
|
||||
getWorktreeSessionPath,
|
||||
loadWorktreeSessions,
|
||||
updateWorktreeSession,
|
||||
} from './sessionStore.js';
|
||||
} from './project/sessionStore.js';
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
/**
|
||||
* Re-export shim — actual implementation in project/projectConfig.ts
|
||||
*/
|
||||
export {
|
||||
loadProjectConfig,
|
||||
saveProjectConfig,
|
||||
updateProjectConfig,
|
||||
getCurrentWorkflow,
|
||||
setCurrentWorkflow,
|
||||
isVerboseMode,
|
||||
type PermissionMode,
|
||||
type ProjectPermissionMode,
|
||||
type ProjectLocalConfig,
|
||||
} from './project/projectConfig.js';
|
||||
@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Re-export shim — actual implementation in project/sessionStore.ts
|
||||
*/
|
||||
export {
|
||||
writeFileAtomic,
|
||||
getInputHistoryPath,
|
||||
MAX_INPUT_HISTORY,
|
||||
loadInputHistory,
|
||||
saveInputHistory,
|
||||
addToInputHistory,
|
||||
type AgentSessionData,
|
||||
getAgentSessionsPath,
|
||||
loadAgentSessions,
|
||||
saveAgentSessions,
|
||||
updateAgentSession,
|
||||
clearAgentSessions,
|
||||
getWorktreeSessionsDir,
|
||||
encodeWorktreePath,
|
||||
getWorktreeSessionPath,
|
||||
loadWorktreeSessions,
|
||||
updateWorktreeSession,
|
||||
getClaudeProjectSessionsDir,
|
||||
clearClaudeProjectSessions,
|
||||
} from './project/sessionStore.js';
|
||||
@ -1,11 +0,0 @@
|
||||
/**
|
||||
* Re-export shim — actual implementation in loaders/workflowLoader.ts
|
||||
*/
|
||||
export {
|
||||
getBuiltinWorkflow,
|
||||
loadWorkflow,
|
||||
loadWorkflowByIdentifier,
|
||||
isWorkflowPath,
|
||||
loadAllWorkflows,
|
||||
listWorkflows,
|
||||
} from './loaders/workflowLoader.js';
|
||||
64
src/models/global-config.ts
Normal file
64
src/models/global-config.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Configuration types (global and project)
|
||||
*/
|
||||
|
||||
/** Custom agent configuration */
|
||||
export interface CustomAgentConfig {
|
||||
name: string;
|
||||
promptFile?: string;
|
||||
prompt?: string;
|
||||
allowedTools?: string[];
|
||||
claudeAgent?: string;
|
||||
claudeSkill?: string;
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Debug configuration for takt */
|
||||
export interface DebugConfig {
|
||||
enabled: boolean;
|
||||
logFile?: string;
|
||||
}
|
||||
|
||||
/** Language setting for takt */
|
||||
export type Language = 'en' | 'ja';
|
||||
|
||||
/** Pipeline execution configuration */
|
||||
export interface PipelineConfig {
|
||||
/** Branch name prefix for pipeline-created branches (default: "takt/") */
|
||||
defaultBranchPrefix?: string;
|
||||
/** Commit message template. Variables: {title}, {issue} */
|
||||
commitMessageTemplate?: string;
|
||||
/** PR body template. Variables: {issue_body}, {report}, {issue} */
|
||||
prBodyTemplate?: string;
|
||||
}
|
||||
|
||||
/** Global configuration for takt */
|
||||
export interface GlobalConfig {
|
||||
language: Language;
|
||||
trustedDirectories: string[];
|
||||
defaultWorkflow: string;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
model?: string;
|
||||
debug?: DebugConfig;
|
||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||
worktreeDir?: string;
|
||||
/** List of builtin workflow/agent names to exclude from fallback loading */
|
||||
disabledBuiltins?: string[];
|
||||
/** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
|
||||
anthropicApiKey?: string;
|
||||
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
||||
openaiApiKey?: string;
|
||||
/** Pipeline execution settings */
|
||||
pipeline?: PipelineConfig;
|
||||
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
||||
minimalOutput?: boolean;
|
||||
}
|
||||
|
||||
/** Project-level configuration */
|
||||
export interface ProjectConfig {
|
||||
workflow?: string;
|
||||
agents?: CustomAgentConfig[];
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
}
|
||||
21
src/models/response.ts
Normal file
21
src/models/response.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Agent response and session state types
|
||||
*/
|
||||
|
||||
import type { Status, RuleMatchMethod } from './status.js';
|
||||
|
||||
/** Response from an agent execution */
|
||||
export interface AgentResponse {
|
||||
agent: string;
|
||||
status: Status;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
sessionId?: string;
|
||||
/** Error message when the query failed (e.g., API error, rate limit) */
|
||||
error?: string;
|
||||
/** Matched rule index (0-based) when rules-based detection was used */
|
||||
matchedRuleIndex?: number;
|
||||
/** How the rule match was detected */
|
||||
matchedRuleMethod?: RuleMatchMethod;
|
||||
}
|
||||
|
||||
@ -2,7 +2,8 @@
|
||||
* Session type definitions
|
||||
*/
|
||||
|
||||
import type { AgentResponse, Status } from './types.js';
|
||||
import type { AgentResponse } from './response.js';
|
||||
import type { Status } from './status.js';
|
||||
|
||||
/**
|
||||
* Session state for workflow execution
|
||||
|
||||
29
src/models/status.ts
Normal file
29
src/models/status.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Status and classification types
|
||||
*/
|
||||
|
||||
/** Built-in agent types */
|
||||
export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom';
|
||||
|
||||
/** Execution status for agents and workflows */
|
||||
export type Status =
|
||||
| 'pending'
|
||||
| 'done'
|
||||
| 'blocked'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'improve'
|
||||
| 'cancelled'
|
||||
| 'interrupted'
|
||||
| 'answer';
|
||||
|
||||
/** How a rule match was detected */
|
||||
export type RuleMatchMethod =
|
||||
| 'aggregate'
|
||||
| 'phase3_tag'
|
||||
| 'phase1_tag'
|
||||
| 'ai_judge'
|
||||
| 'ai_judge_fallback';
|
||||
|
||||
/** Permission mode for tool execution */
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
||||
@ -1,219 +1,45 @@
|
||||
/**
|
||||
* Core type definitions for TAKT orchestration system
|
||||
*
|
||||
* This file re-exports all types from categorized sub-modules.
|
||||
* Consumers import from './types.js' — no path changes needed.
|
||||
*/
|
||||
|
||||
/** Built-in agent types */
|
||||
export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom';
|
||||
// Status and classification types
|
||||
export type {
|
||||
AgentType,
|
||||
Status,
|
||||
RuleMatchMethod,
|
||||
PermissionMode,
|
||||
} from './status.js';
|
||||
|
||||
/** Execution status for agents and workflows */
|
||||
export type Status =
|
||||
| 'pending'
|
||||
| 'done'
|
||||
| 'blocked'
|
||||
| 'approved'
|
||||
| 'rejected'
|
||||
| 'improve'
|
||||
| 'cancelled'
|
||||
| 'interrupted'
|
||||
| 'answer';
|
||||
// Agent response
|
||||
export type {
|
||||
AgentResponse,
|
||||
} from './response.js';
|
||||
|
||||
/** How a rule match was detected */
|
||||
export type RuleMatchMethod =
|
||||
| 'aggregate'
|
||||
| 'phase3_tag'
|
||||
| 'phase1_tag'
|
||||
| 'ai_judge'
|
||||
| 'ai_judge_fallback';
|
||||
// Session state (authoritative definition with createSessionState)
|
||||
export type {
|
||||
SessionState,
|
||||
} from './session.js';
|
||||
|
||||
/** Response from an agent execution */
|
||||
export interface AgentResponse {
|
||||
agent: string;
|
||||
status: Status;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
sessionId?: string;
|
||||
/** Error message when the query failed (e.g., API error, rate limit) */
|
||||
error?: string;
|
||||
/** Matched rule index (0-based) when rules-based detection was used */
|
||||
matchedRuleIndex?: number;
|
||||
/** How the rule match was detected */
|
||||
matchedRuleMethod?: RuleMatchMethod;
|
||||
}
|
||||
// Workflow configuration and runtime state
|
||||
export type {
|
||||
WorkflowRule,
|
||||
ReportConfig,
|
||||
ReportObjectConfig,
|
||||
WorkflowStep,
|
||||
LoopDetectionConfig,
|
||||
WorkflowConfig,
|
||||
WorkflowState,
|
||||
} from './workflow-types.js';
|
||||
|
||||
/** Session state for workflow execution */
|
||||
export interface SessionState {
|
||||
task: string;
|
||||
projectDir: string;
|
||||
iterations: number;
|
||||
history: AgentResponse[];
|
||||
context: Record<string, string>;
|
||||
}
|
||||
|
||||
/** Rule-based transition configuration (new unified format) */
|
||||
export interface WorkflowRule {
|
||||
/** Human-readable condition text */
|
||||
condition: string;
|
||||
/** Next step name (e.g., implement, COMPLETE, ABORT). Optional for parallel sub-steps. */
|
||||
next?: string;
|
||||
/** Template for additional AI output */
|
||||
appendix?: string;
|
||||
/** Whether this condition uses ai() expression (set by loader) */
|
||||
isAiCondition?: boolean;
|
||||
/** The condition text inside ai("...") for AI judge evaluation (set by loader) */
|
||||
aiConditionText?: string;
|
||||
/** Whether this condition uses all()/any() aggregate expression (set by loader) */
|
||||
isAggregateCondition?: boolean;
|
||||
/** Aggregate type: 'all' requires all sub-steps match, 'any' requires at least one (set by loader) */
|
||||
aggregateType?: 'all' | 'any';
|
||||
/** The condition text inside all("...")/any("...") to match against sub-step results (set by loader) */
|
||||
aggregateConditionText?: string;
|
||||
}
|
||||
|
||||
/** Report file configuration for a workflow step (label: path pair) */
|
||||
export interface ReportConfig {
|
||||
/** Display label (e.g., "Scope", "Decisions") */
|
||||
label: string;
|
||||
/** File path relative to report directory (e.g., "01-coder-scope.md") */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Report object configuration with order/format instructions */
|
||||
export interface ReportObjectConfig {
|
||||
/** Report file name (e.g., "00-plan.md") */
|
||||
name: string;
|
||||
/** Instruction prepended before instruction_template (e.g., output destination) */
|
||||
order?: string;
|
||||
/** Instruction appended after instruction_template (e.g., output format) */
|
||||
format?: string;
|
||||
}
|
||||
|
||||
/** Permission mode for tool execution */
|
||||
export type PermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';
|
||||
|
||||
/** Single step in a workflow */
|
||||
export interface WorkflowStep {
|
||||
name: string;
|
||||
/** Agent name or path as specified in workflow YAML */
|
||||
agent: string;
|
||||
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
|
||||
agentDisplayName: string;
|
||||
/** Allowed tools for this step (optional, passed to agent execution) */
|
||||
allowedTools?: string[];
|
||||
/** Resolved absolute path to agent prompt file (set by loader) */
|
||||
agentPath?: string;
|
||||
/** Provider override for this step */
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
/** Model override for this step */
|
||||
model?: string;
|
||||
/** Permission mode for tool execution in this step */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Whether this step is allowed to edit project files (true=allowed, false=prohibited, undefined=no prompt) */
|
||||
edit?: boolean;
|
||||
instructionTemplate: string;
|
||||
/** Rules for step routing */
|
||||
rules?: WorkflowRule[];
|
||||
/** Report file configuration. Single string, array of label:path, or object with order/format. */
|
||||
report?: string | ReportConfig[] | ReportObjectConfig;
|
||||
passPreviousResponse: boolean;
|
||||
/** Sub-steps to execute in parallel. When set, this step runs all sub-steps concurrently. */
|
||||
parallel?: WorkflowStep[];
|
||||
}
|
||||
|
||||
/** Loop detection configuration */
|
||||
export interface LoopDetectionConfig {
|
||||
/** Maximum consecutive runs of the same step before triggering (default: 10) */
|
||||
maxConsecutiveSameStep?: number;
|
||||
/** Action to take when loop is detected (default: 'warn') */
|
||||
action?: 'abort' | 'warn' | 'ignore';
|
||||
}
|
||||
|
||||
/** Workflow configuration */
|
||||
export interface WorkflowConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: WorkflowStep[];
|
||||
initialStep: string;
|
||||
maxIterations: number;
|
||||
/** Loop detection settings */
|
||||
loopDetection?: LoopDetectionConfig;
|
||||
/**
|
||||
* Agent to use for answering AskUserQuestion prompts automatically.
|
||||
* When specified, questions from Claude Code are routed to this agent
|
||||
* instead of prompting the user interactively.
|
||||
*/
|
||||
answerAgent?: string;
|
||||
}
|
||||
|
||||
/** Runtime state of a workflow execution */
|
||||
export interface WorkflowState {
|
||||
workflowName: string;
|
||||
currentStep: string;
|
||||
iteration: number;
|
||||
stepOutputs: Map<string, AgentResponse>;
|
||||
userInputs: string[];
|
||||
agentSessions: Map<string, string>;
|
||||
/** Per-step iteration counters (how many times each step has been executed) */
|
||||
stepIterations: Map<string, number>;
|
||||
status: 'running' | 'completed' | 'aborted';
|
||||
}
|
||||
|
||||
/** Custom agent configuration */
|
||||
export interface CustomAgentConfig {
|
||||
name: string;
|
||||
promptFile?: string;
|
||||
prompt?: string;
|
||||
allowedTools?: string[];
|
||||
claudeAgent?: string;
|
||||
claudeSkill?: string;
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
model?: string;
|
||||
}
|
||||
|
||||
/** Debug configuration for takt */
|
||||
export interface DebugConfig {
|
||||
enabled: boolean;
|
||||
logFile?: string;
|
||||
}
|
||||
|
||||
/** Language setting for takt */
|
||||
export type Language = 'en' | 'ja';
|
||||
|
||||
/** Pipeline execution configuration */
|
||||
export interface PipelineConfig {
|
||||
/** Branch name prefix for pipeline-created branches (default: "takt/") */
|
||||
defaultBranchPrefix?: string;
|
||||
/** Commit message template. Variables: {title}, {issue} */
|
||||
commitMessageTemplate?: string;
|
||||
/** PR body template. Variables: {issue_body}, {report}, {issue} */
|
||||
prBodyTemplate?: string;
|
||||
}
|
||||
|
||||
/** Global configuration for takt */
|
||||
export interface GlobalConfig {
|
||||
language: Language;
|
||||
trustedDirectories: string[];
|
||||
defaultWorkflow: string;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
model?: string;
|
||||
debug?: DebugConfig;
|
||||
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
|
||||
worktreeDir?: string;
|
||||
/** List of builtin workflow/agent names to exclude from fallback loading */
|
||||
disabledBuiltins?: string[];
|
||||
/** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
|
||||
anthropicApiKey?: string;
|
||||
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
|
||||
openaiApiKey?: string;
|
||||
/** Pipeline execution settings */
|
||||
pipeline?: PipelineConfig;
|
||||
/** Minimal output mode for CI - suppress AI output to prevent sensitive information leaks */
|
||||
minimalOutput?: boolean;
|
||||
}
|
||||
|
||||
/** Project-level configuration */
|
||||
export interface ProjectConfig {
|
||||
workflow?: string;
|
||||
agents?: CustomAgentConfig[];
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
}
|
||||
// Configuration types (global and project)
|
||||
export type {
|
||||
CustomAgentConfig,
|
||||
DebugConfig,
|
||||
Language,
|
||||
PipelineConfig,
|
||||
GlobalConfig,
|
||||
ProjectConfig,
|
||||
} from './global-config.js';
|
||||
|
||||
111
src/models/workflow-types.ts
Normal file
111
src/models/workflow-types.ts
Normal file
@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Workflow configuration and runtime state types
|
||||
*/
|
||||
|
||||
import type { PermissionMode } from './status.js';
|
||||
import type { AgentResponse } from './response.js';
|
||||
|
||||
/** Rule-based transition configuration (unified format) */
|
||||
export interface WorkflowRule {
|
||||
/** Human-readable condition text */
|
||||
condition: string;
|
||||
/** Next step name (e.g., implement, COMPLETE, ABORT). Optional for parallel sub-steps. */
|
||||
next?: string;
|
||||
/** Template for additional AI output */
|
||||
appendix?: string;
|
||||
/** Whether this condition uses ai() expression (set by loader) */
|
||||
isAiCondition?: boolean;
|
||||
/** The condition text inside ai("...") for AI judge evaluation (set by loader) */
|
||||
aiConditionText?: string;
|
||||
/** Whether this condition uses all()/any() aggregate expression (set by loader) */
|
||||
isAggregateCondition?: boolean;
|
||||
/** Aggregate type: 'all' requires all sub-steps match, 'any' requires at least one (set by loader) */
|
||||
aggregateType?: 'all' | 'any';
|
||||
/** The condition text inside all("...")/any("...") to match against sub-step results (set by loader) */
|
||||
aggregateConditionText?: string;
|
||||
}
|
||||
|
||||
/** Report file configuration for a workflow step (label: path pair) */
|
||||
export interface ReportConfig {
|
||||
/** Display label (e.g., "Scope", "Decisions") */
|
||||
label: string;
|
||||
/** File path relative to report directory (e.g., "01-coder-scope.md") */
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Report object configuration with order/format instructions */
|
||||
export interface ReportObjectConfig {
|
||||
/** Report file name (e.g., "00-plan.md") */
|
||||
name: string;
|
||||
/** Instruction prepended before instruction_template (e.g., output destination) */
|
||||
order?: string;
|
||||
/** Instruction appended after instruction_template (e.g., output format) */
|
||||
format?: string;
|
||||
}
|
||||
|
||||
/** Single step in a workflow */
|
||||
export interface WorkflowStep {
|
||||
name: string;
|
||||
/** Agent name or path as specified in workflow YAML */
|
||||
agent: string;
|
||||
/** Display name for the agent (shown in output). Falls back to agent basename if not specified */
|
||||
agentDisplayName: string;
|
||||
/** Allowed tools for this step (optional, passed to agent execution) */
|
||||
allowedTools?: string[];
|
||||
/** Resolved absolute path to agent prompt file (set by loader) */
|
||||
agentPath?: string;
|
||||
/** Provider override for this step */
|
||||
provider?: 'claude' | 'codex' | 'mock';
|
||||
/** Model override for this step */
|
||||
model?: string;
|
||||
/** Permission mode for tool execution in this step */
|
||||
permissionMode?: PermissionMode;
|
||||
/** Whether this step is allowed to edit project files (true=allowed, false=prohibited, undefined=no prompt) */
|
||||
edit?: boolean;
|
||||
instructionTemplate: string;
|
||||
/** Rules for step routing */
|
||||
rules?: WorkflowRule[];
|
||||
/** Report file configuration. Single string, array of label:path, or object with order/format. */
|
||||
report?: string | ReportConfig[] | ReportObjectConfig;
|
||||
passPreviousResponse: boolean;
|
||||
/** Sub-steps to execute in parallel. When set, this step runs all sub-steps concurrently. */
|
||||
parallel?: WorkflowStep[];
|
||||
}
|
||||
|
||||
/** Loop detection configuration */
|
||||
export interface LoopDetectionConfig {
|
||||
/** Maximum consecutive runs of the same step before triggering (default: 10) */
|
||||
maxConsecutiveSameStep?: number;
|
||||
/** Action to take when loop is detected (default: 'warn') */
|
||||
action?: 'abort' | 'warn' | 'ignore';
|
||||
}
|
||||
|
||||
/** Workflow configuration */
|
||||
export interface WorkflowConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: WorkflowStep[];
|
||||
initialStep: string;
|
||||
maxIterations: number;
|
||||
/** Loop detection settings */
|
||||
loopDetection?: LoopDetectionConfig;
|
||||
/**
|
||||
* Agent to use for answering AskUserQuestion prompts automatically.
|
||||
* When specified, questions from Claude Code are routed to this agent
|
||||
* instead of prompting the user interactively.
|
||||
*/
|
||||
answerAgent?: string;
|
||||
}
|
||||
|
||||
/** Runtime state of a workflow execution */
|
||||
export interface WorkflowState {
|
||||
workflowName: string;
|
||||
currentStep: string;
|
||||
iteration: number;
|
||||
stepOutputs: Map<string, AgentResponse>;
|
||||
userInputs: string[];
|
||||
agentSessions: Map<string, string>;
|
||||
/** Per-step iteration counters (how many times each step has been executed) */
|
||||
stepIterations: Map<string, number>;
|
||||
status: 'running' | 'completed' | 'aborted';
|
||||
}
|
||||
@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import { callClaude, callClaudeCustom, type ClaudeCallOptions } from '../claude/client.js';
|
||||
import { resolveAnthropicApiKey } from '../config/globalConfig.js';
|
||||
import { resolveAnthropicApiKey } from '../config/global/globalConfig.js';
|
||||
import type { AgentResponse } from '../models/types.js';
|
||||
import type { Provider, ProviderCallOptions } from './index.js';
|
||||
import type { Provider, ProviderCallOptions } from './types.js';
|
||||
|
||||
/** Claude provider - wraps existing Claude client */
|
||||
export class ClaudeProvider implements Provider {
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
*/
|
||||
|
||||
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js';
|
||||
import { resolveOpenaiApiKey } from '../config/globalConfig.js';
|
||||
import { resolveOpenaiApiKey } from '../config/global/globalConfig.js';
|
||||
import type { AgentResponse } from '../models/types.js';
|
||||
import type { Provider, ProviderCallOptions } from './index.js';
|
||||
import type { Provider, ProviderCallOptions } from './types.js';
|
||||
|
||||
/** Codex provider - wraps existing Codex client */
|
||||
export class CodexProvider implements Provider {
|
||||
|
||||
@ -5,66 +5,56 @@
|
||||
* This enables adding new providers without modifying the runner logic.
|
||||
*/
|
||||
|
||||
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/process.js';
|
||||
import type { AgentResponse, PermissionMode } from '../models/types.js';
|
||||
import { ClaudeProvider } from './claude.js';
|
||||
import { CodexProvider } from './codex.js';
|
||||
import { MockProvider } from './mock.js';
|
||||
import type { Provider, ProviderType } from './types.js';
|
||||
|
||||
/** Common options for all providers */
|
||||
export interface ProviderCallOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
allowedTools?: string[];
|
||||
/** Maximum number of agentic turns */
|
||||
maxTurns?: number;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
onStream?: StreamCallback;
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
bypassPermissions?: boolean;
|
||||
/** Anthropic API key for Claude provider */
|
||||
anthropicApiKey?: string;
|
||||
/** OpenAI API key for Codex provider */
|
||||
openaiApiKey?: string;
|
||||
}
|
||||
|
||||
/** Provider interface - all providers must implement this */
|
||||
export interface Provider {
|
||||
/** Call the provider with a prompt (using systemPrompt from options if provided) */
|
||||
call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
|
||||
|
||||
/** Call the provider with explicit system prompt */
|
||||
callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
|
||||
}
|
||||
|
||||
/** Provider type */
|
||||
export type ProviderType = 'claude' | 'codex' | 'mock';
|
||||
|
||||
/** Provider registry */
|
||||
const providers: Record<ProviderType, Provider> = {
|
||||
claude: new ClaudeProvider(),
|
||||
codex: new CodexProvider(),
|
||||
mock: new MockProvider(),
|
||||
};
|
||||
// Re-export types for backward compatibility
|
||||
export type { ProviderCallOptions, Provider, ProviderType } from './types.js';
|
||||
|
||||
/**
|
||||
* Get a provider instance by type
|
||||
* Registry for agent providers.
|
||||
* Singleton — use ProviderRegistry.getInstance().
|
||||
*/
|
||||
export function getProvider(type: ProviderType): Provider {
|
||||
const provider = providers[type];
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown provider type: ${type}`);
|
||||
export class ProviderRegistry {
|
||||
private static instance: ProviderRegistry | null = null;
|
||||
private readonly providers: Record<string, Provider>;
|
||||
|
||||
private constructor() {
|
||||
this.providers = {
|
||||
claude: new ClaudeProvider(),
|
||||
codex: new CodexProvider(),
|
||||
mock: new MockProvider(),
|
||||
};
|
||||
}
|
||||
return provider;
|
||||
|
||||
static getInstance(): ProviderRegistry {
|
||||
if (!ProviderRegistry.instance) {
|
||||
ProviderRegistry.instance = new ProviderRegistry();
|
||||
}
|
||||
return ProviderRegistry.instance;
|
||||
}
|
||||
|
||||
/** Reset singleton for testing */
|
||||
static resetInstance(): void {
|
||||
ProviderRegistry.instance = null;
|
||||
}
|
||||
|
||||
/** Get a provider instance by type */
|
||||
get(type: ProviderType): Provider {
|
||||
const provider = this.providers[type];
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown provider type: ${type}`);
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a custom provider
|
||||
*/
|
||||
export function registerProvider(type: string, provider: Provider): void {
|
||||
(providers as Record<string, Provider>)[type] = provider;
|
||||
// ---- Backward-compatible module-level functions ----
|
||||
|
||||
export function getProvider(type: ProviderType): Provider {
|
||||
return ProviderRegistry.getInstance().get(type);
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
import { callMock, callMockCustom, type MockCallOptions } from '../mock/client.js';
|
||||
import type { AgentResponse } from '../models/types.js';
|
||||
import type { Provider, ProviderCallOptions } from './index.js';
|
||||
import type { Provider, ProviderCallOptions } from './types.js';
|
||||
|
||||
/** Mock provider - wraps existing Mock client */
|
||||
export class MockProvider implements Provider {
|
||||
|
||||
39
src/providers/types.ts
Normal file
39
src/providers/types.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Type definitions for the provider abstraction layer
|
||||
*/
|
||||
|
||||
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/types.js';
|
||||
import type { AgentResponse, PermissionMode } from '../models/types.js';
|
||||
|
||||
/** Common options for all providers */
|
||||
export interface ProviderCallOptions {
|
||||
cwd: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
allowedTools?: string[];
|
||||
/** Maximum number of agentic turns */
|
||||
maxTurns?: number;
|
||||
/** Permission mode for tool execution (from workflow step) */
|
||||
permissionMode?: PermissionMode;
|
||||
onStream?: StreamCallback;
|
||||
onPermissionRequest?: PermissionHandler;
|
||||
onAskUserQuestion?: AskUserQuestionHandler;
|
||||
bypassPermissions?: boolean;
|
||||
/** Anthropic API key for Claude provider */
|
||||
anthropicApiKey?: string;
|
||||
/** OpenAI API key for Codex provider */
|
||||
openaiApiKey?: string;
|
||||
}
|
||||
|
||||
/** Provider interface - all providers must implement this */
|
||||
export interface Provider {
|
||||
/** Call the provider with a prompt (using systemPrompt from options if provided) */
|
||||
call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
|
||||
|
||||
/** Call the provider with explicit system prompt */
|
||||
callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse>;
|
||||
}
|
||||
|
||||
/** Provider type */
|
||||
export type ProviderType = 'claude' | 'codex' | 'mock';
|
||||
@ -24,52 +24,60 @@ export interface AutoCommitResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-commit all changes and push to origin.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Stage all changes (git add -A)
|
||||
* 2. Check if there are staged changes (git status --porcelain)
|
||||
* 3. If changes exist, create a commit with "takt: {taskName}"
|
||||
* 4. Push to origin (git push origin HEAD)
|
||||
*
|
||||
* @param cloneCwd - The clone directory
|
||||
* @param taskName - Task name used in commit message
|
||||
* @param projectDir - The main project directory (push target)
|
||||
* Handles auto-commit and push operations for clone tasks.
|
||||
*/
|
||||
export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult {
|
||||
log.info('Auto-commit starting', { cwd: cloneCwd, taskName });
|
||||
export class AutoCommitter {
|
||||
/**
|
||||
* Auto-commit all changes and push to the main project.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Stage all changes (git add -A)
|
||||
* 2. Check if there are staged changes
|
||||
* 3. If changes exist, create a commit with "takt: {taskName}"
|
||||
* 4. Push to the main project directory
|
||||
*/
|
||||
commitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult {
|
||||
log.info('Auto-commit starting', { cwd: cloneCwd, taskName });
|
||||
|
||||
try {
|
||||
const commitMessage = `takt: ${taskName}`;
|
||||
const commitHash = stageAndCommit(cloneCwd, commitMessage);
|
||||
try {
|
||||
const commitMessage = `takt: ${taskName}`;
|
||||
const commitHash = stageAndCommit(cloneCwd, commitMessage);
|
||||
|
||||
if (!commitHash) {
|
||||
log.info('No changes to commit');
|
||||
return { success: true, message: 'No changes to commit' };
|
||||
if (!commitHash) {
|
||||
log.info('No changes to commit');
|
||||
return { success: true, message: 'No changes to commit' };
|
||||
}
|
||||
|
||||
log.info('Auto-commit created', { commitHash, message: commitMessage });
|
||||
|
||||
execFileSync('git', ['push', projectDir, 'HEAD'], {
|
||||
cwd: cloneCwd,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
log.info('Pushed to main repo', { projectDir });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commitHash,
|
||||
message: `Committed & pushed: ${commitHash} - ${commitMessage}`,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
log.error('Auto-commit failed', { error: errorMessage });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Auto-commit failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
|
||||
log.info('Auto-commit created', { commitHash, message: commitMessage });
|
||||
|
||||
// Push directly to the main repo (origin was removed to isolate the clone)
|
||||
execFileSync('git', ['push', projectDir, 'HEAD'], {
|
||||
cwd: cloneCwd,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
log.info('Pushed to main repo', { projectDir });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commitHash,
|
||||
message: `Committed & pushed: ${commitHash} - ${commitMessage}`,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err);
|
||||
log.error('Auto-commit failed', { error: errorMessage });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: `Auto-commit failed: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Backward-compatible module-level function ----
|
||||
|
||||
const defaultCommitter = new AutoCommitter();
|
||||
|
||||
export function autoCommitAndPush(cloneCwd: string, taskName: string, projectDir: string): AutoCommitResult {
|
||||
return defaultCommitter.commitAndPush(cloneCwd, taskName, projectDir);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Branch list helpers
|
||||
*
|
||||
* Functions for listing, parsing, and enriching takt-managed branches
|
||||
* Listing, parsing, and enriching takt-managed branches
|
||||
* with metadata (diff stats, original instruction, task slug).
|
||||
* Used by the /list command.
|
||||
*/
|
||||
@ -29,147 +29,171 @@ export interface BranchListItem {
|
||||
const TAKT_BRANCH_PREFIX = 'takt/';
|
||||
|
||||
/**
|
||||
* Detect the default branch name (main or master).
|
||||
* Checks local branch refs directly. Falls back to 'main'.
|
||||
* Manages takt branch listing and metadata enrichment.
|
||||
*/
|
||||
export function detectDefaultBranch(cwd: string): string {
|
||||
try {
|
||||
const ref = execFileSync(
|
||||
'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'],
|
||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||
).trim();
|
||||
const parts = ref.split('/');
|
||||
return parts[parts.length - 1] || 'main';
|
||||
} catch {
|
||||
export class BranchManager {
|
||||
/** Detect the default branch name (main or master) */
|
||||
detectDefaultBranch(cwd: string): string {
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', 'main'], {
|
||||
cwd, encoding: 'utf-8', stdio: 'pipe',
|
||||
});
|
||||
return 'main';
|
||||
const ref = execFileSync(
|
||||
'git', ['symbolic-ref', 'refs/remotes/origin/HEAD'],
|
||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||
).trim();
|
||||
const parts = ref.split('/');
|
||||
return parts[parts.length - 1] || 'main';
|
||||
} catch {
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', 'master'], {
|
||||
execFileSync('git', ['rev-parse', '--verify', 'main'], {
|
||||
cwd, encoding: 'utf-8', stdio: 'pipe',
|
||||
});
|
||||
return 'master';
|
||||
} catch {
|
||||
return 'main';
|
||||
} catch {
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', 'master'], {
|
||||
cwd, encoding: 'utf-8', stdio: 'pipe',
|
||||
});
|
||||
return 'master';
|
||||
} catch {
|
||||
return 'main';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** List all takt-managed branches */
|
||||
listTaktBranches(projectDir: string): BranchInfo[] {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
|
||||
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
|
||||
);
|
||||
return BranchManager.parseTaktBranches(output);
|
||||
} catch (err) {
|
||||
log.error('Failed to list takt branches', { error: String(err) });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse `git branch --list` formatted output into BranchInfo entries */
|
||||
static parseTaktBranches(output: string): BranchInfo[] {
|
||||
const entries: BranchInfo[] = [];
|
||||
const lines = output.trim().split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const spaceIdx = trimmed.lastIndexOf(' ');
|
||||
if (spaceIdx === -1) continue;
|
||||
|
||||
const branch = trimmed.slice(0, spaceIdx);
|
||||
const commit = trimmed.slice(spaceIdx + 1);
|
||||
|
||||
if (branch.startsWith(TAKT_BRANCH_PREFIX)) {
|
||||
entries.push({ branch, commit });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Get the number of files changed between the default branch and a given branch */
|
||||
getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'git', ['diff', '--numstat', `${defaultBranch}...${branch}`],
|
||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||
);
|
||||
return output.trim().split('\n').filter(l => l.length > 0).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract a human-readable task slug from a takt branch name */
|
||||
static extractTaskSlug(branch: string): string {
|
||||
const name = branch.replace(TAKT_BRANCH_PREFIX, '');
|
||||
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
|
||||
return withoutTimestamp || name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original task instruction from the first commit message on a branch.
|
||||
* The first commit on a takt branch has the format: "takt: {original instruction}".
|
||||
*/
|
||||
getOriginalInstruction(
|
||||
cwd: string,
|
||||
defaultBranch: string,
|
||||
branch: string,
|
||||
): string {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
|
||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||
).trim();
|
||||
|
||||
if (!output) return '';
|
||||
|
||||
const firstLine = output.split('\n')[0] || '';
|
||||
const TAKT_COMMIT_PREFIX = 'takt:';
|
||||
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
|
||||
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
|
||||
}
|
||||
|
||||
return firstLine;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Build list items from branch list, enriching with diff stats */
|
||||
buildListItems(
|
||||
projectDir: string,
|
||||
branches: BranchInfo[],
|
||||
defaultBranch: string,
|
||||
): BranchListItem[] {
|
||||
return branches.map(br => ({
|
||||
info: br,
|
||||
filesChanged: this.getFilesChanged(projectDir, defaultBranch, br.branch),
|
||||
taskSlug: BranchManager.extractTaskSlug(br.branch),
|
||||
originalInstruction: this.getOriginalInstruction(projectDir, defaultBranch, br.branch),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Backward-compatible module-level functions ----
|
||||
|
||||
const defaultManager = new BranchManager();
|
||||
|
||||
export function detectDefaultBranch(cwd: string): string {
|
||||
return defaultManager.detectDefaultBranch(cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* List all takt-managed branches.
|
||||
*/
|
||||
export function listTaktBranches(projectDir: string): BranchInfo[] {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'],
|
||||
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
|
||||
);
|
||||
return parseTaktBranches(output);
|
||||
} catch (err) {
|
||||
log.error('Failed to list takt branches', { error: String(err) });
|
||||
return [];
|
||||
}
|
||||
return defaultManager.listTaktBranches(projectDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `git branch --list` formatted output into BranchInfo entries.
|
||||
*/
|
||||
export function parseTaktBranches(output: string): BranchInfo[] {
|
||||
const entries: BranchInfo[] = [];
|
||||
const lines = output.trim().split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
const spaceIdx = trimmed.lastIndexOf(' ');
|
||||
if (spaceIdx === -1) continue;
|
||||
|
||||
const branch = trimmed.slice(0, spaceIdx);
|
||||
const commit = trimmed.slice(spaceIdx + 1);
|
||||
|
||||
if (branch.startsWith(TAKT_BRANCH_PREFIX)) {
|
||||
entries.push({ branch, commit });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
return BranchManager.parseTaktBranches(output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of files changed between the default branch and a given branch.
|
||||
*/
|
||||
export function getFilesChanged(cwd: string, defaultBranch: string, branch: string): number {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'git', ['diff', '--numstat', `${defaultBranch}...${branch}`],
|
||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||
);
|
||||
return output.trim().split('\n').filter(l => l.length > 0).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
return defaultManager.getFilesChanged(cwd, defaultBranch, branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a human-readable task slug from a takt branch name.
|
||||
* e.g. "takt/20260128T032800-fix-auth" -> "fix-auth"
|
||||
*/
|
||||
export function extractTaskSlug(branch: string): string {
|
||||
const name = branch.replace(TAKT_BRANCH_PREFIX, '');
|
||||
const withoutTimestamp = name.replace(/^\d{8,}T?\d{0,6}-?/, '');
|
||||
return withoutTimestamp || name;
|
||||
return BranchManager.extractTaskSlug(branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the original task instruction from the first commit message on a branch.
|
||||
*
|
||||
* The first commit on a takt branch has the format: "takt: {original instruction}".
|
||||
* Strips the "takt: " prefix and returns the instruction text.
|
||||
* Returns empty string if extraction fails.
|
||||
*/
|
||||
export function getOriginalInstruction(
|
||||
cwd: string,
|
||||
defaultBranch: string,
|
||||
branch: string,
|
||||
): string {
|
||||
try {
|
||||
const output = execFileSync(
|
||||
'git',
|
||||
['log', '--format=%s', '--reverse', `${defaultBranch}..${branch}`],
|
||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||
).trim();
|
||||
|
||||
if (!output) return '';
|
||||
|
||||
const firstLine = output.split('\n')[0] || '';
|
||||
const TAKT_COMMIT_PREFIX = 'takt:';
|
||||
if (firstLine.startsWith(TAKT_COMMIT_PREFIX)) {
|
||||
return firstLine.slice(TAKT_COMMIT_PREFIX.length).trim();
|
||||
}
|
||||
|
||||
return firstLine;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
export function getOriginalInstruction(cwd: string, defaultBranch: string, branch: string): string {
|
||||
return defaultManager.getOriginalInstruction(cwd, defaultBranch, branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build list items from branch list, enriching with diff stats.
|
||||
*/
|
||||
export function buildListItems(
|
||||
projectDir: string,
|
||||
branches: BranchInfo[],
|
||||
defaultBranch: string,
|
||||
): BranchListItem[] {
|
||||
return branches.map(br => ({
|
||||
info: br,
|
||||
filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch),
|
||||
taskSlug: extractTaskSlug(br.branch),
|
||||
originalInstruction: getOriginalInstruction(projectDir, defaultBranch, br.branch),
|
||||
}));
|
||||
return defaultManager.buildListItems(projectDir, branches, defaultBranch);
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ import * as path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
import { slugify } from '../utils/slug.js';
|
||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
||||
import { loadGlobalConfig } from '../config/global/globalConfig.js';
|
||||
|
||||
const log = createLogger('clone');
|
||||
|
||||
@ -34,234 +34,231 @@ export interface WorktreeResult {
|
||||
branch: string;
|
||||
}
|
||||
|
||||
function generateTimestamp(): string {
|
||||
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
|
||||
}
|
||||
const CLONE_META_DIR = 'clone-meta';
|
||||
|
||||
/**
|
||||
* Resolve the base directory for clones from global config.
|
||||
* Returns the configured worktree_dir (resolved to absolute), or ../
|
||||
*/
|
||||
function resolveCloneBaseDir(projectDir: string): string {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
if (globalConfig.worktreeDir) {
|
||||
return path.isAbsolute(globalConfig.worktreeDir)
|
||||
? globalConfig.worktreeDir
|
||||
: path.resolve(projectDir, globalConfig.worktreeDir);
|
||||
}
|
||||
return path.join(projectDir, '..');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the clone path based on options and global config.
|
||||
* Manages git clone lifecycle for task isolation.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Custom path in options.worktree (string)
|
||||
* 2. worktree_dir from config.yaml (if set)
|
||||
* 3. Default: ../{dir-name}
|
||||
*
|
||||
* Format with issue: {timestamp}-{issue}-{slug}
|
||||
* Format without issue: {timestamp}-{slug}
|
||||
* Handles creation, removal, and metadata tracking of clones
|
||||
* used for parallel task execution.
|
||||
*/
|
||||
function resolveClonePath(projectDir: string, options: WorktreeOptions): string {
|
||||
const timestamp = generateTimestamp();
|
||||
const slug = slugify(options.taskSlug);
|
||||
|
||||
let dirName: string;
|
||||
if (options.issueNumber !== undefined && slug) {
|
||||
dirName = `${timestamp}-${options.issueNumber}-${slug}`;
|
||||
} else if (slug) {
|
||||
dirName = `${timestamp}-${slug}`;
|
||||
} else {
|
||||
dirName = timestamp;
|
||||
export class CloneManager {
|
||||
private static generateTimestamp(): string {
|
||||
return new Date().toISOString().replace(/[-:.]/g, '').slice(0, 13);
|
||||
}
|
||||
|
||||
if (typeof options.worktree === 'string') {
|
||||
return path.isAbsolute(options.worktree)
|
||||
? options.worktree
|
||||
: path.resolve(projectDir, options.worktree);
|
||||
/**
|
||||
* Resolve the base directory for clones from global config.
|
||||
* Returns the configured worktree_dir (resolved to absolute), or ../
|
||||
*/
|
||||
private static resolveCloneBaseDir(projectDir: string): string {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
if (globalConfig.worktreeDir) {
|
||||
return path.isAbsolute(globalConfig.worktreeDir)
|
||||
? globalConfig.worktreeDir
|
||||
: path.resolve(projectDir, globalConfig.worktreeDir);
|
||||
}
|
||||
return path.join(projectDir, '..');
|
||||
}
|
||||
|
||||
return path.join(resolveCloneBaseDir(projectDir), dirName);
|
||||
}
|
||||
/** Resolve the clone path based on options and global config */
|
||||
private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
|
||||
const timestamp = CloneManager.generateTimestamp();
|
||||
const slug = slugify(options.taskSlug);
|
||||
|
||||
/**
|
||||
* Resolve branch name from options.
|
||||
*
|
||||
* Format with issue: takt/#{issue}/{slug}
|
||||
* Format without issue: takt/{timestamp}-{slug}
|
||||
* Custom branch: use as-is
|
||||
*/
|
||||
function resolveBranchName(options: WorktreeOptions): string {
|
||||
if (options.branch) {
|
||||
return options.branch;
|
||||
let dirName: string;
|
||||
if (options.issueNumber !== undefined && slug) {
|
||||
dirName = `${timestamp}-${options.issueNumber}-${slug}`;
|
||||
} else if (slug) {
|
||||
dirName = `${timestamp}-${slug}`;
|
||||
} else {
|
||||
dirName = timestamp;
|
||||
}
|
||||
|
||||
if (typeof options.worktree === 'string') {
|
||||
return path.isAbsolute(options.worktree)
|
||||
? options.worktree
|
||||
: path.resolve(projectDir, options.worktree);
|
||||
}
|
||||
|
||||
return path.join(CloneManager.resolveCloneBaseDir(projectDir), dirName);
|
||||
}
|
||||
|
||||
const slug = slugify(options.taskSlug);
|
||||
/** Resolve branch name from options */
|
||||
private static resolveBranchName(options: WorktreeOptions): string {
|
||||
if (options.branch) {
|
||||
return options.branch;
|
||||
}
|
||||
|
||||
if (options.issueNumber !== undefined && slug) {
|
||||
return `takt/#${options.issueNumber}/${slug}`;
|
||||
const slug = slugify(options.taskSlug);
|
||||
|
||||
if (options.issueNumber !== undefined && slug) {
|
||||
return `takt/#${options.issueNumber}/${slug}`;
|
||||
}
|
||||
|
||||
const timestamp = CloneManager.generateTimestamp();
|
||||
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
|
||||
}
|
||||
|
||||
const timestamp = generateTimestamp();
|
||||
return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`;
|
||||
}
|
||||
private static branchExists(projectDir: string, branch: string): boolean {
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', branch], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function branchExists(projectDir: string, branch: string): boolean {
|
||||
try {
|
||||
execFileSync('git', ['rev-parse', '--verify', branch], {
|
||||
/** Clone a repository and remove origin to isolate from the main repo */
|
||||
private static cloneAndIsolate(projectDir: string, clonePath: string): void {
|
||||
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
|
||||
|
||||
execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a repository and remove origin to isolate from the main repo.
|
||||
*/
|
||||
function cloneAndIsolate(projectDir: string, clonePath: string): void {
|
||||
fs.mkdirSync(path.dirname(clonePath), { recursive: true });
|
||||
execFileSync('git', ['remote', 'remove', 'origin'], {
|
||||
cwd: clonePath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
execFileSync('git', ['clone', '--reference', projectDir, '--dissociate', projectDir, clonePath], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
execFileSync('git', ['remote', 'remove', 'origin'], {
|
||||
cwd: clonePath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
// Propagate local git user config from source repo to clone
|
||||
for (const key of ['user.name', 'user.email']) {
|
||||
try {
|
||||
const value = execFileSync('git', ['config', '--local', key], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
}).toString().trim();
|
||||
if (value) {
|
||||
execFileSync('git', ['config', key, value], {
|
||||
cwd: clonePath,
|
||||
// Propagate local git user config from source repo to clone
|
||||
for (const key of ['user.name', 'user.email']) {
|
||||
try {
|
||||
const value = execFileSync('git', ['config', '--local', key], {
|
||||
cwd: projectDir,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}).toString().trim();
|
||||
if (value) {
|
||||
execFileSync('git', ['config', key, value], {
|
||||
cwd: clonePath,
|
||||
stdio: 'pipe',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// not set locally — skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static encodeBranchName(branch: string): string {
|
||||
return branch.replace(/\//g, '--');
|
||||
}
|
||||
|
||||
private static getCloneMetaPath(projectDir: string, branch: string): string {
|
||||
return path.join(projectDir, '.takt', CLONE_META_DIR, `${CloneManager.encodeBranchName(branch)}.json`);
|
||||
}
|
||||
|
||||
/** Create a git clone for a task */
|
||||
createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
|
||||
const clonePath = CloneManager.resolveClonePath(projectDir, options);
|
||||
const branch = CloneManager.resolveBranchName(options);
|
||||
|
||||
log.info('Creating shared clone', { path: clonePath, branch });
|
||||
|
||||
CloneManager.cloneAndIsolate(projectDir, clonePath);
|
||||
|
||||
if (CloneManager.branchExists(clonePath, branch)) {
|
||||
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
|
||||
} else {
|
||||
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
this.saveCloneMeta(projectDir, branch, clonePath);
|
||||
log.info('Clone created', { path: clonePath, branch });
|
||||
|
||||
return { path: clonePath, branch };
|
||||
}
|
||||
|
||||
/** Create a temporary clone for an existing branch */
|
||||
createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
|
||||
const timestamp = CloneManager.generateTimestamp();
|
||||
const clonePath = path.join(CloneManager.resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
|
||||
|
||||
log.info('Creating temp clone for branch', { path: clonePath, branch });
|
||||
|
||||
CloneManager.cloneAndIsolate(projectDir, clonePath);
|
||||
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
|
||||
|
||||
this.saveCloneMeta(projectDir, branch, clonePath);
|
||||
log.info('Temp clone created', { path: clonePath, branch });
|
||||
|
||||
return { path: clonePath, branch };
|
||||
}
|
||||
|
||||
/** Remove a clone directory */
|
||||
removeClone(clonePath: string): void {
|
||||
log.info('Removing clone', { path: clonePath });
|
||||
try {
|
||||
fs.rmSync(clonePath, { recursive: true, force: true });
|
||||
log.info('Clone removed', { path: clonePath });
|
||||
} catch (err) {
|
||||
log.error('Failed to remove clone', { path: clonePath, error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Save clone metadata (branch → clonePath mapping) */
|
||||
saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
|
||||
const filePath = CloneManager.getCloneMetaPath(projectDir, branch);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
|
||||
log.info('Clone meta saved', { branch, clonePath });
|
||||
}
|
||||
|
||||
/** Remove clone metadata for a branch */
|
||||
removeCloneMeta(projectDir: string, branch: string): void {
|
||||
try {
|
||||
fs.unlinkSync(CloneManager.getCloneMetaPath(projectDir, branch));
|
||||
log.info('Clone meta removed', { branch });
|
||||
} catch {
|
||||
// File may not exist — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Clean up an orphaned clone directory associated with a branch */
|
||||
cleanupOrphanedClone(projectDir: string, branch: string): void {
|
||||
try {
|
||||
const raw = fs.readFileSync(CloneManager.getCloneMetaPath(projectDir, branch), 'utf-8');
|
||||
const meta = JSON.parse(raw) as { clonePath: string };
|
||||
if (fs.existsSync(meta.clonePath)) {
|
||||
this.removeClone(meta.clonePath);
|
||||
log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath });
|
||||
}
|
||||
} catch {
|
||||
// not set locally — skip
|
||||
// No metadata or parse error — nothing to clean up
|
||||
}
|
||||
this.removeCloneMeta(projectDir, branch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a git clone for a task.
|
||||
*
|
||||
* Uses `git clone --reference --dissociate` to create an independent clone,
|
||||
* then removes origin and checks out a new branch.
|
||||
*/
|
||||
// ---- Backward-compatible module-level functions ----
|
||||
|
||||
const defaultManager = new CloneManager();
|
||||
|
||||
export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult {
|
||||
const clonePath = resolveClonePath(projectDir, options);
|
||||
const branch = resolveBranchName(options);
|
||||
|
||||
log.info('Creating shared clone', { path: clonePath, branch });
|
||||
|
||||
cloneAndIsolate(projectDir, clonePath);
|
||||
|
||||
if (branchExists(clonePath, branch)) {
|
||||
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
|
||||
} else {
|
||||
execFileSync('git', ['checkout', '-b', branch], { cwd: clonePath, stdio: 'pipe' });
|
||||
}
|
||||
|
||||
saveCloneMeta(projectDir, branch, clonePath);
|
||||
log.info('Clone created', { path: clonePath, branch });
|
||||
|
||||
return { path: clonePath, branch };
|
||||
return defaultManager.createSharedClone(projectDir, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a temporary clone for an existing branch.
|
||||
* Used by review/instruct to work on a branch that was previously pushed.
|
||||
*/
|
||||
export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult {
|
||||
const timestamp = generateTimestamp();
|
||||
const clonePath = path.join(resolveCloneBaseDir(projectDir), `tmp-${timestamp}`);
|
||||
|
||||
log.info('Creating temp clone for branch', { path: clonePath, branch });
|
||||
|
||||
cloneAndIsolate(projectDir, clonePath);
|
||||
|
||||
execFileSync('git', ['checkout', branch], { cwd: clonePath, stdio: 'pipe' });
|
||||
|
||||
saveCloneMeta(projectDir, branch, clonePath);
|
||||
log.info('Temp clone created', { path: clonePath, branch });
|
||||
|
||||
return { path: clonePath, branch };
|
||||
return defaultManager.createTempCloneForBranch(projectDir, branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a clone directory.
|
||||
*/
|
||||
export function removeClone(clonePath: string): void {
|
||||
log.info('Removing clone', { path: clonePath });
|
||||
try {
|
||||
fs.rmSync(clonePath, { recursive: true, force: true });
|
||||
log.info('Clone removed', { path: clonePath });
|
||||
} catch (err) {
|
||||
log.error('Failed to remove clone', { path: clonePath, error: String(err) });
|
||||
}
|
||||
defaultManager.removeClone(clonePath);
|
||||
}
|
||||
|
||||
// --- Clone metadata ---
|
||||
|
||||
const CLONE_META_DIR = 'clone-meta';
|
||||
|
||||
function encodeBranchName(branch: string): string {
|
||||
return branch.replace(/\//g, '--');
|
||||
}
|
||||
|
||||
function getCloneMetaPath(projectDir: string, branch: string): string {
|
||||
return path.join(projectDir, '.takt', CLONE_META_DIR, `${encodeBranchName(branch)}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save clone metadata (branch → clonePath mapping).
|
||||
* Used to clean up orphaned clone directories on merge/delete.
|
||||
*/
|
||||
export function saveCloneMeta(projectDir: string, branch: string, clonePath: string): void {
|
||||
const filePath = getCloneMetaPath(projectDir, branch);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify({ branch, clonePath }));
|
||||
log.info('Clone meta saved', { branch, clonePath });
|
||||
defaultManager.saveCloneMeta(projectDir, branch, clonePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove clone metadata for a branch.
|
||||
*/
|
||||
export function removeCloneMeta(projectDir: string, branch: string): void {
|
||||
try {
|
||||
fs.unlinkSync(getCloneMetaPath(projectDir, branch));
|
||||
log.info('Clone meta removed', { branch });
|
||||
} catch {
|
||||
// File may not exist — ignore
|
||||
}
|
||||
defaultManager.removeCloneMeta(projectDir, branch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up an orphaned clone directory associated with a branch.
|
||||
* Reads metadata, removes clone directory if it still exists, then removes metadata.
|
||||
*/
|
||||
export function cleanupOrphanedClone(projectDir: string, branch: string): void {
|
||||
try {
|
||||
const raw = fs.readFileSync(getCloneMetaPath(projectDir, branch), 'utf-8');
|
||||
const meta = JSON.parse(raw) as { clonePath: string };
|
||||
if (fs.existsSync(meta.clonePath)) {
|
||||
removeClone(meta.clonePath);
|
||||
log.info('Orphaned clone cleaned up', { branch, clonePath: meta.clonePath });
|
||||
}
|
||||
} catch {
|
||||
// No metadata or parse error — nothing to clean up
|
||||
}
|
||||
removeCloneMeta(projectDir, branch);
|
||||
defaultManager.cleanupOrphanedClone(projectDir, branch);
|
||||
}
|
||||
|
||||
@ -2,6 +2,12 @@
|
||||
* Task execution module
|
||||
*/
|
||||
|
||||
// Classes
|
||||
export { CloneManager } from './clone.js';
|
||||
export { AutoCommitter } from './autoCommit.js';
|
||||
export { TaskSummarizer } from './summarize.js';
|
||||
export { BranchManager } from './branchList.js';
|
||||
|
||||
export {
|
||||
TaskRunner,
|
||||
type TaskInfo,
|
||||
@ -34,4 +40,5 @@ export {
|
||||
type BranchListItem,
|
||||
} from './branchList.js';
|
||||
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
|
||||
export { summarizeTaskName, type SummarizeOptions } from './summarize.js';
|
||||
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user