Skip to content

Instantly share code, notes, and snippets.

@gkio
Last active July 29, 2025 08:47
Show Gist options
  • Select an option

  • Save gkio/9df080cd98674ca3f1cff7e73cf1a2f2 to your computer and use it in GitHub Desktop.

Select an option

Save gkio/9df080cd98674ca3f1cff7e73cf1a2f2 to your computer and use it in GitHub Desktop.
import { spawn, ChildProcess } from 'child_process';
import { EventEmitter } from 'events';
import path from 'path';
import fs from 'fs';
import os from 'os';
import { v4 as uuidv4 } from 'uuid';
export interface ClaudeExecutorConfig {
projectPath: string;
permissionMode: 'acceptEdits' | 'bypassPermissions' | 'default' | 'plan';
model?: string;
sessionId?: string;
verbose?: boolean;
}
export interface ProcessMessage {
id: string;
type: 'assistant' | 'user' | 'system' | 'tool_use' | 'result' | 'thinking';
content: string;
timestamp: number;
toolName?: string;
actionType?: string;
thinkingContent?: string;
}
export interface ProcessUpdate {
type: 'message' | 'status' | 'session' | 'error';
data: any;
timestamp: number;
}
export class ClaudeExecutor extends EventEmitter {
private process: ChildProcess | null = null;
private config: ClaudeExecutorConfig;
private sessionId: string | null = null;
private isRunning: boolean = false;
private messageBuffer: string = '';
private processId: string;
constructor(config: ClaudeExecutorConfig) {
super();
this.config = config;
this.processId = uuidv4();
}
/**
* Start the Claude Code CLI process
*/
async start(prompt: string): Promise<void> {
if (this.isRunning) {
throw new Error('Claude executor is already running');
}
try {
await this.validateProjectPath();
const command = this.buildCommand();
// Debug logging for command execution
console.log('=== CLAUDE EXECUTOR DEBUG ===');
console.log('Config sessionId:', this.config.sessionId);
console.log('Built command:', command);
console.log('Project path:', this.config.projectPath);
console.log('===============================');
this.emit('update', {
type: 'status',
data: { status: 'starting', command },
timestamp: Date.now()
});
this.process = spawn('bash', ['-c', command], {
cwd: this.config.projectPath,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
NODE_NO_WARNINGS: '1'
},
detached: false
});
this.setupProcessHandlers();
this.isRunning = true;
// Send the prompt via stdin
if (this.process.stdin) {
this.process.stdin.write(prompt + '\n');
this.process.stdin.end();
}
this.emit('update', {
type: 'status',
data: { status: 'running', processId: this.processId },
timestamp: Date.now()
});
} catch (error) {
this.emit('update', {
type: 'error',
data: { error: error instanceof Error ? error.message : 'Unknown error' },
timestamp: Date.now()
});
throw error;
}
}
/**
* Send a follow-up message to the running process
*/
async sendMessage(message: string): Promise<void> {
if (!this.isRunning || !this.process || !this.process.stdin) {
throw new Error('Claude executor is not running');
}
this.process.stdin.write(message + '\n');
this.emit('update', {
type: 'message',
data: {
id: uuidv4(),
type: 'user',
content: message,
timestamp: Date.now()
},
timestamp: Date.now()
});
}
/**
* Stop the Claude Code process
*/
async stop(): Promise<void> {
if (!this.isRunning || !this.process) {
return;
}
return new Promise((resolve) => {
if (this.process) {
this.process.once('exit', () => {
this.cleanup();
resolve();
});
// Try graceful shutdown first
this.process.kill('SIGTERM');
// Force kill after 5 seconds
setTimeout(() => {
if (this.process && !this.process.killed) {
this.process.kill('SIGKILL');
}
}, 5000);
} else {
this.cleanup();
resolve();
}
});
}
/**
* Get current session ID
*/
getSessionId(): string | null {
return this.sessionId;
}
/**
* Get process ID
*/
getProcessId(): string {
return this.processId;
}
/**
* Check if process is running
*/
isProcessRunning(): boolean {
return this.isRunning;
}
private async validateProjectPath(): Promise<void> {
if (!fs.existsSync(this.config.projectPath)) {
throw new Error(`Project path does not exist: ${this.config.projectPath}`);
}
const stat = fs.statSync(this.config.projectPath);
if (!stat.isDirectory()) {
throw new Error(`Project path is not a directory: ${this.config.projectPath}`);
}
}
private checkSessionExists(sessionId: string): boolean {
try {
// Get Claude's project directory path
const sanitizedPath = this.config.projectPath.replace(/\//g, '-');
const claudeProjectPath = path.join(os.homedir(), '.claude', 'projects', sanitizedPath);
const sessionFile = path.join(claudeProjectPath, `${sessionId}.jsonl`);
console.log('Checking for session file:', sessionFile);
const exists = fs.existsSync(sessionFile);
console.log('Session file exists:', exists);
return exists;
} catch (error) {
console.error('Error checking session file:', error);
return false;
}
}
private buildCommand(): string {
const baseCommand = 'npx -y @anthropic-ai/claude-code@latest';
const args = ['-p'];
// Add permission mode
switch (this.config.permissionMode) {
case 'plan':
args.push('--permission-mode=plan');
break;
case 'acceptEdits':
args.push('--permission-mode=accept');
break;
case 'bypassPermissions':
args.push('--dangerously-skip-permissions');
break;
default:
// Default mode - no special flags
break;
}
// Add model selection (convert 'default' to empty string for CLI)
if (this.config.model && this.config.model !== 'default') {
args.push(`--model=${this.config.model}`);
}
// Add session resumption if available and session file exists
if (this.config.sessionId) {
const sessionExists = this.checkSessionExists(this.config.sessionId);
if (sessionExists) {
console.log('Session file exists, resuming:', this.config.sessionId);
args.push(`--resume=${this.config.sessionId}`);
} else {
console.log('Session file does not exist, starting new session for:', this.config.sessionId);
// Don't add --resume flag, let Claude create the session with this ID
}
}
// Add output format and verbosity
args.push('--output-format=stream-json');
if (this.config.verbose) {
args.push('--verbose');
}
const fullCommand = `${baseCommand} ${args.join(' ')}`;
// For plan mode, wrap with watchkill script
if (this.config.permissionMode === 'plan') {
return this.wrapWithWatchkill(fullCommand);
}
return fullCommand;
}
private wrapWithWatchkill(command: string): string {
// Create the watchkill script inline
const watchkillScript = `
#!/usr/bin/env bash
set -euo pipefail
word="Exit plan mode?"
command="${command}"
exit_code=0
while IFS= read -r line; do
printf '%s\\n' "$line"
if [[ $line == *"$word"* ]]; then
exit 0
fi
done < <($command <&0 2>&1)
exit_code=\${PIPESTATUS[0]}
exit "$exit_code"
`.trim();
return `bash -c '${watchkillScript.replace(/'/g, "'\"'\"'")}'`;
}
private setupProcessHandlers(): void {
if (!this.process) return;
// Handle stdout (main output)
this.process.stdout?.on('data', (data: Buffer) => {
this.handleOutput(data.toString());
});
// Handle stderr (errors and logs)
this.process.stderr?.on('data', (data: Buffer) => {
this.handleError(data.toString());
});
// Handle process exit
this.process.on('exit', (code: number | null, signal: string | null) => {
this.emit('update', {
type: 'status',
data: {
status: 'completed',
exitCode: code,
signal,
sessionId: this.sessionId
},
timestamp: Date.now()
});
this.cleanup();
});
// Handle process errors
this.process.on('error', (error: Error) => {
this.emit('update', {
type: 'error',
data: { error: error.message },
timestamp: Date.now()
});
this.cleanup();
});
}
private handleOutput(data: string): void {
this.messageBuffer += data;
const lines = this.messageBuffer.split('\n');
// Keep the last incomplete line in buffer
this.messageBuffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
this.processLine(line.trim());
}
}
}
private handleError(data: string): void {
this.emit('update', {
type: 'message',
data: {
id: uuidv4(),
type: 'system',
content: data,
timestamp: Date.now()
},
timestamp: Date.now()
});
}
private processLine(line: string): void {
try {
// Try to parse as JSON (Claude's stream-json format)
const jsonData = JSON.parse(line);
this.processJsonMessage(jsonData);
} catch {
// If not JSON, treat as plain text output
this.emit('update', {
type: 'message',
data: {
id: uuidv4(),
type: 'system',
content: line,
timestamp: Date.now()
},
timestamp: Date.now()
});
}
}
private processJsonMessage(data: any): void {
// Extract session ID if present
if (data.session_id && !this.sessionId) {
this.sessionId = data.session_id;
this.emit('update', {
type: 'session',
data: { sessionId: this.sessionId },
timestamp: Date.now()
});
}
// Process different message types
if (data.type === 'message') {
let content = data.content || data.text || '';
let thinkingContent: string | undefined;
// Handle structured content that may contain thinking
if (Array.isArray(data.content)) {
const textParts: string[] = [];
for (const part of data.content) {
if (part.type === 'text') {
textParts.push(part.text || '');
} else if (part.type === 'thinking') {
thinkingContent = part.thinking || part.text || '';
}
}
content = textParts.join('\n\n').trim();
}
// Check if this is a thinking-only message
if (data.thinking || thinkingContent) {
// For thinking-only content, create a thinking message
if (!content.trim() && thinkingContent) {
const thinkingMessage: ProcessMessage = {
id: uuidv4(),
type: 'thinking',
content: thinkingContent,
timestamp: Date.now()
};
this.emit('update', {
type: 'message',
data: thinkingMessage,
timestamp: Date.now()
});
return;
}
}
const message: ProcessMessage = {
id: uuidv4(),
type: this.mapMessageType(data.role || 'system'),
content,
timestamp: Date.now(),
toolName: data.tool_name,
actionType: this.extractActionType(data),
thinkingContent
};
this.emit('update', {
type: 'message',
data: message,
timestamp: Date.now()
});
}
}
private mapMessageType(role: string): ProcessMessage['type'] {
switch (role) {
case 'assistant':
return 'assistant';
case 'user':
return 'user';
case 'tool':
case 'tool_use':
return 'tool_use';
case 'result':
return 'result';
case 'thinking':
return 'thinking';
default:
return 'system';
}
}
private extractActionType(data: any): string | undefined {
if (data.tool_name) {
const toolName = data.tool_name.toLowerCase();
// Map Claude's tools to action types
const toolMapping: Record<string, string> = {
'read': 'file_read',
'edit': 'file_edit',
'write': 'file_write',
'multiedit': 'file_edit',
'bash': 'command_execution',
'grep': 'search',
'glob': 'search',
'webfetch': 'web_request',
'task': 'task_management',
'todowrite': 'task_management',
'todoread': 'task_management',
'exitplanmode': 'plan_presentation'
};
return toolMapping[toolName] || 'other';
}
return undefined;
}
private cleanup(): void {
this.isRunning = false;
this.process = null;
this.messageBuffer = '';
}
}
// TRASNFORM JSONL
import fs from 'fs';
import path from 'path';
import { DatabaseChatMessage } from './models';
// Claude Code history interfaces matching the JSONL format
export interface ClaudeHistoryMessage {
parentUuid: string | null;
isSidechain: boolean;
userType: string;
cwd: string;
sessionId: string;
version: string;
gitBranch: string;
type: 'user' | 'assistant';
message: {
role: 'user' | 'assistant';
content: string | Array<{
type: 'text' | 'tool_use' | 'tool_result' | 'thinking';
text?: string;
id?: string;
name?: string;
input?: any;
content?: string;
tool_use_id?: string;
thinking?: string;
}>;
model?: string;
stop_reason?: string;
usage?: {
input_tokens: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
output_tokens: number;
service_tier?: string;
};
};
uuid: string;
timestamp: string;
requestId?: string;
toolUseResult?: any;
}
export interface ClaudeSession {
sessionId: string;
projectPath: string;
gitBranch: string;
messages: ClaudeHistoryMessage[];
startTime: Date;
endTime?: Date;
totalTokens: number;
}
export class ClaudeHistoryParser {
private static claudeDir = path.join(process.env.HOME || '~', '.claude');
/**
* Get the Claude projects directory for a specific project path
*/
private static getProjectClaudeDir(projectPath: string): string {
// Convert project path to Claude directory format
const normalizedPath = projectPath.replace(/\//g, '-').replace(/^-/, '');
return path.join(this.claudeDir, 'projects', `-${normalizedPath}`);
}
/**
* Get all session files for a project
*/
static getProjectSessions(projectPath: string): string[] {
try {
const projectClaudeDir = this.getProjectClaudeDir(projectPath);
if (!fs.existsSync(projectClaudeDir)) {
return [];
}
return fs.readdirSync(projectClaudeDir)
.filter(file => file.endsWith('.jsonl'))
.map(file => path.join(projectClaudeDir, file));
} catch (error) {
console.error('Error getting project sessions:', error);
return [];
}
}
/**
* Parse a single JSONL session file
*/
static parseSessionFile(filePath: string): ClaudeSession | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.trim().split('\n').filter(line => line.trim());
if (lines.length === 0) {
return null;
}
const messages: ClaudeHistoryMessage[] = [];
let sessionId = '';
let projectPath = '';
let gitBranch = '';
let startTime: Date = new Date();
let endTime: Date | undefined;
let totalTokens = 0;
for (const line of lines) {
try {
const message: ClaudeHistoryMessage = JSON.parse(line);
messages.push(message);
// Extract session metadata from first message
if (!sessionId) {
sessionId = message.sessionId;
projectPath = message.cwd;
gitBranch = message.gitBranch || 'main';
startTime = new Date(message.timestamp);
}
// Update end time with latest message
endTime = new Date(message.timestamp);
// Calculate token usage
if (message.message && message.message.usage) {
totalTokens += message.message.usage.input_tokens || 0;
totalTokens += message.message.usage.output_tokens || 0;
}
} catch (parseError) {
console.error('Error parsing line:', parseError);
}
}
return {
sessionId,
projectPath,
gitBranch,
messages,
startTime,
endTime,
totalTokens
};
} catch (error) {
console.error('Error parsing session file:', error);
return null;
}
}
/**
* Get all sessions for a project
*/
static getProjectClaudeSessions(projectPath: string): ClaudeSession[] {
const sessionFiles = this.getProjectSessions(projectPath);
const sessions: ClaudeSession[] = [];
for (const file of sessionFiles) {
const session = this.parseSessionFile(file);
if (session) {
sessions.push(session);
}
}
// Sort by start time, newest first
return sessions.sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
}
/**
* Convert Claude history messages to chat message format with pagination
*/
static convertToChatMessages(
claudeMessages: ClaudeHistoryMessage[],
limit?: number,
offset?: number
): {
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
uuid: string;
model?: string;
tokens?: number;
toolUse?: boolean;
toolDetails?: Array<{
name: string;
action: string;
path?: string;
input?: any;
result?: string;
}>;
thinkingContent?: string;
}>;
hasMore: boolean;
total: number;
} {
const allMessages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
uuid: string;
model?: string;
tokens?: number;
toolUse?: boolean;
toolDetails?: Array<{
name: string;
action: string;
path?: string;
input?: any;
result?: string;
}>;
thinkingContent?: string;
}> = [];
for (const msg of claudeMessages) {
// Skip messages without message property
if (!msg.message) {
continue;
}
let content = '';
let toolUse = false;
let thinkingContent = '';
const toolDetails: Array<{
name: string;
action: string;
path?: string;
input?: any;
result?: string;
}> = [];
// Extract content from different message formats
if (typeof msg.message.content === 'string') {
content = msg.message.content;
} else if (Array.isArray(msg.message.content)) {
// Handle structured content (tool uses, etc.)
const textParts: string[] = [];
const pendingToolResults: Array<{tool_use_id: string, content: string}> = [];
// First pass: collect text, thinking, and tool uses
for (const part of msg.message.content) {
if (part.type === 'text') {
const text = part.text || '';
if (text.trim()) {
textParts.push(text);
}
} else if (part.type === 'thinking') {
// Extract thinking content
const thinking = part.thinking || part.text || '';
if (thinking.trim()) {
thinkingContent = thinking;
}
} else if (part.type === 'tool_use') {
toolUse = true;
// Extract tool details
const toolDetail: any = {
name: part.name || '',
action: 'tool_use',
input: part.input,
id: part.id
};
// Try to extract file path and action from tool input
if (part.input) {
if (part.input.file_path || part.input.path) {
toolDetail.path = part.input.file_path || part.input.path;
}
// Determine action based on tool name
const toolName = part.name?.toLowerCase() || '';
if (toolName.includes('todowrite')) {
toolDetail.action = 'todo_write';
} else if (toolName.includes('edit') || toolName.includes('multiedit')) {
toolDetail.action = 'file_edit';
} else if (toolName.includes('write') || toolName.includes('create')) {
toolDetail.action = 'file_write';
} else if (toolName.includes('read')) {
toolDetail.action = 'file_read';
} else if (toolName.includes('bash') || toolName.includes('command')) {
toolDetail.action = 'command_run';
if (part.input.command) {
toolDetail.command = part.input.command;
}
} else if (toolName.includes('search') || toolName.includes('grep')) {
toolDetail.action = 'search';
} else if (toolName.includes('webfetch')) {
toolDetail.action = 'web_fetch';
} else if (toolName.includes('websearch')) {
toolDetail.action = 'web_search';
} else if (toolName.includes('glob')) {
toolDetail.action = 'file_search';
} else if (toolName.includes('ls')) {
toolDetail.action = 'list_directory';
} else if (toolName.includes('task')) {
toolDetail.action = 'agent_task';
}
}
toolDetails.push(toolDetail);
} else if (part.type === 'tool_result') {
pendingToolResults.push({
tool_use_id: part.tool_use_id,
content: part.content
});
}
}
// Second pass: match results to tools
for (const result of pendingToolResults) {
const matchingTool = toolDetails.find(t => t.id === result.tool_use_id);
if (matchingTool) {
matchingTool.result = result.content;
}
}
// Clean text processing - separate text from tool uses
content = textParts.join('\n\n').trim();
// If there's no text content but there are tool uses, provide a clean indicator
if (!content && toolDetails.length > 0) {
// Create a more descriptive message based on tool types
const toolTypes = [...new Set(toolDetails.map(t => t.action))];
if (toolTypes.length === 1) {
const action = toolTypes[0];
const actionMap: Record<string, string> = {
'file_read': 'read files',
'file_edit': 'edited files',
'file_write': 'created files',
'command_run': 'ran commands',
'search': 'searched',
'file_search': 'searched files',
'list_directory': 'listed directories',
'todo_write': 'updated todos',
'web_fetch': 'fetched web content',
'web_search': 'searched web',
'agent_task': 'ran agent tasks'
};
content = `[${actionMap[action] || action}]`;
} else {
content = `[Used ${toolDetails.length} tool${toolDetails.length > 1 ? 's' : ''}]`;
}
}
}
// Skip messages without content AND without tool details AND without thinking
if (!content.trim() && toolDetails.length === 0 && !thinkingContent.trim()) {
continue;
}
const tokens = msg.message && msg.message.usage
? (msg.message.usage.input_tokens || 0) + (msg.message.usage.output_tokens || 0)
: undefined;
// Handle interruption messages specially
const trimmedContent = content.trim();
const isInterruption = trimmedContent.includes('[Request interrupted by user]');
// Ensure proper role assignment - don't override with 'system' unless actually an interruption
const messageRole = isInterruption ? 'system' : (msg.message.role || msg.type);
allMessages.push({
id: msg.uuid,
role: messageRole as 'user' | 'assistant' | 'system',
content: isInterruption ? '[Request interrupted by user]' : trimmedContent,
timestamp: new Date(msg.timestamp),
uuid: msg.uuid,
model: msg.message.model,
tokens,
toolUse,
toolDetails: toolDetails.length > 0 ? toolDetails : undefined,
thinkingContent: thinkingContent || undefined
});
}
// Sort by timestamp (newest first for chat display)
allMessages.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Apply pagination
const total = allMessages.length;
const startIndex = offset || 0;
const endIndex = limit ? startIndex + limit : allMessages.length;
const messages = allMessages.slice(startIndex, endIndex);
const hasMore = endIndex < total;
return {
messages,
hasMore,
total
};
}
/**
* Get a summary of a Claude session
*/
static getSessionSummary(session: ClaudeSession): {
title: string;
messageCount: number;
duration: string;
firstUserMessage: string;
} {
const firstUserMessage = session.messages.find(m => m.type === 'user')?.message.content;
const title = typeof firstUserMessage === 'string'
? firstUserMessage.slice(0, 50) + (firstUserMessage.length > 50 ? '...' : '')
: 'Claude Session';
const duration = session.endTime
? Math.round((session.endTime.getTime() - session.startTime.getTime()) / 1000 / 60)
: 0;
return {
title,
messageCount: session.messages.length,
duration: duration > 0 ? `${duration}m` : 'Active',
firstUserMessage: typeof firstUserMessage === 'string' ? firstUserMessage : ''
};
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment