Files
Claude-Code-Workflow/ccw/src/tools/cli-executor.ts
catlog22 ac43cf85ec feat: Implement Skills Manager View and Notifier Module
- Added `skills-manager.js` for managing Claude Code skills with functionalities for loading, displaying, and editing skills.
- Introduced a Notifier module in `notifier.ts` for CLI to server communication, enabling notifications for UI updates on data changes.
- Created comprehensive documentation for the Chain Search implementation, including usage examples and performance tips.
- Developed a test suite for the Chain Search engine, covering basic search, quick search, symbol search, and files-only search functionalities.
2025-12-14 11:12:48 +08:00

1704 lines
51 KiB
TypeScript

/**
* CLI Executor Tool - Unified execution for external CLI tools
* Supports Gemini, Qwen, and Codex with streaming output
*/
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { spawn, ChildProcess } from 'child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs';
import { join, relative } from 'path';
// Native resume support
import {
trackNewSession,
getNativeResumeArgs,
supportsNativeResume,
calculateProjectHash
} from './native-session-discovery.js';
import {
determineResumeStrategy,
buildContextPrefix,
getResumeModeDescription,
type ResumeDecision
} from './resume-strategy.js';
// CLI History storage path
const CLI_HISTORY_DIR = join(process.cwd(), '.workflow', '.cli-history');
// Lazy-loaded SQLite store module
let sqliteStoreModule: typeof import('./cli-history-store.js') | null = null;
/**
* Get or initialize SQLite store (async)
*/
async function getSqliteStore(baseDir: string) {
if (!sqliteStoreModule) {
sqliteStoreModule = await import('./cli-history-store.js');
}
return sqliteStoreModule.getHistoryStore(baseDir);
}
/**
* Get SQLite store (sync - uses cached module)
*/
function getSqliteStoreSync(baseDir: string) {
if (!sqliteStoreModule) {
throw new Error('SQLite store not initialized. Call an async function first.');
}
return sqliteStoreModule.getHistoryStore(baseDir);
}
// Define Zod schema for validation
const ParamsSchema = z.object({
tool: z.enum(['gemini', 'qwen', 'codex']),
prompt: z.string().min(1, 'Prompt is required'),
mode: z.enum(['analysis', 'write', 'auto']).default('analysis'),
format: z.enum(['plain', 'yaml', 'json']).default('plain'), // Multi-turn prompt concatenation format
model: z.string().optional(),
cd: z.string().optional(),
includeDirs: z.string().optional(),
timeout: z.number().default(300000),
resume: z.union([z.boolean(), z.string()]).optional(), // true = last, string = single ID or comma-separated IDs
id: z.string().optional(), // Custom execution ID (e.g., IMPL-001-step1)
noNative: z.boolean().optional(), // Force prompt concatenation instead of native resume
category: z.enum(['user', 'internal', 'insight']).default('user'), // Execution category for tracking
parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios
});
// Execution category types
export type ExecutionCategory = 'user' | 'internal' | 'insight';
type Params = z.infer<typeof ParamsSchema>;
// Prompt concatenation format types
type PromptFormat = 'plain' | 'yaml' | 'json';
interface ToolAvailability {
available: boolean;
path: string | null;
}
// Single turn in a conversation
interface ConversationTurn {
turn: number;
timestamp: string;
prompt: string;
duration_ms: number;
status: 'success' | 'error' | 'timeout';
exit_code: number | null;
output: {
stdout: string;
stderr: string;
truncated: boolean;
};
}
// Multi-turn conversation record
interface ConversationRecord {
id: string;
created_at: string;
updated_at: string;
tool: string;
model: string;
mode: string;
category: ExecutionCategory; // user | internal | insight
total_duration_ms: number;
turn_count: number;
latest_status: 'success' | 'error' | 'timeout';
turns: ConversationTurn[];
}
// Legacy single execution record (for backward compatibility)
interface ExecutionRecord {
id: string;
timestamp: string;
tool: string;
model: string;
mode: string;
prompt: string;
status: 'success' | 'error' | 'timeout';
exit_code: number | null;
duration_ms: number;
output: {
stdout: string;
stderr: string;
truncated: boolean;
};
}
interface HistoryIndex {
version: number;
total_executions: number;
executions: {
id: string;
timestamp: string; // created_at for conversations
updated_at?: string; // last update time
tool: string;
status: string;
duration_ms: number;
turn_count?: number; // number of turns in conversation
prompt_preview: string;
}[];
}
interface ExecutionOutput {
success: boolean;
execution: ExecutionRecord;
conversation: ConversationRecord; // Full conversation record
stdout: string;
stderr: string;
}
/**
* Check if a CLI tool is available
*/
async function checkToolAvailability(tool: string): Promise<ToolAvailability> {
return new Promise((resolve) => {
const isWindows = process.platform === 'win32';
const command = isWindows ? 'where' : 'which';
const child = spawn(command, [tool], {
shell: isWindows,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
child.stdout!.on('data', (data) => { stdout += data.toString(); });
child.on('close', (code) => {
if (code === 0 && stdout.trim()) {
resolve({ available: true, path: stdout.trim().split('\n')[0] });
} else {
resolve({ available: false, path: null });
}
});
child.on('error', () => {
resolve({ available: false, path: null });
});
// Timeout after 5 seconds
setTimeout(() => {
child.kill();
resolve({ available: false, path: null });
}, 5000);
});
}
// Native resume configuration
interface NativeResumeConfig {
enabled: boolean;
sessionId?: string; // Native UUID
isLatest?: boolean; // Use latest/--last flag
}
/**
* Build command arguments based on tool and options
*/
function buildCommand(params: {
tool: string;
prompt: string;
mode: string;
model?: string;
dir?: string;
include?: string;
nativeResume?: NativeResumeConfig;
}): { command: string; args: string[]; useStdin: boolean } {
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume } = params;
let command = tool;
let args: string[] = [];
// Default to stdin for all tools to avoid escaping issues on Windows
let useStdin = true;
switch (tool) {
case 'gemini':
// Native resume: gemini -r <uuid> or -r latest
if (nativeResume?.enabled) {
if (nativeResume.isLatest) {
args.push('-r', 'latest');
} else if (nativeResume.sessionId) {
args.push('-r', nativeResume.sessionId);
}
}
if (model) {
args.push('-m', model);
}
if (mode === 'write') {
args.push('--approval-mode', 'yolo');
}
if (include) {
args.push('--include-directories', include);
}
break;
case 'qwen':
// Native resume: qwen --continue (latest) or --resume <uuid>
if (nativeResume?.enabled) {
if (nativeResume.isLatest) {
args.push('--continue');
} else if (nativeResume.sessionId) {
args.push('--resume', nativeResume.sessionId);
}
}
if (model) {
args.push('-m', model);
}
if (mode === 'write') {
args.push('--approval-mode', 'yolo');
}
if (include) {
args.push('--include-directories', include);
}
break;
case 'codex':
// Native resume: codex resume <uuid> [prompt] or --last
if (nativeResume?.enabled) {
args.push('resume');
if (nativeResume.isLatest) {
args.push('--last');
} else if (nativeResume.sessionId) {
args.push(nativeResume.sessionId);
}
// Codex resume still supports additional flags
if (dir) {
args.push('-C', dir);
}
if (mode === 'write' || mode === 'auto') {
args.push('--skip-git-repo-check', '-s', 'danger-full-access');
}
if (model) {
args.push('-m', model);
}
if (include) {
const dirs = include.split(',').map(d => d.trim()).filter(d => d);
for (const addDir of dirs) {
args.push('--add-dir', addDir);
}
}
} else {
// Standard exec mode
args.push('exec');
if (dir) {
args.push('-C', dir);
}
args.push('--full-auto');
if (mode === 'write' || mode === 'auto') {
args.push('--skip-git-repo-check', '-s', 'danger-full-access');
}
if (model) {
args.push('-m', model);
}
if (include) {
const dirs = include.split(',').map(d => d.trim()).filter(d => d);
for (const addDir of dirs) {
args.push('--add-dir', addDir);
}
}
}
break;
case 'claude':
// Claude Code: claude -p "prompt" for non-interactive mode
args.push('-p'); // Print mode (non-interactive)
// Native resume: claude --resume <session-id> or --continue
if (nativeResume?.enabled) {
if (nativeResume.isLatest) {
args.push('--continue');
} else if (nativeResume.sessionId) {
args.push('--resume', nativeResume.sessionId);
}
}
if (model) {
args.push('--model', model);
}
// Permission modes for write/auto
if (mode === 'write' || mode === 'auto') {
args.push('--dangerously-skip-permissions');
}
// Output format for better parsing
args.push('--output-format', 'text');
// Add directories
if (include) {
const dirs = include.split(',').map(d => d.trim()).filter(d => d);
for (const addDir of dirs) {
args.push('--add-dir', addDir);
}
}
break;
default:
throw new Error(`Unknown CLI tool: ${tool}`);
}
return { command, args, useStdin };
}
/**
* Ensure history directory exists
*/
function ensureHistoryDir(baseDir: string): string {
const historyDir = join(baseDir, '.workflow', '.cli-history');
if (!existsSync(historyDir)) {
mkdirSync(historyDir, { recursive: true });
}
return historyDir;
}
/**
* Save conversation to SQLite
*/
async function saveConversationAsync(historyDir: string, conversation: ConversationRecord): Promise<void> {
const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
const store = await getSqliteStore(baseDir);
store.saveConversation(conversation);
}
/**
* Sync wrapper for saveConversation (uses cached SQLite module)
*/
function saveConversation(historyDir: string, conversation: ConversationRecord): void {
const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
try {
const store = getSqliteStoreSync(baseDir);
store.saveConversation(conversation);
} catch {
// If sync not available, queue for async save
saveConversationAsync(historyDir, conversation).catch(err => {
console.error('[CLI Executor] Failed to save conversation:', err.message);
});
}
}
/**
* Load existing conversation by ID from SQLite
*/
async function loadConversationAsync(historyDir: string, conversationId: string): Promise<ConversationRecord | null> {
const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
const store = await getSqliteStore(baseDir);
return store.getConversation(conversationId);
}
/**
* Sync wrapper for loadConversation (uses cached SQLite module)
*/
function loadConversation(historyDir: string, conversationId: string): ConversationRecord | null {
const baseDir = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
try {
const store = getSqliteStoreSync(baseDir);
return store.getConversation(conversationId);
} catch {
// SQLite not initialized yet, return null
return null;
}
}
/**
* Convert legacy ExecutionRecord to ConversationRecord
*/
function convertToConversation(record: ExecutionRecord): ConversationRecord {
return {
id: record.id,
created_at: record.timestamp,
updated_at: record.timestamp,
tool: record.tool,
model: record.model,
mode: record.mode,
category: 'user', // Legacy records default to user category
total_duration_ms: record.duration_ms,
turn_count: 1,
latest_status: record.status,
turns: [{
turn: 1,
timestamp: record.timestamp,
prompt: record.prompt,
duration_ms: record.duration_ms,
status: record.status,
exit_code: record.exit_code,
output: record.output
}]
};
}
/**
* Merge multiple conversations into a unified context
* Returns merged turns sorted by timestamp with source tracking
*/
interface MergedTurn extends ConversationTurn {
source_id: string; // Original conversation ID
}
interface MergeResult {
mergedTurns: MergedTurn[];
sourceConversations: ConversationRecord[];
totalDuration: number;
}
function mergeConversations(conversations: ConversationRecord[]): MergeResult {
const mergedTurns: MergedTurn[] = [];
// Collect all turns with source tracking
for (const conv of conversations) {
for (const turn of conv.turns) {
mergedTurns.push({
...turn,
source_id: conv.id
});
}
}
// Sort by timestamp
mergedTurns.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
// Re-number turns
mergedTurns.forEach((turn, idx) => {
turn.turn = idx + 1;
});
// Calculate total duration
const totalDuration = mergedTurns.reduce((sum, t) => sum + t.duration_ms, 0);
return {
mergedTurns,
sourceConversations: conversations,
totalDuration
};
}
/**
* Build prompt from merged conversations
*/
function buildMergedPrompt(
mergeResult: MergeResult,
newPrompt: string,
format: PromptFormat = 'plain'
): string {
const concatenator = createPromptConcatenator({ format });
// Set metadata for merged conversations
concatenator.setMetadata(
'merged_sources',
mergeResult.sourceConversations.map(c => c.id).join(', ')
);
// Add all merged turns with source tracking
for (const turn of mergeResult.mergedTurns) {
concatenator.addFromConversationTurn(turn, turn.source_id);
}
return concatenator.build(newPrompt);
}
/**
* Execute CLI tool with streaming output
*/
async function executeCliTool(
params: Record<string, unknown>,
onOutput?: ((data: { type: string; data: string }) => void) | null
): Promise<ExecutionOutput> {
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
throw new Error(`Invalid params: ${parsed.error.message}`);
}
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category } = parsed.data;
// Determine working directory early (needed for conversation lookup)
const workingDir = cd || process.cwd();
const historyDir = ensureHistoryDir(workingDir);
// Get SQLite store for native session lookup
const store = await getSqliteStore(workingDir);
// Determine conversation ID and load existing conversation
// Logic:
// - If --resume <id1,id2,...> (multiple IDs): merge conversations
// - With --id: create new merged conversation
// - Without --id: append to ALL source conversations
// - If --resume <id> AND --id <newId>: fork - read context from resume ID, create new conversation with newId
// - If --id provided (no resume): use that ID (create new or append)
// - If --resume <id> without --id: use resume ID (append to existing)
// - No params: create new with auto-generated ID
let conversationId: string;
let existingConversation: ConversationRecord | null = null;
let contextConversation: ConversationRecord | null = null; // For fork scenario
let mergeResult: MergeResult | null = null; // For merge scenario
let sourceConversations: ConversationRecord[] = []; // All source conversations for merge
// Parse resume IDs (can be comma-separated for merge)
const resumeIds: string[] = resume
? (typeof resume === 'string' ? resume.split(',').map(id => id.trim()).filter(Boolean) : [])
: [];
const isMerge = resumeIds.length > 1;
const resumeId = resumeIds.length === 1 ? resumeIds[0] : null;
if (isMerge) {
// Merge scenario: multiple resume IDs
sourceConversations = resumeIds
.map(id => loadConversation(historyDir, id))
.filter((c): c is ConversationRecord => c !== null);
if (sourceConversations.length === 0) {
throw new Error('No valid conversations found for merge');
}
mergeResult = mergeConversations(sourceConversations);
if (customId) {
// Create new merged conversation with custom ID
conversationId = customId;
existingConversation = loadConversation(historyDir, customId);
} else {
// Will append to ALL source conversations (handled in save logic)
// Use first source conversation ID as primary
conversationId = sourceConversations[0].id;
existingConversation = sourceConversations[0];
}
} else if (customId && resumeId) {
// Fork: read context from resume ID, but create new conversation with custom ID
conversationId = customId;
contextConversation = loadConversation(historyDir, resumeId);
existingConversation = loadConversation(historyDir, customId);
} else if (customId) {
// Use custom ID - may be new or existing
conversationId = customId;
existingConversation = loadConversation(historyDir, customId);
} else if (resumeId) {
// Resume single ID without new ID - append to existing conversation
conversationId = resumeId;
existingConversation = loadConversation(historyDir, resumeId);
} else if (resume) {
// resume=true: get last conversation for this tool
const history = getExecutionHistory(workingDir, { limit: 1, tool });
if (history.executions.length > 0) {
conversationId = history.executions[0].id;
existingConversation = loadConversation(historyDir, conversationId);
} else {
// No previous conversation, create new
conversationId = `${Date.now()}-${tool}`;
}
} else {
// New conversation with auto-generated ID
conversationId = `${Date.now()}-${tool}`;
}
// Determine resume strategy (native vs prompt-concat vs hybrid)
let resumeDecision: ResumeDecision | null = null;
let nativeResumeConfig: NativeResumeConfig | undefined;
// resume=true (latest) - use native latest if supported
if (resume === true && !noNative && supportsNativeResume(tool)) {
resumeDecision = {
strategy: 'native',
isLatest: true,
primaryConversationId: conversationId
};
}
// Use strategy engine for complex scenarios
else if (resumeIds.length > 0 && !noNative) {
resumeDecision = determineResumeStrategy({
tool,
resumeIds,
customId,
forcePromptConcat: noNative,
getNativeSessionId: (ccwId) => store.getNativeSessionId(ccwId),
getConversation: (ccwId) => loadConversation(historyDir, ccwId),
getConversationTool: (ccwId) => {
const conv = loadConversation(historyDir, ccwId);
return conv?.tool || null;
}
});
}
// Configure native resume if strategy decided to use it
if (resumeDecision && (resumeDecision.strategy === 'native' || resumeDecision.strategy === 'hybrid')) {
nativeResumeConfig = {
enabled: true,
sessionId: resumeDecision.nativeSessionId,
isLatest: resumeDecision.isLatest
};
}
// Build final prompt with conversation context
// For native: minimal prompt (native tool handles context)
// For hybrid: context prefix from other conversations + new prompt
// For prompt-concat: full multi-turn prompt
let finalPrompt = prompt;
if (resumeDecision?.strategy === 'native') {
// Native mode: just use the new prompt, tool handles context
finalPrompt = prompt;
} else if (resumeDecision?.strategy === 'hybrid' && resumeDecision.contextTurns?.length) {
// Hybrid mode: add context prefix from other conversations
const contextPrefix = buildContextPrefix(resumeDecision.contextTurns, format);
finalPrompt = contextPrefix + prompt;
} else if (mergeResult && mergeResult.mergedTurns.length > 0) {
// Full merge: use merged prompt
finalPrompt = buildMergedPrompt(mergeResult, prompt, format);
} else {
// Standard prompt-concat
const conversationForContext = contextConversation || existingConversation;
if (conversationForContext && conversationForContext.turns.length > 0) {
finalPrompt = buildMultiTurnPrompt(conversationForContext, prompt, format);
}
}
// Check tool availability
const toolStatus = await checkToolAvailability(tool);
if (!toolStatus.available) {
throw new Error(`CLI tool not available: ${tool}. Please ensure it is installed and in PATH.`);
}
// Log resume mode for debugging
if (resumeDecision) {
const modeDesc = getResumeModeDescription(resumeDecision);
if (onOutput) {
onOutput({ type: 'stderr', data: `[Resume mode: ${modeDesc}]\n` });
}
}
// Build command
const { command, args, useStdin } = buildCommand({
tool,
prompt: finalPrompt,
mode,
model,
dir: cd,
include: includeDirs,
nativeResume: nativeResumeConfig
});
const startTime = Date.now();
return new Promise((resolve, reject) => {
const isWindows = process.platform === 'win32';
// On Windows with shell:true, we need to properly quote args containing spaces
let spawnArgs = args;
if (isWindows) {
// Quote arguments containing spaces for cmd.exe
spawnArgs = args.map(arg => {
if (arg.includes(' ') || arg.includes('"')) {
// Escape existing quotes and wrap in quotes
return `"${arg.replace(/"/g, '\\"')}"`;
}
return arg;
});
}
const child = spawn(command, spawnArgs, {
cwd: workingDir,
shell: isWindows,
stdio: [useStdin ? 'pipe' : 'ignore', 'pipe', 'pipe']
});
// Write prompt to stdin if using stdin mode (for gemini/qwen)
if (useStdin && child.stdin) {
child.stdin.write(finalPrompt);
child.stdin.end();
}
let stdout = '';
let stderr = '';
let timedOut = false;
// Handle stdout
child.stdout!.on('data', (data) => {
const text = data.toString();
stdout += text;
if (onOutput) {
onOutput({ type: 'stdout', data: text });
}
});
// Handle stderr
child.stderr!.on('data', (data) => {
const text = data.toString();
stderr += text;
if (onOutput) {
onOutput({ type: 'stderr', data: text });
}
});
// Handle completion
child.on('close', (code) => {
const endTime = Date.now();
const duration = endTime - startTime;
// Determine status
let status: 'success' | 'error' | 'timeout' = 'success';
if (timedOut) {
status = 'timeout';
} else if (code !== 0) {
// Check if HTTP 429 but results exist (Gemini quirk)
if (stderr.includes('429') && stdout.trim()) {
status = 'success';
} else {
status = 'error';
}
}
// Create new turn
const newTurnOutput = {
stdout: stdout.substring(0, 10240), // Truncate to 10KB
stderr: stderr.substring(0, 2048), // Truncate to 2KB
truncated: stdout.length > 10240 || stderr.length > 2048
};
// Determine base turn number for merge scenarios
const baseTurnNumber = isMerge && mergeResult
? mergeResult.mergedTurns.length + 1
: (existingConversation ? existingConversation.turns.length + 1 : 1);
const newTurn: ConversationTurn = {
turn: baseTurnNumber,
timestamp: new Date(startTime).toISOString(),
prompt,
duration_ms: duration,
status,
exit_code: code,
output: newTurnOutput
};
// Create or update conversation record
let conversation: ConversationRecord;
if (isMerge && mergeResult && !customId) {
// Merge without --id: append to ALL source conversations
// Save new turn to each source conversation
const savedConversations: ConversationRecord[] = [];
for (const srcConv of sourceConversations) {
const turnForSrc: ConversationTurn = {
...newTurn,
turn: srcConv.turns.length + 1 // Use each conversation's turn count
};
const updatedConv: ConversationRecord = {
...srcConv,
updated_at: new Date().toISOString(),
total_duration_ms: srcConv.total_duration_ms + duration,
turn_count: srcConv.turns.length + 1,
latest_status: status,
turns: [...srcConv.turns, turnForSrc]
};
savedConversations.push(updatedConv);
}
// Use first conversation as primary
conversation = savedConversations[0];
// Save all source conversations
try {
for (const conv of savedConversations) {
saveConversation(historyDir, conv);
}
} catch (err) {
console.error('[CLI Executor] Failed to save merged histories:', (err as Error).message);
}
} else if (isMerge && mergeResult && customId) {
// Merge with --id: create new conversation with merged turns + new turn
// Convert merged turns to regular turns (without source_id)
const mergedTurns: ConversationTurn[] = mergeResult.mergedTurns.map((mt, idx) => ({
turn: idx + 1,
timestamp: mt.timestamp,
prompt: mt.prompt,
duration_ms: mt.duration_ms,
status: mt.status,
exit_code: mt.exit_code,
output: mt.output
}));
conversation = existingConversation
? {
...existingConversation,
updated_at: new Date().toISOString(),
total_duration_ms: existingConversation.total_duration_ms + duration,
turn_count: existingConversation.turns.length + 1,
latest_status: status,
turns: [...existingConversation.turns, newTurn]
}
: {
id: conversationId,
created_at: new Date(startTime).toISOString(),
updated_at: new Date().toISOString(),
tool,
model: model || 'default',
mode,
category,
total_duration_ms: mergeResult.totalDuration + duration,
turn_count: mergedTurns.length + 1,
latest_status: status,
turns: [...mergedTurns, newTurn]
};
// Save merged conversation
try {
saveConversation(historyDir, conversation);
} catch (err) {
console.error('[CLI Executor] Failed to save merged conversation:', (err as Error).message);
}
} else {
// Normal scenario: single conversation
conversation = existingConversation
? {
...existingConversation,
updated_at: new Date().toISOString(),
total_duration_ms: existingConversation.total_duration_ms + duration,
turn_count: existingConversation.turns.length + 1,
latest_status: status,
turns: [...existingConversation.turns, newTurn]
}
: {
id: conversationId,
created_at: new Date(startTime).toISOString(),
updated_at: new Date().toISOString(),
tool,
model: model || 'default',
mode,
category,
total_duration_ms: duration,
turn_count: 1,
latest_status: status,
turns: [newTurn]
};
// Try to save conversation to history
try {
saveConversation(historyDir, conversation);
} catch (err) {
// Non-fatal: continue even if history save fails
console.error('[CLI Executor] Failed to save history:', (err as Error).message);
}
}
// Track native session after execution (async, non-blocking)
trackNewSession(tool, new Date(startTime), workingDir)
.then((nativeSession) => {
if (nativeSession) {
// Save native session mapping
try {
store.saveNativeSessionMapping({
ccw_id: conversationId,
tool,
native_session_id: nativeSession.sessionId,
native_session_path: nativeSession.filePath,
project_hash: nativeSession.projectHash,
created_at: new Date().toISOString()
});
} catch (err) {
console.error('[CLI Executor] Failed to save native session mapping:', (err as Error).message);
}
}
})
.catch((err) => {
console.error('[CLI Executor] Failed to track native session:', (err as Error).message);
});
// Create legacy execution record for backward compatibility
const execution: ExecutionRecord = {
id: conversationId,
timestamp: new Date(startTime).toISOString(),
tool,
model: model || 'default',
mode,
prompt,
status,
exit_code: code,
duration_ms: duration,
output: newTurnOutput
};
resolve({
success: status === 'success',
execution,
conversation,
stdout,
stderr
});
});
// Handle errors
child.on('error', (error) => {
reject(new Error(`Failed to spawn ${tool}: ${error.message}`));
});
// Timeout handling
const timeoutId = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
setTimeout(() => {
if (!child.killed) {
child.kill('SIGKILL');
}
}, 5000);
}, timeout);
child.on('close', () => {
clearTimeout(timeoutId);
});
});
}
// Tool schema for MCP
export const schema: ToolSchema = {
name: 'cli_executor',
description: `Execute external CLI tools (gemini/qwen/codex) with unified interface.
Modes:
- analysis: Read-only operations (default)
- write: File modifications allowed
- auto: Full autonomous operations (codex only)`,
inputSchema: {
type: 'object',
properties: {
tool: {
type: 'string',
enum: ['gemini', 'qwen', 'codex'],
description: 'CLI tool to execute'
},
prompt: {
type: 'string',
description: 'Prompt to send to the CLI tool'
},
mode: {
type: 'string',
enum: ['analysis', 'write', 'auto'],
description: 'Execution mode (default: analysis)',
default: 'analysis'
},
model: {
type: 'string',
description: 'Model override (tool-specific)'
},
cd: {
type: 'string',
description: 'Working directory for execution (-C for codex)'
},
includeDirs: {
type: 'string',
description: 'Additional directories (comma-separated). Maps to --include-directories for gemini/qwen, --add-dir for codex'
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds (default: 300000 = 5 minutes)',
default: 300000
}
},
required: ['tool', 'prompt']
}
};
// Handler function
export async function handler(params: Record<string, unknown>): Promise<ToolResult<ExecutionOutput>> {
try {
const result = await executeCliTool(params);
return {
success: result.success,
result
};
} catch (error) {
return {
success: false,
error: `CLI execution failed: ${(error as Error).message}`
};
}
}
/**
* Find all CLI history directories in a directory tree (max depth 3)
*/
function findCliHistoryDirs(baseDir: string, maxDepth: number = 3): string[] {
const historyDirs: string[] = [];
const ignoreDirs = new Set(['node_modules', '.git', 'dist', 'build', '.next', '__pycache__', 'venv', '.venv']);
function scanDir(dir: string, depth: number) {
if (depth > maxDepth) return;
// Check if this directory has CLI history (SQLite database)
const historyDir = join(dir, '.workflow', '.cli-history');
if (existsSync(join(historyDir, 'history.db'))) {
historyDirs.push(historyDir);
}
// Scan subdirectories
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && !entry.name.startsWith('.') && !ignoreDirs.has(entry.name)) {
scanDir(join(dir, entry.name), depth + 1);
}
}
} catch {
// Ignore permission errors
}
}
scanDir(baseDir, 0);
return historyDirs;
}
/**
* Get execution history from SQLite
*/
export async function getExecutionHistoryAsync(baseDir: string, options: {
limit?: number;
tool?: string | null;
status?: string | null;
category?: ExecutionCategory | null;
search?: string | null;
recursive?: boolean;
} = {}): Promise<{
total: number;
count: number;
executions: (HistoryIndex['executions'][0] & { sourceDir?: string })[];
}> {
const { limit = 50, tool = null, status = null, category = null, search = null, recursive = false } = options;
if (recursive) {
// For recursive, we need to check multiple directories
const historyDirs = findCliHistoryDirs(baseDir);
let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = [];
let totalCount = 0;
for (const historyDir of historyDirs) {
const dirBase = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
const store = await getSqliteStore(dirBase);
const result = store.getHistory({ limit: 100, tool, status, category, search });
totalCount += result.total;
const relativeSource = relative(baseDir, dirBase) || '.';
for (const exec of result.executions) {
allExecutions.push({ ...exec, sourceDir: relativeSource });
}
}
// Sort by timestamp (newest first)
allExecutions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
return {
total: totalCount,
count: Math.min(allExecutions.length, limit),
executions: allExecutions.slice(0, limit)
};
}
const store = await getSqliteStore(baseDir);
return store.getHistory({ limit, tool, status, category, search });
}
/**
* Get execution history (sync version - uses cached SQLite module)
*/
export function getExecutionHistory(baseDir: string, options: {
limit?: number;
tool?: string | null;
status?: string | null;
recursive?: boolean;
} = {}): {
total: number;
count: number;
executions: (HistoryIndex['executions'][0] & { sourceDir?: string })[];
} {
const { limit = 50, tool = null, status = null, recursive = false } = options;
try {
if (recursive) {
const historyDirs = findCliHistoryDirs(baseDir);
let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = [];
let totalCount = 0;
for (const historyDir of historyDirs) {
const dirBase = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
const store = getSqliteStoreSync(dirBase);
const result = store.getHistory({ limit: 100, tool, status });
totalCount += result.total;
const relativeSource = relative(baseDir, dirBase) || '.';
for (const exec of result.executions) {
allExecutions.push({ ...exec, sourceDir: relativeSource });
}
}
allExecutions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
return {
total: totalCount,
count: Math.min(allExecutions.length, limit),
executions: allExecutions.slice(0, limit)
};
}
const store = getSqliteStoreSync(baseDir);
return store.getHistory({ limit, tool, status });
} catch {
// SQLite not initialized, return empty
return { total: 0, count: 0, executions: [] };
}
}
/**
* Get conversation detail by ID (returns ConversationRecord)
*/
export function getConversationDetail(baseDir: string, conversationId: string): ConversationRecord | null {
const historyDir = join(baseDir, '.workflow', '.cli-history');
return loadConversation(historyDir, conversationId);
}
/**
* Get execution detail by ID (legacy, returns ExecutionRecord for backward compatibility)
*/
export function getExecutionDetail(baseDir: string, executionId: string): ExecutionRecord | null {
const conversation = getConversationDetail(baseDir, executionId);
if (!conversation) return null;
// Convert to legacy ExecutionRecord format (using latest turn)
const latestTurn = conversation.turns[conversation.turns.length - 1];
return {
id: conversation.id,
timestamp: conversation.created_at,
tool: conversation.tool,
model: conversation.model,
mode: conversation.mode,
prompt: latestTurn.prompt,
status: conversation.latest_status,
exit_code: latestTurn.exit_code,
duration_ms: conversation.total_duration_ms,
output: latestTurn.output
};
}
/**
* Delete execution by ID (async version)
*/
export async function deleteExecutionAsync(baseDir: string, executionId: string): Promise<{ success: boolean; error?: string }> {
const store = await getSqliteStore(baseDir);
return store.deleteConversation(executionId);
}
/**
* Delete execution by ID (sync version - uses cached SQLite module)
*/
export function deleteExecution(baseDir: string, executionId: string): { success: boolean; error?: string } {
try {
const store = getSqliteStoreSync(baseDir);
return store.deleteConversation(executionId);
} catch {
return { success: false, error: 'SQLite store not initialized' };
}
}
/**
* Batch delete executions (async)
*/
export async function batchDeleteExecutionsAsync(baseDir: string, ids: string[]): Promise<{
success: boolean;
deleted: number;
total: number;
errors?: string[];
}> {
const store = await getSqliteStore(baseDir);
const result = store.batchDelete(ids);
return { ...result, total: ids.length };
}
/**
* Get status of all CLI tools
*/
export async function getCliToolsStatus(): Promise<Record<string, ToolAvailability>> {
const tools = ['gemini', 'qwen', 'codex', 'claude'];
const results: Record<string, ToolAvailability> = {};
await Promise.all(tools.map(async (tool) => {
results[tool] = await checkToolAvailability(tool);
}));
return results;
}
// ========== Prompt Concatenation System ==========
/**
* Turn data structure for concatenation
*/
interface TurnData {
turn: number;
timestamp?: string;
role: 'user' | 'assistant';
content: string;
status?: string;
duration_ms?: number;
source_id?: string; // For merged conversations
}
/**
* Prompt concatenation options
*/
interface ConcatOptions {
format: PromptFormat;
includeMetadata?: boolean;
includeTurnMarkers?: boolean;
maxOutputLength?: number; // Truncate output for context efficiency
}
/**
* PromptConcatenator - Dedicated class for building multi-turn prompts
* Supports multiple output formats: plain text, YAML, JSON
*/
class PromptConcatenator {
private turns: TurnData[] = [];
private options: ConcatOptions;
private metadata: Record<string, unknown> = {};
constructor(options: Partial<ConcatOptions> = {}) {
this.options = {
format: options.format || 'plain',
includeMetadata: options.includeMetadata ?? true,
includeTurnMarkers: options.includeTurnMarkers ?? true,
maxOutputLength: options.maxOutputLength || 8192
};
}
/**
* Set metadata for the conversation
*/
setMetadata(key: string, value: unknown): this {
this.metadata[key] = value;
return this;
}
/**
* Add a user turn
*/
addUserTurn(content: string, options: Partial<Omit<TurnData, 'role' | 'content'>> = {}): this {
this.turns.push({
turn: this.turns.length + 1,
role: 'user',
content,
...options
});
return this;
}
/**
* Add an assistant turn
*/
addAssistantTurn(content: string, options: Partial<Omit<TurnData, 'role' | 'content'>> = {}): this {
// Truncate output if needed
const truncatedContent = content.length > this.options.maxOutputLength!
? content.substring(0, this.options.maxOutputLength!) + '\n... [truncated]'
: content;
this.turns.push({
turn: this.turns.length + 1,
role: 'assistant',
content: truncatedContent,
...options
});
return this;
}
/**
* Add a conversation turn from ConversationTurn
*/
addFromConversationTurn(turn: ConversationTurn, sourceId?: string): this {
this.addUserTurn(turn.prompt, {
turn: turn.turn * 2 - 1,
timestamp: turn.timestamp,
source_id: sourceId
});
this.addAssistantTurn(turn.output.stdout || '[No output]', {
turn: turn.turn * 2,
timestamp: turn.timestamp,
status: turn.status,
duration_ms: turn.duration_ms,
source_id: sourceId
});
return this;
}
/**
* Load turns from an existing conversation
*/
loadConversation(conversation: ConversationRecord): this {
for (const turn of conversation.turns) {
this.addFromConversationTurn(turn);
}
return this;
}
/**
* Build the final prompt in plain text format
*/
private buildPlainText(newPrompt: string): string {
const parts: string[] = [];
// Metadata section
if (this.options.includeMetadata && Object.keys(this.metadata).length > 0) {
parts.push('=== CONTEXT ===');
for (const [key, value] of Object.entries(this.metadata)) {
parts.push(`${key}: ${String(value)}`);
}
parts.push('');
}
// Conversation history
if (this.turns.length > 0) {
parts.push('=== CONVERSATION HISTORY ===');
parts.push('');
let currentTurn = 0;
for (let i = 0; i < this.turns.length; i += 2) {
currentTurn++;
const userTurn = this.turns[i];
const assistantTurn = this.turns[i + 1];
if (this.options.includeTurnMarkers) {
const sourceMarker = userTurn.source_id ? ` [${userTurn.source_id}]` : '';
parts.push(`--- Turn ${currentTurn}${sourceMarker} ---`);
}
parts.push('USER:');
parts.push(userTurn.content);
parts.push('');
if (assistantTurn) {
parts.push('ASSISTANT:');
parts.push(assistantTurn.content);
parts.push('');
}
}
}
// New request
parts.push('=== NEW REQUEST ===');
parts.push('');
parts.push(newPrompt);
return parts.join('\n');
}
/**
* Build the final prompt in YAML format
*/
private buildYaml(newPrompt: string): string {
const yamlLines: string[] = [];
// Metadata
if (this.options.includeMetadata && Object.keys(this.metadata).length > 0) {
yamlLines.push('context:');
for (const [key, value] of Object.entries(this.metadata)) {
yamlLines.push(` ${key}: ${this.yamlValue(value)}`);
}
yamlLines.push('');
}
// Conversation history
if (this.turns.length > 0) {
yamlLines.push('conversation:');
let currentTurn = 0;
for (let i = 0; i < this.turns.length; i += 2) {
currentTurn++;
const userTurn = this.turns[i];
const assistantTurn = this.turns[i + 1];
yamlLines.push(` - turn: ${currentTurn}`);
if (userTurn.source_id) {
yamlLines.push(` source: ${userTurn.source_id}`);
}
if (userTurn.timestamp) {
yamlLines.push(` timestamp: ${userTurn.timestamp}`);
}
// User message
yamlLines.push(' user: |');
const userLines = userTurn.content.split('\n');
for (const line of userLines) {
yamlLines.push(` ${line}`);
}
// Assistant message
if (assistantTurn) {
if (assistantTurn.status) {
yamlLines.push(` status: ${assistantTurn.status}`);
}
if (assistantTurn.duration_ms) {
yamlLines.push(` duration_ms: ${assistantTurn.duration_ms}`);
}
yamlLines.push(' assistant: |');
const assistantLines = assistantTurn.content.split('\n');
for (const line of assistantLines) {
yamlLines.push(` ${line}`);
}
}
yamlLines.push('');
}
}
// New request
yamlLines.push('new_request: |');
const requestLines = newPrompt.split('\n');
for (const line of requestLines) {
yamlLines.push(` ${line}`);
}
return yamlLines.join('\n');
}
/**
* Build the final prompt in JSON format
*/
private buildJson(newPrompt: string): string {
const data: Record<string, unknown> = {};
// Metadata
if (this.options.includeMetadata && Object.keys(this.metadata).length > 0) {
data.context = this.metadata;
}
// Conversation history
if (this.turns.length > 0) {
const conversation: Array<{
turn: number;
source?: string;
timestamp?: string;
user: string;
assistant?: string;
status?: string;
duration_ms?: number;
}> = [];
for (let i = 0; i < this.turns.length; i += 2) {
const userTurn = this.turns[i];
const assistantTurn = this.turns[i + 1];
const turnData: typeof conversation[0] = {
turn: Math.ceil((i + 1) / 2),
user: userTurn.content
};
if (userTurn.source_id) turnData.source = userTurn.source_id;
if (userTurn.timestamp) turnData.timestamp = userTurn.timestamp;
if (assistantTurn) {
turnData.assistant = assistantTurn.content;
if (assistantTurn.status) turnData.status = assistantTurn.status;
if (assistantTurn.duration_ms) turnData.duration_ms = assistantTurn.duration_ms;
}
conversation.push(turnData);
}
data.conversation = conversation;
}
data.new_request = newPrompt;
return JSON.stringify(data, null, 2);
}
/**
* Helper to format YAML values
*/
private yamlValue(value: unknown): string {
if (typeof value === 'string') {
// Quote strings that might be interpreted as other types
if (/[:\[\]{}#&*!|>'"@`]/.test(value) || value === '') {
return `"${value.replace(/"/g, '\\"')}"`;
}
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
if (value === null || value === undefined) {
return 'null';
}
return JSON.stringify(value);
}
/**
* Build the final prompt string
*/
build(newPrompt: string): string {
switch (this.options.format) {
case 'yaml':
return this.buildYaml(newPrompt);
case 'json':
return this.buildJson(newPrompt);
case 'plain':
default:
return this.buildPlainText(newPrompt);
}
}
/**
* Reset the concatenator for reuse
*/
reset(): this {
this.turns = [];
this.metadata = {};
return this;
}
}
/**
* Create a prompt concatenator with specified options
*/
function createPromptConcatenator(options?: Partial<ConcatOptions>): PromptConcatenator {
return new PromptConcatenator(options);
}
/**
* Quick helper to build a multi-turn prompt in any format
*/
function buildPrompt(
conversation: ConversationRecord,
newPrompt: string,
format: PromptFormat = 'plain'
): string {
return createPromptConcatenator({ format })
.loadConversation(conversation)
.build(newPrompt);
}
/**
* Build multi-turn prompt with full conversation history
* Uses the PromptConcatenator with plain text format by default
*/
function buildMultiTurnPrompt(
conversation: ConversationRecord,
newPrompt: string,
format: PromptFormat = 'plain'
): string {
return buildPrompt(conversation, newPrompt, format);
}
/**
* Build continuation prompt with previous conversation context (legacy)
*/
function buildContinuationPrompt(previous: ExecutionRecord, additionalPrompt?: string): string {
const parts: string[] = [];
// Add previous conversation context
parts.push('=== PREVIOUS CONVERSATION ===');
parts.push('');
parts.push('USER PROMPT:');
parts.push(previous.prompt);
parts.push('');
parts.push('ASSISTANT RESPONSE:');
parts.push(previous.output.stdout || '[No output recorded]');
parts.push('');
parts.push('=== CONTINUATION ===');
parts.push('');
if (additionalPrompt) {
parts.push(additionalPrompt);
} else {
parts.push('Continue from where we left off. What should we do next?');
}
return parts.join('\n');
}
/**
* Get previous execution for resume
* @param baseDir - Working directory
* @param tool - Tool to filter by
* @param resume - true for last, or execution ID string
*/
function getPreviousExecution(baseDir: string, tool: string, resume: boolean | string): ExecutionRecord | null {
if (typeof resume === 'string') {
// Resume specific execution by ID
return getExecutionDetail(baseDir, resume);
} else if (resume === true) {
// Resume last execution for this tool
const history = getExecutionHistory(baseDir, { limit: 1, tool });
if (history.executions.length === 0) {
return null;
}
return getExecutionDetail(baseDir, history.executions[0].id);
}
return null;
}
/**
* Get latest execution for a specific tool
*/
export function getLatestExecution(baseDir: string, tool?: string): ExecutionRecord | null {
const history = getExecutionHistory(baseDir, { limit: 1, tool: tool || null });
if (history.executions.length === 0) {
return null;
}
return getExecutionDetail(baseDir, history.executions[0].id);
}
// ========== Native Session Content Functions ==========
/**
* Get native session content by CCW ID
* Parses the native session file and returns full conversation data
*/
export async function getNativeSessionContent(baseDir: string, ccwId: string) {
const store = await getSqliteStore(baseDir);
return store.getNativeSessionContent(ccwId);
}
/**
* Get formatted native conversation text
*/
export async function getFormattedNativeConversation(baseDir: string, ccwId: string, options?: {
includeThoughts?: boolean;
includeToolCalls?: boolean;
includeTokens?: boolean;
maxContentLength?: number;
}) {
const store = await getSqliteStore(baseDir);
return store.getFormattedNativeConversation(ccwId, options);
}
/**
* Get conversation pairs from native session
*/
export async function getNativeConversationPairs(baseDir: string, ccwId: string) {
const store = await getSqliteStore(baseDir);
return store.getNativeConversationPairs(ccwId);
}
/**
* Get enriched conversation (CCW + native session merged)
*/
export async function getEnrichedConversation(baseDir: string, ccwId: string) {
const store = await getSqliteStore(baseDir);
return store.getEnrichedConversation(ccwId);
}
/**
* Get history with native session info
*/
export async function getHistoryWithNativeInfo(baseDir: string, options?: {
limit?: number;
offset?: number;
tool?: string | null;
status?: string | null;
category?: ExecutionCategory | null;
search?: string | null;
}) {
const store = await getSqliteStore(baseDir);
return store.getHistoryWithNativeInfo(options || {});
}
// Export types
export type { ConversationRecord, ConversationTurn, ExecutionRecord, PromptFormat, ConcatOptions };
// Export utility functions and tool definition for backward compatibility
export { executeCliTool, checkToolAvailability };
// Export prompt concatenation utilities
export { PromptConcatenator, createPromptConcatenator, buildPrompt, buildMultiTurnPrompt };
// Note: Async storage functions (getExecutionHistoryAsync, deleteExecutionAsync,
// batchDeleteExecutionsAsync) are exported at declaration site - SQLite storage only
// Export tool definition (for legacy imports) - This allows direct calls to execute with onOutput
export const cliExecutorTool = {
schema,
execute: executeCliTool // Use executeCliTool directly which supports onOutput callback
};