refactor(delegation): modularize headless-executor into executor/ directory

- extract types, stream-parser, result-aggregator

- consolidate ExecutionResult to executor/types.ts (DRY)

- slim headless-executor.ts from 729 to 391 lines (46% reduction)

- add barrel exports at executor/index.ts and delegation/index.ts
This commit is contained in:
kaitranntt
2025-12-19 12:35:59 -05:00
parent 5e4fa200df
commit c3baaa8251
7 changed files with 480 additions and 464 deletions
+7
View File
@@ -0,0 +1,7 @@
/**
* Barrel export for executor module
*/
export * from './types';
export { StreamBuffer, formatToolVerbose } from './stream-parser';
export { buildExecutionResult, extractSessionInfo } from './result-aggregator';
@@ -0,0 +1,80 @@
/**
* Result aggregation utilities for headless executor
*/
import type { ExecutionResult, StreamMessage } from './types';
import { warn } from '../../utils/ui';
/**
* Build execution result from stream messages
* @param params - Parameters for building result
* @returns ExecutionResult with all fields populated
*/
export function buildExecutionResult(params: {
exitCode: number;
stdout: string;
stderr: string;
cwd: string;
profile: string;
duration: number;
timedOut: boolean;
messages: StreamMessage[];
}): ExecutionResult {
const { exitCode, stdout, stderr, cwd, profile, duration, timedOut, messages } = params;
const result: ExecutionResult = {
exitCode,
stdout,
stderr,
cwd,
profile,
duration,
timedOut,
success: exitCode === 0 && !timedOut,
messages,
};
// Extract metadata from final 'result' message in stream-json
const resultMessage = messages.find((m) => m.type === 'result');
if (resultMessage) {
result.sessionId = resultMessage.session_id || undefined;
result.totalCost = resultMessage.total_cost_usd || 0;
result.numTurns = resultMessage.num_turns || 0;
result.isError = resultMessage.is_error || false;
result.type = resultMessage.type || null;
result.subtype = resultMessage.subtype || undefined;
result.durationApi = resultMessage.duration_api_ms || 0;
result.permissionDenials = resultMessage.permission_denials || [];
result.errors = resultMessage.errors || [];
result.content = resultMessage.result || '';
} else {
// Fallback: no result message found (shouldn't happen)
result.content = stdout;
if (process.env.CCS_DEBUG) {
console.error(warn('No result message found in stream-json output'));
}
}
return result;
}
/**
* Extract session info from result for session management
* @param result - Execution result
* @returns Session info or null
*/
export function extractSessionInfo(result: ExecutionResult): {
sessionId: string;
totalCost?: number;
cwd: string;
} | null {
if (!result.sessionId) {
return null;
}
return {
sessionId: result.sessionId,
totalCost: result.totalCost,
cwd: result.cwd,
};
}
+151
View File
@@ -0,0 +1,151 @@
/**
* Stream parsing utilities for Claude CLI stream-json output
*/
import type { StreamMessage, ToolInput } from './types';
import { warn } from '../../utils/ui';
/**
* Buffer for incomplete JSON lines during streaming
*/
export class StreamBuffer {
private partialLine = '';
/**
* Process incoming data chunk and extract complete JSON messages
* @param dataStr - Raw data string from stream
* @returns Array of parsed StreamMessage objects
*/
parseChunk(dataStr: string): StreamMessage[] {
const messages: StreamMessage[] = [];
const chunk = this.partialLine + dataStr;
const lines = chunk.split('\n');
this.partialLine = lines.pop() || ''; // Save incomplete line for next chunk
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg: StreamMessage = JSON.parse(line);
messages.push(msg);
} catch (parseError) {
// Skip malformed JSON lines (shouldn't happen with stream-json)
if (process.env.CCS_DEBUG) {
console.error(warn(`Failed to parse stream-json line: ${(parseError as Error).message}`));
}
}
}
return messages;
}
/**
* Reset buffer state
*/
reset(): void {
this.partialLine = '';
}
}
/**
* Format tool use message for verbose logging
* @param toolName - Name of the tool
* @param toolInput - Tool input parameters
* @returns Formatted verbose message
*/
export function formatToolVerbose(toolName: string, toolInput: ToolInput): string {
let verboseMsg = `[Tool] ${toolName}`;
switch (toolName) {
case 'Bash':
if (toolInput.command) {
const command = toolInput.command as string;
const cmd = command.length > 80 ? command.substring(0, 77) + '...' : command;
verboseMsg += `: ${cmd}`;
}
break;
case 'Edit':
case 'Write':
case 'Read':
if (toolInput.file_path) {
verboseMsg += `: ${toolInput.file_path}`;
}
break;
case 'NotebookEdit':
case 'NotebookRead':
if (toolInput.notebook_path) {
verboseMsg += `: ${toolInput.notebook_path}`;
}
break;
case 'Grep':
if (toolInput.pattern) {
verboseMsg += `: searching for "${toolInput.pattern}"`;
if (toolInput.path) {
verboseMsg += ` in ${toolInput.path}`;
}
}
break;
case 'Glob':
if (toolInput.pattern) {
verboseMsg += `: ${toolInput.pattern}`;
}
break;
case 'SlashCommand':
if (toolInput.command) {
verboseMsg += `: ${toolInput.command}`;
}
break;
case 'Task':
if (toolInput.description) {
verboseMsg += `: ${toolInput.description}`;
} else if (toolInput.prompt) {
const promptText = toolInput.prompt as string;
const prompt = promptText.length > 60 ? promptText.substring(0, 57) + '...' : promptText;
verboseMsg += `: ${prompt}`;
}
break;
case 'TodoWrite':
if (toolInput.todos && Array.isArray(toolInput.todos)) {
const inProgressTask = toolInput.todos.find(
(t: { status: string; activeForm?: string }) => t.status === 'in_progress'
);
if (inProgressTask && inProgressTask.activeForm) {
verboseMsg += `: ${inProgressTask.activeForm}`;
} else {
verboseMsg += `: ${toolInput.todos.length} task(s)`;
}
}
break;
case 'WebFetch':
if (toolInput.url) {
verboseMsg += `: ${toolInput.url}`;
}
break;
case 'WebSearch':
if (toolInput.query) {
verboseMsg += `: "${toolInput.query}"`;
}
break;
default:
// For unknown tools, show first meaningful parameter
if (Object.keys(toolInput).length > 0) {
const firstKey = Object.keys(toolInput)[0];
const firstValue = toolInput[firstKey];
if (typeof firstValue === 'string' && firstValue.length < 60) {
verboseMsg += `: ${firstValue}`;
}
}
}
return verboseMsg;
}
+124
View File
@@ -0,0 +1,124 @@
/**
* Type definitions for headless executor
*/
/**
* Claude message from stream-json output
*/
export interface ClaudeMessage {
type: string;
content?: string;
thinking?: string;
tool_use?: {
id: string;
name: string;
input: Record<string, unknown>;
};
tool_result?: {
tool_use_id: string;
content: string;
};
}
/**
* Permission denial information
*/
export interface PermissionDenial {
tool_name?: string;
reason?: string;
tool_input?: {
command?: string;
description?: string;
[key: string]: unknown;
};
}
/**
* Execution error information
*/
export interface ExecutionError {
message?: string;
error?: string;
type?: string;
tool_name?: string;
[key: string]: unknown;
}
/**
* Options for headless execution
*/
export interface ExecutionOptions {
cwd?: string;
timeout?: number;
outputFormat?: string;
permissionMode?: string;
resumeSession?: boolean;
sessionId?: string;
maxRetries?: number;
extraArgs?: string[]; // Passthrough args for Claude CLI
}
/**
* Result of headless execution
*/
export interface ExecutionResult {
exitCode: number;
stdout: string;
stderr: string;
cwd: string;
profile: string;
duration: number;
timedOut: boolean;
success: boolean;
messages: StreamMessage[];
sessionId?: string;
totalCost?: number;
numTurns?: number;
isError?: boolean;
type?: string | null;
subtype?: string;
durationApi?: number;
permissionDenials?: PermissionDenial[];
errors?: ExecutionError[];
content?: string;
}
/**
* Stream message from Claude CLI stream-json output
*/
export interface StreamMessage {
type: string;
message?: {
content?: Array<{
type: string;
name?: string;
input?: Record<string, unknown>;
}>;
};
session_id?: string;
total_cost_usd?: number;
num_turns?: number;
is_error?: boolean;
result?: string;
duration_api_ms?: number;
permission_denials?: PermissionDenial[];
errors?: ExecutionError[];
subtype?: string;
}
/**
* Tool input types for verbose logging
*/
export interface ToolInput {
command?: string;
file_path?: string;
notebook_path?: string;
pattern?: string;
path?: string;
description?: string;
prompt?: string;
todos?: Array<{ status: string; activeForm?: string }>;
url?: string;
query?: string;
[key: string]: unknown;
}
+102 -431
View File
@@ -1,5 +1,10 @@
#!/usr/bin/env node #!/usr/bin/env node
/**
* Headless executor for Claude CLI delegation
* Spawns claude with -p flag for single-turn execution
*/
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import * as path from 'path'; import * as path from 'path';
import * as os from 'os'; import * as os from 'os';
@@ -7,92 +12,15 @@ import * as fs from 'fs';
import { SessionManager } from './session-manager'; import { SessionManager } from './session-manager';
import { SettingsParser } from './settings-parser'; import { SettingsParser } from './settings-parser';
import { ui, warn, info } from '../utils/ui'; import { ui, warn, info } from '../utils/ui';
import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from './executor/types';
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
import { buildExecutionResult } from './executor/result-aggregator';
// Type definitions for delegation responses // Re-export types for consumers
interface ClaudeMessage { export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
type: string;
content?: string;
thinking?: string;
tool_use?: {
id: string;
name: string;
input: Record<string, unknown>;
};
tool_result?: {
tool_use_id: string;
content: string;
};
}
interface PermissionDenial {
tool_name: string;
reason: string;
}
interface ExecutionError {
message?: string;
error?: string;
type?: string;
tool_name?: string;
[key: string]: unknown;
}
interface ExecutionOptions {
cwd?: string;
timeout?: number;
outputFormat?: string;
permissionMode?: string;
resumeSession?: boolean;
sessionId?: string;
maxRetries?: number;
extraArgs?: string[]; // Passthrough args for Claude CLI
}
interface ExecutionResult {
exitCode: number;
stdout: string;
stderr: string;
cwd: string;
profile: string;
duration: number;
timedOut: boolean;
success: boolean;
messages: ClaudeMessage[];
sessionId?: string;
totalCost?: number;
numTurns?: number;
isError?: boolean;
type?: string | null;
subtype?: string;
durationApi?: number;
permissionDenials?: PermissionDenial[];
errors?: ExecutionError[];
content?: string;
}
interface StreamMessage {
type: string;
message?: {
content?: Array<{
type: string;
name?: string;
input?: Record<string, unknown>;
}>;
};
session_id?: string;
total_cost_usd?: number;
num_turns?: number;
is_error?: boolean;
result?: string;
duration_api_ms?: number;
permission_denials?: PermissionDenial[];
errors?: ExecutionError[];
subtype?: string;
}
/** /**
* Headless executor for Claude CLI delegation * Headless executor for Claude CLI delegation
* Spawns claude with -p flag for single-turn execution
*/ */
export class HeadlessExecutor { export class HeadlessExecutor {
/** /**
@@ -141,26 +69,20 @@ export class HeadlessExecutor {
} }
// Smart slash command detection and preservation // Smart slash command detection and preservation
// Detects if prompt contains slash command and restructures for proper execution
const processedPrompt = this._processSlashCommand(enhancedPrompt); const processedPrompt = this._processSlashCommand(enhancedPrompt);
// Prepare arguments // Prepare arguments
const args: string[] = ['-p', processedPrompt, '--settings', settingsPath]; const args: string[] = ['-p', processedPrompt, '--settings', settingsPath];
// Always use stream-json for real-time progress visibility // Always use stream-json for real-time progress visibility
// Note: --verbose is required when using --print with stream-json
args.push('--output-format', 'stream-json', '--verbose'); args.push('--output-format', 'stream-json', '--verbose');
// Add permission mode // Add permission mode
if (permissionMode && permissionMode !== 'default') { if (permissionMode && permissionMode !== 'default') {
if (permissionMode === 'bypassPermissions') { if (permissionMode === 'bypassPermissions') {
args.push('--dangerously-skip-permissions'); args.push('--dangerously-skip-permissions');
// Warn about dangerous mode
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.warn(warn('WARNING: Using --dangerously-skip-permissions mode')); console.warn(warn('WARNING: Using --dangerously-skip-permissions mode'));
console.warn(
warn('This bypasses ALL permission checks. Use only in trusted environments.')
);
} }
} else { } else {
args.push('--permission-mode', permissionMode); args.push('--permission-mode', permissionMode);
@@ -170,56 +92,35 @@ export class HeadlessExecutor {
// Add resume flag for multi-turn sessions // Add resume flag for multi-turn sessions
if (resumeSession) { if (resumeSession) {
const lastSession = sessionMgr.getLastSession(profile); const lastSession = sessionMgr.getLastSession(profile);
if (lastSession) { if (lastSession) {
args.push('--resume', lastSession.sessionId); args.push('--resume', lastSession.sessionId);
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
const cost = const cost = lastSession.totalCost?.toFixed(4) || '0.0000';
lastSession.totalCost !== undefined && lastSession.totalCost !== null console.error(info(`Resuming session: ${lastSession.sessionId} ($${cost})`));
? lastSession.totalCost.toFixed(4)
: '0.0000';
console.error(
info(
`Resuming session: ${lastSession.sessionId} (${lastSession.turns} turns, $${cost})`
)
);
} }
} else if (sessionId) { } else if (sessionId) {
args.push('--resume', sessionId); args.push('--resume', sessionId);
if (process.env.CCS_DEBUG) {
console.error(info(`Resuming specific session: ${sessionId}`));
}
} else { } else {
console.warn(warn('No previous session found, starting new session')); console.warn(warn('No previous session found, starting new session'));
} }
} else if (sessionId) { } else if (sessionId) {
args.push('--resume', sessionId); args.push('--resume', sessionId);
if (process.env.CCS_DEBUG) {
console.error(info(`Resuming specific session: ${sessionId}`));
}
} }
// Add tool restrictions from settings // Add tool restrictions from settings
const toolRestrictions = SettingsParser.parseToolRestrictions(cwd); const toolRestrictions = SettingsParser.parseToolRestrictions(cwd);
if (toolRestrictions.allowedTools.length > 0) { if (toolRestrictions.allowedTools.length > 0) {
args.push('--allowedTools'); args.push('--allowedTools', ...toolRestrictions.allowedTools);
toolRestrictions.allowedTools.forEach((tool) => args.push(tool));
} }
if (toolRestrictions.disallowedTools.length > 0) { if (toolRestrictions.disallowedTools.length > 0) {
args.push('--disallowedTools'); args.push('--disallowedTools', ...toolRestrictions.disallowedTools);
toolRestrictions.disallowedTools.forEach((tool) => args.push(tool));
} }
// Note: No max-turns limit - using time-based limits instead (default 10min timeout) // Passthrough extra args
// Passthrough extra args (from Claude CLI flags like --agent, --system-prompt-file, etc.)
if (extraArgs.length > 0) { if (extraArgs.length > 0) {
args.push(...extraArgs); args.push(...extraArgs);
} }
// Debug log args
if (process.env.CCS_DEBUG) { if (process.env.CCS_DEBUG) {
console.error(info(`Claude CLI args: ${args.join(' ')}`)); console.error(info(`Claude CLI args: ${args.join(' ')}`));
} }
@@ -228,13 +129,38 @@ export class HeadlessExecutor {
await ui.init(); await ui.init();
// Execute with spawn // Execute with spawn
return this._spawnAndExecute(claudeCli, args, {
cwd,
profile,
timeout,
resumeSession,
sessionId,
sessionMgr,
});
}
/**
* Spawn Claude CLI and handle execution
*/
private static _spawnAndExecute(
claudeCli: string,
args: string[],
ctx: {
cwd: string;
profile: string;
timeout: number;
resumeSession: boolean;
sessionId: string | null;
sessionMgr: SessionManager;
}
): Promise<ExecutionResult> {
const { cwd, profile, timeout, resumeSession, sessionId, sessionMgr } = ctx;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const startTime = Date.now(); const startTime = Date.now();
// Show progress unless explicitly disabled with CCS_QUIET
const showProgress = !process.env.CCS_QUIET; const showProgress = !process.env.CCS_QUIET;
const streamBuffer = new StreamBuffer();
// Show initial progress message
if (showProgress) { if (showProgress) {
const modelName = const modelName =
profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase(); profile === 'glm' ? 'GLM-4.6' : profile === 'kimi' ? 'Kimi' : profile.toUpperCase();
@@ -250,40 +176,28 @@ export class HeadlessExecutor {
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';
let progressInterval: NodeJS.Timeout | undefined; let progressInterval: NodeJS.Timeout | undefined;
const messages: StreamMessage[] = []; // Accumulate stream-json messages const messages: StreamMessage[] = [];
let partialLine = ''; // Buffer for incomplete JSON lines let timedOut = false;
// Handle parent process termination (Ctrl+C or Esc in Claude) // Setup signal handlers for cleanup
// When main Claude session is killed, cleanup spawned child process
const cleanupHandler = () => { const cleanupHandler = () => {
if (!proc.killed) { if (!proc.killed) {
if (process.env.CCS_DEBUG) {
console.error(warn('Parent process terminating, killing delegated session...'));
}
proc.kill('SIGTERM'); proc.kill('SIGTERM');
// Force kill if not dead after 2s
setTimeout(() => { setTimeout(() => {
if (!proc.killed) { if (!proc.killed) proc.kill('SIGKILL');
proc.kill('SIGKILL');
}
}, 2000); }, 2000);
} }
}; };
// Register signal handlers for parent process termination
process.once('SIGINT', cleanupHandler); process.once('SIGINT', cleanupHandler);
process.once('SIGTERM', cleanupHandler); process.once('SIGTERM', cleanupHandler);
// Cleanup signal handlers when child process exits
const removeSignalHandlers = () => { const removeSignalHandlers = () => {
process.removeListener('SIGINT', cleanupHandler); process.removeListener('SIGINT', cleanupHandler);
process.removeListener('SIGTERM', cleanupHandler); process.removeListener('SIGTERM', cleanupHandler);
}; };
proc.on('close', removeSignalHandlers); proc.on('close', removeSignalHandlers);
proc.on('error', removeSignalHandlers); proc.on('error', removeSignalHandlers);
// Progress indicator (show elapsed time every 5 seconds) // Progress indicator
if (showProgress) { if (showProgress) {
progressInterval = setInterval(() => { progressInterval = setInterval(() => {
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -291,155 +205,34 @@ export class HeadlessExecutor {
}, 5000); }, 5000);
} }
// Capture stdout (stream-json format - jsonl) // Capture stdout (stream-json format)
proc.stdout?.on('data', (data: Buffer) => { proc.stdout?.on('data', (data: Buffer) => {
const dataStr = data.toString(); const dataStr = data.toString();
stdout += dataStr; stdout += dataStr;
// Parse stream-json messages (jsonl format - one JSON per line) const parsedMessages = streamBuffer.parseChunk(dataStr);
const chunk = partialLine + dataStr; for (const msg of parsedMessages) {
const lines = chunk.split('\n'); messages.push(msg);
partialLine = lines.pop() || ''; // Save incomplete line for next chunk
for (const line of lines) { // Show real-time tool use
if (!line.trim()) continue; if (showProgress && msg.type === 'assistant') {
const toolUses = msg.message?.content?.filter((c) => c.type === 'tool_use') || [];
try { for (const tool of toolUses) {
const msg: StreamMessage = JSON.parse(line); process.stderr.write('\r\x1b[K');
messages.push(msg); const toolInput = tool.input || {};
const verboseMsg = formatToolVerbose(tool.name || 'Unknown', toolInput);
// Show real-time tool use with verbose details process.stderr.write(`${verboseMsg}\n`);
if (showProgress && msg.type === 'assistant') {
const toolUses = msg.message?.content?.filter((c) => c.type === 'tool_use') || [];
for (const tool of toolUses) {
process.stderr.write('\r\x1b[K'); // Clear line
// Show verbose tool use with description/input if available
const toolInput = tool.input || {};
let verboseMsg = `[Tool] ${tool.name}`;
// Add context based on tool type (all Claude Code tools)
switch (tool.name) {
case 'Bash':
if (toolInput.command) {
// Truncate long commands
const command = toolInput.command as string;
const cmd = command.length > 80 ? command.substring(0, 77) + '...' : command;
verboseMsg += `: ${cmd}`;
}
break;
case 'Edit':
case 'Write':
case 'Read':
if (toolInput.file_path) {
verboseMsg += `: ${toolInput.file_path}`;
}
break;
case 'NotebookEdit':
case 'NotebookRead':
if (toolInput.notebook_path) {
verboseMsg += `: ${toolInput.notebook_path}`;
}
break;
case 'Grep':
if (toolInput.pattern) {
verboseMsg += `: searching for "${toolInput.pattern}"`;
if (toolInput.path) {
verboseMsg += ` in ${toolInput.path}`;
}
}
break;
case 'Glob':
if (toolInput.pattern) {
verboseMsg += `: ${toolInput.pattern}`;
}
break;
case 'SlashCommand':
if (toolInput.command) {
verboseMsg += `: ${toolInput.command}`;
}
break;
case 'Task':
if (toolInput.description) {
verboseMsg += `: ${toolInput.description}`;
} else if (toolInput.prompt) {
const promptText = toolInput.prompt as string;
const prompt =
promptText.length > 60 ? promptText.substring(0, 57) + '...' : promptText;
verboseMsg += `: ${prompt}`;
}
break;
case 'TodoWrite':
if (toolInput.todos && Array.isArray(toolInput.todos)) {
// Show in_progress task instead of just count
const inProgressTask = toolInput.todos.find(
(t: { status: string; activeForm?: string }) => t.status === 'in_progress'
);
if (inProgressTask && inProgressTask.activeForm) {
verboseMsg += `: ${inProgressTask.activeForm}`;
} else {
// Fallback to count if no in_progress task
verboseMsg += `: ${toolInput.todos.length} task(s)`;
}
}
break;
case 'WebFetch':
if (toolInput.url) {
verboseMsg += `: ${toolInput.url}`;
}
break;
case 'WebSearch':
if (toolInput.query) {
verboseMsg += `: "${toolInput.query}"`;
}
break;
default:
// For unknown tools, show first meaningful parameter
if (Object.keys(toolInput).length > 0) {
const firstKey = Object.keys(toolInput)[0];
const firstValue = toolInput[firstKey];
if (typeof firstValue === 'string' && firstValue.length < 60) {
verboseMsg += `: ${firstValue}`;
}
}
}
process.stderr.write(`${verboseMsg}\n`);
}
}
} catch (parseError) {
// Skip malformed JSON lines (shouldn't happen with stream-json)
if (process.env.CCS_DEBUG) {
console.error(
warn(`Failed to parse stream-json line: ${(parseError as Error).message}`)
);
} }
} }
} }
}); });
// Stream stderr in real-time (progress messages from Claude CLI) // Stream stderr in real-time
proc.stderr?.on('data', (data: Buffer) => { proc.stderr?.on('data', (data: Buffer) => {
const stderrText = data.toString(); const stderrText = data.toString();
stderr += stderrText; stderr += stderrText;
// Show stderr in real-time if in TTY
if (showProgress) { if (showProgress) {
// Clear progress line before showing stderr if (progressInterval) process.stderr.write('\r\x1b[K');
if (progressInterval) {
process.stderr.write('\r\x1b[K'); // Clear line
}
process.stderr.write(stderrText); process.stderr.write(stderrText);
} }
}); });
@@ -448,80 +241,44 @@ export class HeadlessExecutor {
proc.on('close', (exitCode: number | null) => { proc.on('close', (exitCode: number | null) => {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
// Clear progress indicator
if (progressInterval) { if (progressInterval) {
clearInterval(progressInterval); clearInterval(progressInterval);
process.stderr.write('\r\x1b[K'); // Clear line process.stderr.write('\r\x1b[K');
} }
// Show completion message
if (showProgress) { if (showProgress) {
const durationSec = (duration / 1000).toFixed(1); const durationSec = (duration / 1000).toFixed(1);
if (timedOut) { console.error(
console.error(ui.warn(`Execution timed out after ${durationSec}s`)); timedOut
} else { ? ui.warn(`Timed out after ${durationSec}s`)
console.error(ui.info(`Execution completed in ${durationSec}s`)); : ui.info(`Completed in ${durationSec}s`)
} );
console.error(''); // Blank line before formatted output console.error('');
} }
const result: ExecutionResult = { const result = buildExecutionResult({
exitCode: exitCode || 0, exitCode: exitCode || 0,
stdout, stdout,
stderr, stderr,
cwd, cwd,
profile, profile,
duration, duration,
timedOut: false, timedOut,
success: exitCode === 0 && !timedOut, messages,
messages, // Include all stream-json messages });
};
// Extract metadata from final 'result' message in stream-json // Store session
const resultMessage = messages.find((m) => m.type === 'result');
if (resultMessage) {
// Add parsed fields from result message
result.sessionId = resultMessage.session_id || undefined;
result.totalCost = resultMessage.total_cost_usd || 0;
result.numTurns = resultMessage.num_turns || 0;
result.isError = resultMessage.is_error || false;
result.type = resultMessage.type || null;
result.subtype = resultMessage.subtype || undefined;
result.durationApi = resultMessage.duration_api_ms || 0;
result.permissionDenials = resultMessage.permission_denials || [];
result.errors = resultMessage.errors || [];
// Extract content from result message
result.content = resultMessage.result || '';
} else {
// Fallback: no result message found (shouldn't happen)
result.content = stdout;
if (process.env.CCS_DEBUG) {
console.error(warn('No result message found in stream-json output'));
}
}
// Store or update session if we have session ID (even on timeout, for :continue support)
if (result.sessionId) { if (result.sessionId) {
if (resumeSession || sessionId) { if (resumeSession || sessionId) {
// Update existing session sessionMgr.updateSession(profile, result.sessionId, { totalCost: result.totalCost });
sessionMgr.updateSession(profile, result.sessionId, {
totalCost: result.totalCost,
});
} else { } else {
// Store new session
sessionMgr.storeSession(profile, { sessionMgr.storeSession(profile, {
sessionId: result.sessionId, sessionId: result.sessionId,
totalCost: result.totalCost, totalCost: result.totalCost,
cwd: result.cwd, cwd,
}); });
} }
if (Math.random() < 0.1) sessionMgr.cleanupExpired();
// Cleanup expired sessions periodically
if (Math.random() < 0.1) {
// 10% chance
sessionMgr.cleanupExpired();
}
} }
resolve(result); resolve(result);
@@ -529,57 +286,31 @@ export class HeadlessExecutor {
// Handle errors // Handle errors
proc.on('error', (error: Error) => { proc.on('error', (error: Error) => {
if (progressInterval) { if (progressInterval) clearInterval(progressInterval);
clearInterval(progressInterval);
}
reject(new Error(`Failed to execute Claude CLI: ${error.message}`)); reject(new Error(`Failed to execute Claude CLI: ${error.message}`));
}); });
// Handle timeout with graceful SIGTERM then forceful SIGKILL // Handle timeout
let timedOut = false;
if (timeout > 0) { if (timeout > 0) {
const timeoutHandle = setTimeout(() => { const timeoutHandle = setTimeout(() => {
if (!proc.killed) { if (!proc.killed) {
timedOut = true; timedOut = true;
if (progressInterval) { if (progressInterval) {
clearInterval(progressInterval); clearInterval(progressInterval);
process.stderr.write('\r\x1b[K'); // Clear line process.stderr.write('\r\x1b[K');
} }
if (process.env.CCS_DEBUG) {
console.error(
warn(`Timeout reached after ${timeout}ms, sending SIGTERM for graceful shutdown...`)
);
}
// Send SIGTERM for graceful shutdown
proc.kill('SIGTERM'); proc.kill('SIGTERM');
// If process doesn't terminate within 10s, force kill
setTimeout(() => { setTimeout(() => {
if (!proc.killed) { if (!proc.killed) proc.kill('SIGKILL');
if (process.env.CCS_DEBUG) { }, 10000);
console.error(warn('Process did not terminate gracefully, sending SIGKILL...'));
}
proc.kill('SIGKILL');
}
}, 10000); // Give 10s for graceful shutdown instead of 5s
} }
}, timeout); }, timeout);
// Clear timeout on successful completion
proc.on('close', () => clearTimeout(timeoutHandle)); proc.on('close', () => clearTimeout(timeoutHandle));
} }
}); });
} }
/** /** Validate permission mode */
* Validate permission mode
* @param mode - Permission mode
* @throws {Error} If mode is invalid
* @private
*/
private static _validatePermissionMode(mode: string): void { private static _validatePermissionMode(mode: string): void {
const VALID_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions']; const VALID_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'];
if (!VALID_MODES.includes(mode)) { if (!VALID_MODES.includes(mode)) {
@@ -587,34 +318,18 @@ export class HeadlessExecutor {
} }
} }
/** /** Detect Claude CLI executable */
* Detect Claude CLI executable
* @returns Path to claude CLI or null if not found
* @private
*/
private static _detectClaudeCli(): string | null { private static _detectClaudeCli(): string | null {
// Check environment variable override if (process.env.CCS_CLAUDE_PATH) return process.env.CCS_CLAUDE_PATH;
if (process.env.CCS_CLAUDE_PATH) {
return process.env.CCS_CLAUDE_PATH;
}
// Try to find in PATH
const { execSync } = require('child_process'); const { execSync } = require('child_process');
try { try {
const result = execSync('command -v claude', { encoding: 'utf8' }); return execSync('command -v claude', { encoding: 'utf8' }).trim();
return result.trim(); } catch {
} catch (_error) {
return null; return null;
} }
} }
/** /** Execute with retry logic */
* Execute with retry logic
* @param profile - Profile name
* @param enhancedPrompt - Enhanced prompt
* @param options - Execution options
* @returns execution result
*/
static async executeWithRetry( static async executeWithRetry(
profile: string, profile: string,
enhancedPrompt: string, enhancedPrompt: string,
@@ -626,103 +341,59 @@ export class HeadlessExecutor {
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
try { try {
const result = await this.execute(profile, enhancedPrompt, execOptions); const result = await this.execute(profile, enhancedPrompt, execOptions);
if (result.success) return result;
// If successful, return immediately
if (result.success) {
return result;
}
// If not last attempt, retry
if (attempt < maxRetries) { if (attempt < maxRetries) {
console.error(warn(`Attempt ${attempt + 1} failed, retrying...`)); console.error(warn(`Attempt ${attempt + 1} failed, retrying...`));
await this._sleep(1000 * (attempt + 1)); // Exponential backoff await this._sleep(1000 * (attempt + 1));
continue; continue;
} }
// Last attempt failed, return result anyway
return result; return result;
} catch (error) { } catch (error) {
lastError = error as Error; lastError = error as Error;
if (attempt < maxRetries) { if (attempt < maxRetries) {
console.error( console.error(warn(`Attempt ${attempt + 1} errored, retrying...`));
warn(`Attempt ${attempt + 1} errored: ${(error as Error).message}, retrying...`)
);
await this._sleep(1000 * (attempt + 1)); await this._sleep(1000 * (attempt + 1));
} }
} }
} }
// All retries exhausted
throw lastError || new Error('Execution failed after all retry attempts'); throw lastError || new Error('Execution failed after all retry attempts');
} }
/** /** Sleep utility for retry backoff */
* Sleep utility for retry backoff
* @param ms - Milliseconds to sleep
* @returns Promise<void>
* @private
*/
private static _sleep(ms: number): Promise<void> { private static _sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms));
} }
/** /** Process prompt to detect and preserve slash commands */
* Process prompt to detect and preserve slash commands
* Implements smart enhancement: preserves slash command at start, allows context in rest
* @param prompt - Original prompt (may contain slash command)
* @returns Processed prompt with slash command preserved
* @private
*/
private static _processSlashCommand(prompt: string): string { private static _processSlashCommand(prompt: string): string {
const trimmed = prompt.trim(); const trimmed = prompt.trim();
// Case 1: Already starts with slash command - keep as-is // Case 1: Already starts with slash command
if (trimmed.match(/^\/[\w:-]+(\s|$)/)) { if (trimmed.match(/^\/[\w:-]+(\s|$)/)) return prompt;
return prompt;
}
// Case 2: Find slash command embedded in text // Case 2: Find slash command embedded in text
// Look for /command that's NOT part of a file path
// File paths: /home/user, /path/to/file (have / before or after)
// Commands: /cook, /plan (standalone, preceded by space/colon/start)
// Strategy: Find LAST occurrence that looks like a command, not a path
const embeddedSlash = trimmed.match(/(?:^|[^\w/])(\/[\w:-]+)(\s+[\s\S]*)?$/); const embeddedSlash = trimmed.match(/(?:^|[^\w/])(\/[\w:-]+)(\s+[\s\S]*)?$/);
if (embeddedSlash) { if (embeddedSlash) {
const command = embeddedSlash[1]; // e.g., "/cook" const command = embeddedSlash[1];
const args = (embeddedSlash[2] || '').trim(); // Everything after command const args = (embeddedSlash[2] || '').trim();
// Calculate where the command starts (excluding preceding char if any)
const matchIndex = embeddedSlash.index || 0; const matchIndex = embeddedSlash.index || 0;
const matchStart = matchIndex + (embeddedSlash[0][0] === '/' ? 0 : 1); const matchStart = matchIndex + (embeddedSlash[0][0] === '/' ? 0 : 1);
const beforeCommand = trimmed.substring(0, matchStart).trim(); const beforeCommand = trimmed.substring(0, matchStart).trim();
// Restructure: command first, context after if (beforeCommand && args) return `${command} ${args}\n\nContext: ${beforeCommand}`;
if (beforeCommand && args) { if (beforeCommand) return `${command}\n\nContext: ${beforeCommand}`;
return `${command} ${args}\n\nContext: ${beforeCommand}`;
} else if (beforeCommand) {
return `${command}\n\nContext: ${beforeCommand}`;
}
return args ? `${command} ${args}` : command; return args ? `${command} ${args}` : command;
} }
// No slash command detected, return as-is
return prompt; return prompt;
} }
/** /** Test if profile is executable */
* Test if profile is executable (quick health check)
* @param profile - Profile name
* @returns True if profile can execute
*/
static async testProfile(profile: string): Promise<boolean> { static async testProfile(profile: string): Promise<boolean> {
try { try {
const result = await this.execute(profile, 'Say "test successful"', { const result = await this.execute(profile, 'Say "test successful"', { timeout: 10000 });
timeout: 10000,
});
return result.success; return result.success;
} catch (_error) { } catch {
return false; return false;
} }
} }
+13
View File
@@ -0,0 +1,13 @@
/**
* Delegation module barrel export
*/
export { HeadlessExecutor } from './headless-executor';
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './headless-executor';
export { SessionManager } from './session-manager';
export { SettingsParser } from './settings-parser';
export { ResultFormatter } from './result-formatter';
export { DelegationHandler } from './delegation-handler';
// Re-export executor sub-module
export * from './executor';
+3 -33
View File
@@ -9,40 +9,10 @@ import * as path from 'path';
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import * as fs from 'fs'; import * as fs from 'fs';
import { ui } from '../utils/ui'; import { ui } from '../utils/ui';
import type { ExecutionResult, ExecutionError, PermissionDenial } from './executor/types';
interface ExecutionResult { // Alias for backward compatibility
profile: string; type ErrorInfo = ExecutionError;
cwd: string;
exitCode: number;
stdout: string;
stderr: string;
duration: number;
success: boolean;
content?: string;
sessionId?: string;
totalCost?: number;
numTurns?: number;
subtype?: string;
permissionDenials?: PermissionDenial[];
errors?: ErrorInfo[];
// json?: any; // Removed: unused parameter
timedOut?: boolean;
}
interface PermissionDenial {
tool_name?: string;
tool_input?: {
command?: string;
description?: string;
[key: string]: unknown;
};
}
interface ErrorInfo {
message?: string;
error?: string;
[key: string]: unknown;
}
interface FileChanges { interface FileChanges {
created: string[]; created: string[];