mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 10:19:37 +00:00
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:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user