mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 22:16:41 +00:00
652 lines
22 KiB
JavaScript
652 lines
22 KiB
JavaScript
#!/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 * as path from 'path';
|
|
import { killWithEscalation } from '../utils/process-utils';
|
|
import * as fs from 'fs';
|
|
import { SessionManager } from './session-manager';
|
|
import { SettingsParser } from './settings-parser';
|
|
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';
|
|
import { getCcsDir, getModelDisplayName, loadSettings } from '../utils/config-manager';
|
|
import { getProfileLookupCandidates } from '../utils/profile-compat';
|
|
import { getClaudeLaunchEnvOverrides, stripClaudeCodeEnv } from '../utils/shell-executor';
|
|
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
|
import {
|
|
appendThirdPartyImageAnalysisToolArgs,
|
|
ensureImageAnalysisMcpOrThrow,
|
|
syncImageAnalysisMcpToConfigDir,
|
|
} from '../utils/image-analysis';
|
|
import {
|
|
applyImageAnalysisRuntimeOverrides,
|
|
getImageAnalysisHookEnv,
|
|
prepareImageAnalysisFallbackHook,
|
|
resolveImageAnalysisRuntimeConnection,
|
|
resolveImageAnalysisRuntimeStatus,
|
|
} from '../utils/hooks';
|
|
import {
|
|
ensureProfileHooks as ensureImageAnalyzerHooks,
|
|
removeImageAnalysisProfileHook,
|
|
} from '../utils/hooks/image-analyzer-profile-hook-injector';
|
|
import { resolveCliproxyBridgeMetadata } from '../api/services';
|
|
import { ensureCliproxyService } from '../cliproxy';
|
|
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
|
|
import {
|
|
appendThirdPartyWebSearchToolArgs,
|
|
appendWebSearchTrace,
|
|
createWebSearchTraceContext,
|
|
ensureWebSearchMcpOrThrow,
|
|
getWebSearchHookEnv,
|
|
readWebSearchTraceRecords,
|
|
syncWebSearchMcpToConfigDir,
|
|
} from '../utils/websearch-manager';
|
|
|
|
// Re-export types for consumers
|
|
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
|
|
|
|
/**
|
|
* Headless executor for Claude CLI delegation
|
|
*/
|
|
export class HeadlessExecutor {
|
|
/**
|
|
* Execute task via headless Claude CLI
|
|
* @param profile - Profile name (glm, km, custom)
|
|
* @param enhancedPrompt - Enhanced prompt with context
|
|
* @param options - Execution options
|
|
* @returns execution result
|
|
*/
|
|
static async execute(
|
|
profile: string,
|
|
enhancedPrompt: string,
|
|
options: ExecutionOptions = {}
|
|
): Promise<ExecutionResult> {
|
|
const {
|
|
cwd = process.cwd(),
|
|
timeout = 600000, // 10 minutes default
|
|
permissionMode = 'acceptEdits',
|
|
resumeSession = false,
|
|
sessionId = null,
|
|
maxTurns,
|
|
fallbackModel,
|
|
agents,
|
|
betas,
|
|
extraArgs = [],
|
|
} = options;
|
|
|
|
// Validate permission mode
|
|
this._validatePermissionMode(permissionMode);
|
|
|
|
// Initialize session manager
|
|
const sessionMgr = new SessionManager();
|
|
|
|
// Detect Claude CLI path
|
|
const claudeCli = this._detectClaudeCli();
|
|
if (!claudeCli) {
|
|
throw new Error(
|
|
'Claude CLI not found in PATH. Install from: https://docs.claude.com/en/docs/claude-code/installation'
|
|
);
|
|
}
|
|
|
|
// Get settings path for profile (supports compatibility aliases like km -> kimi)
|
|
const ccsDir = getCcsDir();
|
|
const settingsCandidates = getProfileLookupCandidates(profile).map((candidate) =>
|
|
path.join(ccsDir, `${candidate}.settings.json`)
|
|
);
|
|
const settingsPath = settingsCandidates.find((candidatePath) => fs.existsSync(candidatePath));
|
|
const primarySettingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
|
|
|
// Validate settings file exists
|
|
if (!settingsPath) {
|
|
throw new Error(
|
|
`Settings file not found: ${primarySettingsPath}\nProfile "${profile}" may not be configured.`
|
|
);
|
|
}
|
|
|
|
const continuityInheritance = await resolveProfileContinuityInheritance({
|
|
profileName: profile,
|
|
profileType: 'settings',
|
|
target: 'claude',
|
|
});
|
|
const inheritedClaudeConfigDir = continuityInheritance.claudeConfigDir;
|
|
if (continuityInheritance.sourceAccount && process.env.CCS_DEBUG) {
|
|
console.error(
|
|
info(
|
|
`Continuity inheritance active: profile "${profile}" -> account "${continuityInheritance.sourceAccount}"`
|
|
)
|
|
);
|
|
}
|
|
|
|
ensureWebSearchMcpOrThrow();
|
|
const imageAnalysisMcpReady = ensureImageAnalysisMcpOrThrow();
|
|
syncWebSearchMcpToConfigDir(inheritedClaudeConfigDir);
|
|
syncImageAnalysisMcpToConfigDir(inheritedClaudeConfigDir);
|
|
|
|
const settings = loadSettings(settingsPath);
|
|
const cliproxyBridge = resolveCliproxyBridgeMetadata(settings);
|
|
let imageAnalysisFallbackHookReady: boolean | undefined;
|
|
if (imageAnalysisMcpReady) {
|
|
removeImageAnalysisProfileHook(profile, settingsPath);
|
|
} else {
|
|
imageAnalysisFallbackHookReady = prepareImageAnalysisFallbackHook();
|
|
ensureImageAnalyzerHooks({
|
|
profileName: profile,
|
|
profileType: 'settings',
|
|
settingsPath,
|
|
settings,
|
|
cliproxyBridge,
|
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
|
});
|
|
}
|
|
const imageAnalysisStatus = await resolveImageAnalysisRuntimeStatus({
|
|
profileName: profile,
|
|
profileType: 'settings',
|
|
settings,
|
|
cliproxyBridge,
|
|
sharedHookInstalled: imageAnalysisFallbackHookReady,
|
|
});
|
|
const runtimeConnection = resolveImageAnalysisRuntimeConnection();
|
|
let imageAnalysisEnv = getImageAnalysisHookEnv({
|
|
profileName: profile,
|
|
profileType: 'settings',
|
|
settings,
|
|
cliproxyBridge,
|
|
});
|
|
imageAnalysisEnv = applyImageAnalysisRuntimeOverrides(imageAnalysisEnv, {
|
|
backendId: imageAnalysisStatus.backendId,
|
|
model: imageAnalysisStatus.model,
|
|
runtimePath: imageAnalysisStatus.runtimePath,
|
|
baseUrl: runtimeConnection.baseUrl,
|
|
apiKey: runtimeConnection.apiKey,
|
|
allowSelfSigned: runtimeConnection.allowSelfSigned,
|
|
});
|
|
imageAnalysisEnv = {
|
|
...imageAnalysisEnv,
|
|
CCS_IMAGE_ANALYSIS_SKIP_HOOK: imageAnalysisMcpReady ? '1' : '0',
|
|
};
|
|
|
|
const imageAnalysisProvider = imageAnalysisEnv['CCS_CURRENT_PROVIDER'];
|
|
if (
|
|
imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' &&
|
|
imageAnalysisProvider &&
|
|
imageAnalysisStatus.effectiveRuntimeMode === 'native-read'
|
|
) {
|
|
console.error(
|
|
info(
|
|
`${imageAnalysisStatus.effectiveRuntimeReason || `Image analysis via ${imageAnalysisProvider} is unavailable.`} This delegation will use native Read.`
|
|
)
|
|
);
|
|
imageAnalysisEnv = {
|
|
...imageAnalysisEnv,
|
|
CCS_CURRENT_PROVIDER: '',
|
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
|
};
|
|
} else if (
|
|
imageAnalysisEnv['CCS_IMAGE_ANALYSIS_SKIP'] !== '1' &&
|
|
imageAnalysisProvider &&
|
|
imageAnalysisStatus.proxyReadiness === 'stopped'
|
|
) {
|
|
const ensureServiceResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, false);
|
|
if (!ensureServiceResult.started) {
|
|
console.error(
|
|
warn(
|
|
`Image analysis via ${imageAnalysisProvider} is unavailable because CCS could not start the local CLIProxy service. This delegation will use native Read.`
|
|
)
|
|
);
|
|
imageAnalysisEnv = {
|
|
...imageAnalysisEnv,
|
|
CCS_CURRENT_PROVIDER: '',
|
|
CCS_IMAGE_ANALYSIS_SKIP: '1',
|
|
};
|
|
}
|
|
}
|
|
|
|
// Smart slash command detection and preservation
|
|
const processedPrompt = this._processSlashCommand(enhancedPrompt);
|
|
|
|
// Prepare arguments
|
|
const args: string[] = ['-p', processedPrompt, '--settings', settingsPath];
|
|
|
|
// Always use stream-json for real-time progress visibility
|
|
args.push('--output-format', 'stream-json', '--verbose');
|
|
|
|
// Add permission mode
|
|
if (permissionMode && permissionMode !== 'default') {
|
|
if (permissionMode === 'bypassPermissions') {
|
|
args.push('--dangerously-skip-permissions');
|
|
if (process.env.CCS_DEBUG) {
|
|
console.warn(warn('WARNING: Using --dangerously-skip-permissions mode'));
|
|
}
|
|
} else {
|
|
args.push('--permission-mode', permissionMode);
|
|
}
|
|
}
|
|
|
|
// Add resume flag for multi-turn sessions
|
|
if (resumeSession) {
|
|
const lastSession = sessionMgr.getLastSession(profile);
|
|
if (lastSession) {
|
|
args.push('--resume', lastSession.sessionId);
|
|
if (process.env.CCS_DEBUG) {
|
|
const cost = lastSession.totalCost?.toFixed(4) || '0.0000';
|
|
console.error(info(`Resuming session: ${lastSession.sessionId} ($${cost})`));
|
|
}
|
|
} else if (sessionId) {
|
|
args.push('--resume', sessionId);
|
|
} else {
|
|
console.warn(warn('No previous session found, starting new session'));
|
|
}
|
|
} else if (sessionId) {
|
|
args.push('--resume', sessionId);
|
|
}
|
|
|
|
// Add tool restrictions from settings
|
|
const toolRestrictions = SettingsParser.parseToolRestrictions(cwd);
|
|
if (toolRestrictions.allowedTools.length > 0) {
|
|
args.push('--allowedTools', ...toolRestrictions.allowedTools);
|
|
}
|
|
if (toolRestrictions.disallowedTools.length > 0) {
|
|
args.push('--disallowedTools', toolRestrictions.disallowedTools.join(','));
|
|
}
|
|
|
|
// Claude Code CLI passthrough flags (explicit, validated)
|
|
// Use undefined checks (not truthy) to allow empty strings if ever valid
|
|
if (maxTurns !== undefined && maxTurns > 0) {
|
|
args.push('--max-turns', String(maxTurns));
|
|
}
|
|
if (fallbackModel !== undefined && fallbackModel) {
|
|
args.push('--fallback-model', fallbackModel);
|
|
}
|
|
if (agents !== undefined && agents) {
|
|
args.push('--agents', agents);
|
|
}
|
|
if (betas !== undefined && betas) {
|
|
args.push('--betas', betas);
|
|
}
|
|
|
|
// Passthrough extra args (catch-all for new/unknown flags)
|
|
// Filter out duplicates of explicitly handled flags
|
|
if (extraArgs.length > 0) {
|
|
const explicitFlags = new Set(['--max-turns', '--fallback-model', '--agents', '--betas']);
|
|
const filteredExtras: string[] = [];
|
|
for (let i = 0; i < extraArgs.length; i++) {
|
|
if (explicitFlags.has(extraArgs[i])) {
|
|
// Skip this flag and its value (next element)
|
|
if (i + 1 < extraArgs.length && !extraArgs[i + 1].startsWith('-')) {
|
|
i++; // Skip value too
|
|
}
|
|
continue;
|
|
}
|
|
filteredExtras.push(extraArgs[i]);
|
|
}
|
|
if (filteredExtras.length > 0) {
|
|
args.push(...filteredExtras);
|
|
}
|
|
}
|
|
|
|
const imageAnalysisArgs = imageAnalysisMcpReady
|
|
? appendThirdPartyImageAnalysisToolArgs(args)
|
|
: args;
|
|
const launchArgs = appendThirdPartyWebSearchToolArgs(imageAnalysisArgs);
|
|
const traceEnv = createWebSearchTraceContext({
|
|
launcher: 'delegation.headless-executor',
|
|
args: launchArgs,
|
|
cwd,
|
|
profile,
|
|
profileType: 'settings',
|
|
settingsPath,
|
|
claudeConfigDir: inheritedClaudeConfigDir,
|
|
});
|
|
|
|
if (process.env.CCS_DEBUG) {
|
|
console.error(info(`Claude CLI args: ${launchArgs.join(' ')}`));
|
|
}
|
|
|
|
// Initialize UI before spawning
|
|
await ui.init();
|
|
|
|
// Execute with spawn
|
|
return this._spawnAndExecute(claudeCli, launchArgs, {
|
|
cwd,
|
|
profile,
|
|
timeout,
|
|
resumeSession,
|
|
sessionId,
|
|
sessionMgr,
|
|
claudeConfigDir: inheritedClaudeConfigDir,
|
|
imageAnalysisEnv,
|
|
traceEnv,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
claudeConfigDir?: string;
|
|
imageAnalysisEnv?: Record<string, string>;
|
|
traceEnv?: Record<string, string>;
|
|
}
|
|
): Promise<ExecutionResult> {
|
|
const {
|
|
cwd,
|
|
profile,
|
|
timeout,
|
|
resumeSession,
|
|
sessionId,
|
|
sessionMgr,
|
|
claudeConfigDir,
|
|
imageAnalysisEnv = {},
|
|
traceEnv = {},
|
|
} = ctx;
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const startTime = Date.now();
|
|
const showProgress = !process.env.CCS_QUIET;
|
|
const streamBuffer = new StreamBuffer();
|
|
|
|
if (showProgress) {
|
|
const modelName = getModelDisplayName(profile);
|
|
console.error(ui.info(`Delegating to ${modelName}...`));
|
|
}
|
|
|
|
// Strip Claude Code nested session guard env var to allow CCS delegation
|
|
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
|
|
const cleanEnv = stripClaudeCodeEnv({
|
|
...process.env,
|
|
...getClaudeLaunchEnvOverrides(),
|
|
...getWebSearchHookEnv(),
|
|
...imageAnalysisEnv,
|
|
...traceEnv,
|
|
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
|
CCS_PROFILE_TYPE: 'settings',
|
|
});
|
|
|
|
const proc = spawn(claudeCli, args, {
|
|
cwd,
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
timeout,
|
|
env: cleanEnv,
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let progressInterval: NodeJS.Timeout | undefined;
|
|
const messages: StreamMessage[] = [];
|
|
let timedOut = false;
|
|
|
|
// Setup signal handlers for cleanup
|
|
const cleanupHandler = () => {
|
|
if (proc.exitCode === null) {
|
|
killWithEscalation(proc, 2000);
|
|
}
|
|
};
|
|
process.once('SIGINT', cleanupHandler);
|
|
process.once('SIGTERM', cleanupHandler);
|
|
const removeSignalHandlers = () => {
|
|
process.removeListener('SIGINT', cleanupHandler);
|
|
process.removeListener('SIGTERM', cleanupHandler);
|
|
};
|
|
proc.on('close', removeSignalHandlers);
|
|
proc.on('error', removeSignalHandlers);
|
|
|
|
// Progress indicator
|
|
if (showProgress) {
|
|
progressInterval = setInterval(() => {
|
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
process.stderr.write(`${ui.info(`Still running... ${elapsed}s elapsed`)}\r`);
|
|
}, 5000);
|
|
}
|
|
|
|
// Capture stdout (stream-json format)
|
|
proc.stdout?.on('data', (data: Buffer) => {
|
|
const dataStr = data.toString();
|
|
stdout += dataStr;
|
|
|
|
const parsedMessages = streamBuffer.parseChunk(dataStr);
|
|
for (const msg of parsedMessages) {
|
|
messages.push(msg);
|
|
|
|
// Show real-time tool use
|
|
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');
|
|
const toolInput = tool.input || {};
|
|
const verboseMsg = formatToolVerbose(tool.name || 'Unknown', toolInput);
|
|
process.stderr.write(`${verboseMsg}\n`);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Stream stderr in real-time
|
|
proc.stderr?.on('data', (data: Buffer) => {
|
|
const stderrText = data.toString();
|
|
stderr += stderrText;
|
|
if (showProgress) {
|
|
if (progressInterval) process.stderr.write('\r\x1b[K');
|
|
process.stderr.write(stderrText);
|
|
}
|
|
});
|
|
|
|
// Handle completion
|
|
proc.on('close', (exitCode: number | null) => {
|
|
const duration = Date.now() - startTime;
|
|
|
|
if (progressInterval) {
|
|
clearInterval(progressInterval);
|
|
process.stderr.write('\r\x1b[K');
|
|
}
|
|
|
|
if (showProgress) {
|
|
const durationSec = (duration / 1000).toFixed(1);
|
|
console.error(
|
|
timedOut
|
|
? ui.warn(`Timed out after ${durationSec}s`)
|
|
: ui.info(`Completed in ${durationSec}s`)
|
|
);
|
|
console.error('');
|
|
}
|
|
|
|
const result = buildExecutionResult({
|
|
exitCode: exitCode || 0,
|
|
stdout,
|
|
stderr,
|
|
cwd,
|
|
profile,
|
|
duration,
|
|
timedOut,
|
|
messages,
|
|
});
|
|
|
|
const launchId = traceEnv.CCS_WEBSEARCH_TRACE_LAUNCH_ID;
|
|
if (launchId) {
|
|
const launchTraceRecords = readWebSearchTraceRecords(launchId, {
|
|
...process.env,
|
|
...traceEnv,
|
|
CCS_PROFILE_TYPE: 'settings',
|
|
});
|
|
const mcpSessionSummary = [...launchTraceRecords]
|
|
.reverse()
|
|
.find((record) => record.event === 'mcp_session_summary');
|
|
const providerSuccess = [...launchTraceRecords]
|
|
.reverse()
|
|
.find((record) => record.event === 'websearch_provider_success');
|
|
|
|
const exposed = mcpSessionSummary?.exposed === true;
|
|
const calledWebSearch = result.toolUsageSummary?.calledWebSearch === true;
|
|
const fallbackToolsUsed = result.toolUsageSummary?.fallbackToolsUsed || [];
|
|
|
|
appendWebSearchTrace(
|
|
'headless_websearch_summary',
|
|
{
|
|
profile,
|
|
sessionId: result.sessionId || null,
|
|
calledWebSearch,
|
|
fallbackToolsUsed,
|
|
providerUsed:
|
|
typeof providerSuccess?.providerName === 'string'
|
|
? providerSuccess.providerName
|
|
: null,
|
|
exposed,
|
|
likelyBypassed:
|
|
exposed && !calledWebSearch
|
|
? fallbackToolsUsed.length > 0
|
|
? true
|
|
: 'unknown'
|
|
: false,
|
|
},
|
|
{
|
|
...process.env,
|
|
...traceEnv,
|
|
CCS_PROFILE_TYPE: 'settings',
|
|
}
|
|
);
|
|
}
|
|
|
|
// Store session
|
|
if (result.sessionId) {
|
|
if (resumeSession || sessionId) {
|
|
sessionMgr.updateSession(profile, result.sessionId, { totalCost: result.totalCost });
|
|
} else {
|
|
sessionMgr.storeSession(profile, {
|
|
sessionId: result.sessionId,
|
|
totalCost: result.totalCost,
|
|
cwd,
|
|
});
|
|
}
|
|
if (Math.random() < 0.1) sessionMgr.cleanupExpired();
|
|
}
|
|
|
|
resolve(result);
|
|
});
|
|
|
|
// Handle errors
|
|
proc.on('error', (error: Error) => {
|
|
if (progressInterval) clearInterval(progressInterval);
|
|
reject(new Error(`Failed to execute Claude CLI: ${error.message}`));
|
|
});
|
|
|
|
// Handle timeout
|
|
if (timeout > 0) {
|
|
const timeoutHandle = setTimeout(() => {
|
|
if (proc.exitCode === null) {
|
|
timedOut = true;
|
|
if (progressInterval) {
|
|
clearInterval(progressInterval);
|
|
process.stderr.write('\r\x1b[K');
|
|
}
|
|
// Longer grace period for timeout (vs 2s for SIGINT) since delegated sessions may need time to flush output
|
|
killWithEscalation(proc, 10000);
|
|
}
|
|
}, timeout);
|
|
proc.on('close', () => clearTimeout(timeoutHandle));
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Validate permission mode */
|
|
private static _validatePermissionMode(mode: string): void {
|
|
const VALID_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'];
|
|
if (!VALID_MODES.includes(mode)) {
|
|
throw new Error(`Invalid permission mode: "${mode}". Valid modes: ${VALID_MODES.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
/** Detect Claude CLI executable */
|
|
private static _detectClaudeCli(): string | null {
|
|
if (process.env.CCS_CLAUDE_PATH) return process.env.CCS_CLAUDE_PATH;
|
|
const { execSync } = require('child_process');
|
|
try {
|
|
return execSync('command -v claude', { encoding: 'utf8' }).trim();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Execute with retry logic */
|
|
static async executeWithRetry(
|
|
profile: string,
|
|
enhancedPrompt: string,
|
|
options: ExecutionOptions = {}
|
|
): Promise<ExecutionResult> {
|
|
const { maxRetries = 2, ...execOptions } = options;
|
|
let lastError: Error | undefined;
|
|
|
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
const result = await this.execute(profile, enhancedPrompt, execOptions);
|
|
if (result.success) return result;
|
|
if (attempt < maxRetries) {
|
|
console.error(warn(`Attempt ${attempt + 1} failed, retrying...`));
|
|
await this._sleep(1000 * (attempt + 1));
|
|
continue;
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
if (attempt < maxRetries) {
|
|
console.error(warn(`Attempt ${attempt + 1} errored, retrying...`));
|
|
await this._sleep(1000 * (attempt + 1));
|
|
}
|
|
}
|
|
}
|
|
throw lastError || new Error('Execution failed after all retry attempts');
|
|
}
|
|
|
|
/** Sleep utility for retry backoff */
|
|
private static _sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/** Process prompt to detect and preserve slash commands */
|
|
private static _processSlashCommand(prompt: string): string {
|
|
const trimmed = prompt.trim();
|
|
|
|
// Case 1: Already starts with slash command
|
|
if (trimmed.match(/^\/[\w:-]+(\s|$)/)) return prompt;
|
|
|
|
// Case 2: Find slash command embedded in text
|
|
const embeddedSlash = trimmed.match(/(?:^|[^\w/])(\/[\w:-]+)(\s+[\s\S]*)?$/);
|
|
if (embeddedSlash) {
|
|
const command = embeddedSlash[1];
|
|
const args = (embeddedSlash[2] || '').trim();
|
|
const matchIndex = embeddedSlash.index || 0;
|
|
const matchStart = matchIndex + (embeddedSlash[0][0] === '/' ? 0 : 1);
|
|
const beforeCommand = trimmed.substring(0, matchStart).trim();
|
|
|
|
if (beforeCommand && args) return `${command} ${args}\n\nContext: ${beforeCommand}`;
|
|
if (beforeCommand) return `${command}\n\nContext: ${beforeCommand}`;
|
|
return args ? `${command} ${args}` : command;
|
|
}
|
|
|
|
return prompt;
|
|
}
|
|
|
|
/** Test if profile is executable */
|
|
static async testProfile(profile: string): Promise<boolean> {
|
|
try {
|
|
const result = await this.execute(profile, 'Say "test successful"', { timeout: 10000 });
|
|
return result.success;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
}
|