Files
Aperant/apps/frontend/src/main/agent/agent-process.ts
T
StillKnotKnown 5e91c3a75c fix(frontend): pass CLAUDE_CLI_PATH to Python backend subprocess (ACS-230) (#1081)
* fix(frontend): pass CLAUDE_CLI_PATH to Python backend subprocess

Fixes ACS-230: Claude Code CLI not found despite being installed

When the Electron app is launched from Finder/Dock (not from terminal),
the Python subprocess doesn't inherit the user's shell PATH. This causes
the Claude Agent SDK in the Python backend to fail finding the Claude CLI
when it's installed via Homebrew at /opt/homebrew/bin/claude (macOS) or
other non-standard locations.

This fix:
1. Detects the Claude CLI path using the existing getToolInfo('claude')
2. Passes it to Python backend via CLAUDE_CLI_PATH environment variable
3. Respects existing CLAUDE_CLI_PATH if already set (user override)
4. Follows the same pattern as CLAUDE_CODE_GIT_BASH_PATH (Windows only)

The Python backend (apps/backend/core/client.py) already checks
CLAUDE_CLI_PATH first in find_claude_cli() (line 316), so no backend
changes are needed.

Related: PR #1004 (commit e07a0dbd) which added comprehensive CLI detection
to the backend, but the frontend wasn't passing the detected path.

Refs: ACS-230

* fix: correct typo in CLAUDE_CLI_PATH environment variable check

Addressed review feedback on PR #1081:
- Fixed typo: CLADE_CLI_PATH → CLAUDE_CLI_PATH (line 147)
- This ensures user-provided CLAUDE_CLI_PATH overrides are respected
- Previously the typo caused the check to always fail, ignoring user overrides

The typo was caught by automated review tools (gemini-code-assist, sentry).

Fixes review comments:
- https://github.com/AndyMik90/Auto-Claude/pull/1081#discussion_r2691279285
- https://github.com/AndyMik90/Auto-Claude/pull/1081#discussion_r2691284743

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-01-15 17:25:24 +02:00

756 lines
27 KiB
TypeScript

import { spawn } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
import { existsSync, readFileSync } from 'fs';
import { app } from 'electron';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { EventEmitter } from 'events';
import { AgentState } from './agent-state';
import { AgentEvents } from './agent-events';
import { ProcessType, ExecutionProgressData } from './types';
import type { CompletablePhase } from '../../shared/constants/phase-protocol';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector';
import { getAPIProfileEnv } from '../services/profile';
import { projectStore } from '../project-store';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { parsePythonCommand, validatePythonPath } from '../python-detector';
import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager';
import { buildMemoryEnvVars } from '../memory-env-builder';
import { readSettingsFile } from '../settings-utils';
import type { AppSettings } from '../../shared/types/settings';
import { getOAuthModeClearVars } from './env-utils';
import { getAugmentedEnv } from '../env-utils';
import { getToolInfo } from '../cli-tool-manager';
function deriveGitBashPath(gitExePath: string): string | null {
if (process.platform !== 'win32') {
return null;
}
try {
const gitDir = path.dirname(gitExePath); // e.g., D:\...\Git\mingw64\bin
const gitDirName = path.basename(gitDir).toLowerCase();
// Find Git installation root
let gitRoot: string;
if (gitDirName === 'cmd') {
// .../Git/cmd/git.exe -> .../Git
gitRoot = path.dirname(gitDir);
} else if (gitDirName === 'bin') {
// Could be .../Git/bin/git.exe OR .../Git/mingw64/bin/git.exe
const parent = path.dirname(gitDir);
const parentName = path.basename(parent).toLowerCase();
if (parentName === 'mingw64' || parentName === 'mingw32') {
// .../Git/mingw64/bin/git.exe -> .../Git
gitRoot = path.dirname(parent);
} else {
// .../Git/bin/git.exe -> .../Git
gitRoot = parent;
}
} else {
// Unknown structure - try to find 'bin' sibling
gitRoot = path.dirname(gitDir);
}
// Bash.exe is in Git/bin/bash.exe
const bashPath = path.join(gitRoot, 'bin', 'bash.exe');
if (existsSync(bashPath)) {
console.log('[AgentProcess] Derived git-bash path:', bashPath);
return bashPath;
}
// Fallback: check one level up if gitRoot didn't work
const altBashPath = path.join(path.dirname(gitRoot), 'bin', 'bash.exe');
if (existsSync(altBashPath)) {
console.log('[AgentProcess] Found git-bash at alternate path:', altBashPath);
return altBashPath;
}
console.warn('[AgentProcess] Could not find bash.exe from git path:', gitExePath);
return null;
} catch (error) {
console.error('[AgentProcess] Error deriving git-bash path:', error);
return null;
}
}
/**
* Process spawning and lifecycle management
*/
export class AgentProcessManager {
private state: AgentState;
private events: AgentEvents;
private emitter: EventEmitter;
// Python path will be configured by pythonEnvManager after venv is ready
// Use null to indicate not yet configured - getPythonPath() will use fallback
private _pythonPath: string | null = null;
private autoBuildSourcePath: string = '';
constructor(state: AgentState, events: AgentEvents, emitter: EventEmitter) {
this.state = state;
this.events = events;
this.emitter = emitter;
}
configure(pythonPath?: string, autoBuildSourcePath?: string): void {
if (pythonPath) {
const validation = validatePythonPath(pythonPath);
if (validation.valid) {
this._pythonPath = validation.sanitizedPath || pythonPath;
} else {
console.error(`[AgentProcess] Invalid Python path rejected: ${validation.reason}`);
console.error(`[AgentProcess] Falling back to getConfiguredPythonPath()`);
// Don't set _pythonPath - let getPythonPath() use getConfiguredPythonPath() fallback
}
}
if (autoBuildSourcePath) {
this.autoBuildSourcePath = autoBuildSourcePath;
}
}
private setupProcessEnvironment(
extraEnv: Record<string, string>
): NodeJS.ProcessEnv {
const profileEnv = getProfileEnv();
// Use getAugmentedEnv() to ensure common tool paths (dotnet, homebrew, etc.)
// are available even when app is launched from Finder/Dock
const augmentedEnv = getAugmentedEnv();
// On Windows, detect and pass git-bash path for Claude Code CLI
// Electron can detect git via where.exe, but Python subprocess may not have the same PATH
const gitBashEnv: Record<string, string> = {};
if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) {
try {
const gitInfo = getToolInfo('git');
if (gitInfo.found && gitInfo.path) {
const bashPath = deriveGitBashPath(gitInfo.path);
if (bashPath) {
gitBashEnv['CLAUDE_CODE_GIT_BASH_PATH'] = bashPath;
console.log('[AgentProcess] Setting CLAUDE_CODE_GIT_BASH_PATH:', bashPath);
}
}
} catch (error) {
console.warn('[AgentProcess] Failed to detect git-bash path:', error);
}
}
// Detect and pass Claude CLI path to Python backend
// Common issue: Claude CLI installed via Homebrew at /opt/homebrew/bin/claude (macOS)
// or other non-standard locations not in subprocess PATH when app launches from Finder/Dock
const claudeCliEnv: Record<string, string> = {};
if (!process.env.CLAUDE_CLI_PATH) {
try {
const claudeInfo = getToolInfo('claude');
if (claudeInfo.found && claudeInfo.path) {
claudeCliEnv['CLAUDE_CLI_PATH'] = claudeInfo.path;
console.log('[AgentProcess] Setting CLAUDE_CLI_PATH:', claudeInfo.path, `(source: ${claudeInfo.source})`);
}
} catch (error) {
console.warn('[AgentProcess] Failed to detect Claude CLI path:', error);
}
}
return {
...augmentedEnv,
...gitBashEnv,
...claudeCliEnv,
...extraEnv,
...profileEnv,
PYTHONUNBUFFERED: '1',
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
} as NodeJS.ProcessEnv;
}
private handleProcessFailure(
taskId: string,
allOutput: string,
processType: ProcessType
): boolean {
console.log('[AgentProcess] Checking for rate limit in output (last 500 chars):', allOutput.slice(-500));
const rateLimitDetection = detectRateLimit(allOutput);
console.log('[AgentProcess] Rate limit detection result:', {
isRateLimited: rateLimitDetection.isRateLimited,
resetTime: rateLimitDetection.resetTime,
limitType: rateLimitDetection.limitType,
profileId: rateLimitDetection.profileId,
suggestedProfile: rateLimitDetection.suggestedProfile
});
if (rateLimitDetection.isRateLimited) {
const wasHandled = this.handleRateLimitWithAutoSwap(
taskId,
rateLimitDetection,
processType
);
if (wasHandled) return true;
const source = processType === 'spec-creation' ? 'roadmap' : 'task';
const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, { taskId });
console.log('[AgentProcess] Emitting sdk-rate-limit event (manual):', rateLimitInfo);
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
return true;
}
return this.handleAuthFailure(taskId, allOutput);
}
private handleRateLimitWithAutoSwap(
taskId: string,
rateLimitDetection: ReturnType<typeof detectRateLimit>,
processType: ProcessType
): boolean {
const profileManager = getClaudeProfileManager();
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
console.log('[AgentProcess] Auto-switch settings:', {
enabled: autoSwitchSettings.enabled,
autoSwitchOnRateLimit: autoSwitchSettings.autoSwitchOnRateLimit,
proactiveSwapEnabled: autoSwitchSettings.proactiveSwapEnabled
});
if (!autoSwitchSettings.enabled || !autoSwitchSettings.autoSwitchOnRateLimit) {
console.log('[AgentProcess] Auto-switch disabled - showing manual modal');
return false;
}
const currentProfileId = rateLimitDetection.profileId;
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
console.log('[AgentProcess] Best available profile:', bestProfile ? {
id: bestProfile.id,
name: bestProfile.name
} : 'NONE');
if (!bestProfile) {
console.log('[AgentProcess] No alternative profile available - falling back to manual modal');
return false;
}
console.log('[AgentProcess] AUTO-SWAP: Switching from', currentProfileId, 'to', bestProfile.id);
profileManager.setActiveProfile(bestProfile.id);
const source = processType === 'spec-creation' ? 'roadmap' : 'task';
const rateLimitInfo = createSDKRateLimitInfo(source, rateLimitDetection, { taskId });
rateLimitInfo.wasAutoSwapped = true;
rateLimitInfo.swappedToProfile = { id: bestProfile.id, name: bestProfile.name };
rateLimitInfo.swapReason = 'reactive';
console.log('[AgentProcess] Emitting sdk-rate-limit event (auto-swapped):', rateLimitInfo);
this.emitter.emit('sdk-rate-limit', rateLimitInfo);
console.log('[AgentProcess] Emitting auto-swap-restart-task event for task:', taskId);
this.emitter.emit('auto-swap-restart-task', taskId, bestProfile.id);
return true;
}
private handleAuthFailure(taskId: string, allOutput: string): boolean {
console.log('[AgentProcess] No rate limit detected - checking for auth failure');
const authFailureDetection = detectAuthFailure(allOutput);
if (authFailureDetection.isAuthFailure) {
console.log('[AgentProcess] Auth failure detected:', authFailureDetection);
this.emitter.emit('auth-failure', taskId, {
profileId: authFailureDetection.profileId,
failureType: authFailureDetection.failureType,
message: authFailureDetection.message,
originalError: authFailureDetection.originalError
});
return true;
}
console.log('[AgentProcess] Process failed but no rate limit or auth failure detected');
return false;
}
/**
* Get the configured Python path.
* Returns explicitly configured path, or falls back to getConfiguredPythonPath()
* which uses the venv Python if ready.
*/
getPythonPath(): string {
// If explicitly configured (by pythonEnvManager), use that
if (this._pythonPath) {
return this._pythonPath;
}
// Otherwise use the global configured path (venv if ready, else bundled/system)
return getConfiguredPythonPath();
}
/**
* Get the auto-claude source path (detects automatically if not configured)
*/
getAutoBuildSourcePath(): string | null {
// Use runners/spec_runner.py as the validation marker - this is the file actually needed
const validatePath = (p: string): boolean => {
return existsSync(p) && existsSync(path.join(p, 'runners', 'spec_runner.py'));
};
// If manually configured AND valid, use that
if (this.autoBuildSourcePath && validatePath(this.autoBuildSourcePath)) {
return this.autoBuildSourcePath;
}
// Auto-detect from app location (configured path was invalid or not set)
const possiblePaths = [
// Packaged app: backend is in extraResources (process.resourcesPath/backend)
...(app.isPackaged ? [path.join(process.resourcesPath, 'backend')] : []),
// Dev mode: from dist/main -> ../../backend (apps/frontend/out/main -> apps/backend)
path.resolve(__dirname, '..', '..', '..', 'backend'),
// Alternative: from app root -> apps/backend
path.resolve(app.getAppPath(), '..', 'backend'),
// If running from repo root with apps structure
path.resolve(process.cwd(), 'apps', 'backend')
];
for (const p of possiblePaths) {
if (validatePath(p)) {
return p;
}
}
return null;
}
/**
* Ensure Python environment is ready before spawning processes.
* This is a shared method used by AgentManager and AgentQueueManager
* to prevent race conditions where tasks start before venv initialization completes.
*
* @param context - Context identifier for logging (e.g., 'AgentManager', 'AgentQueue')
* @returns Object with ready status and optional error message
*/
async ensurePythonEnvReady(context: string): Promise<{ ready: boolean; error?: string }> {
if (pythonEnvManager.isEnvReady()) {
return { ready: true };
}
console.log(`[${context}] Python environment not ready, waiting for initialization...`);
const autoBuildSource = this.getAutoBuildSourcePath();
if (!autoBuildSource) {
const error = 'auto-build source not found';
console.error(`[${context}] Cannot initialize Python - ${error}`);
return { ready: false, error };
}
const status = await pythonEnvManager.initialize(autoBuildSource);
if (!status.ready) {
console.error(`[${context}] Python environment initialization failed:`, status.error);
return { ready: false, error: status.error || 'initialization failed' };
}
console.log(`[${context}] Python environment now ready`);
return { ready: true };
}
/**
* Get project-specific environment variables based on project settings
*/
private getProjectEnvVars(projectPath: string): Record<string, string> {
const env: Record<string, string> = {};
// Find project by path
const projects = projectStore.getProjects();
const project = projects.find((p) => p.path === projectPath);
if (project?.settings) {
// Graphiti MCP integration
if (project.settings.graphitiMcpEnabled) {
const graphitiUrl = project.settings.graphitiMcpUrl || 'http://localhost:8000/mcp/';
env['GRAPHITI_MCP_URL'] = graphitiUrl;
}
// CLAUDE.md integration (enabled by default)
if (project.settings.useClaudeMd !== false) {
env['USE_CLAUDE_MD'] = 'true';
}
}
return env;
}
/**
* Parse environment variables from a .env file content.
* Filters out empty values to prevent overriding valid tokens from profiles.
*/
private parseEnvFile(envPath: string): Record<string, string> {
if (!existsSync(envPath)) {
return {};
}
try {
const envContent = readFileSync(envPath, 'utf-8');
const envVars: Record<string, string> = {};
// Handle both Unix (\n) and Windows (\r\n) line endings
for (const line of envContent.split(/\r?\n/)) {
const trimmed = line.trim();
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Skip empty values to prevent overriding valid values from other sources
if (value) {
envVars[key] = value;
}
}
}
return envVars;
} catch {
return {};
}
}
/**
* Load environment variables from project's .auto-claude/.env file
* This contains frontend-configured settings like memory/Graphiti configuration
*/
private loadProjectEnv(projectPath: string): Record<string, string> {
// Find project by path to get autoBuildPath
const projects = projectStore.getProjects();
const project = projects.find((p) => p.path === projectPath);
if (!project?.autoBuildPath) {
return {};
}
const envPath = path.join(projectPath, project.autoBuildPath, '.env');
return this.parseEnvFile(envPath);
}
/**
* Load environment variables from auto-claude .env file
*/
loadAutoBuildEnv(): Record<string, string> {
const autoBuildSource = this.getAutoBuildSourcePath();
if (!autoBuildSource) {
return {};
}
const envPath = path.join(autoBuildSource, '.env');
return this.parseEnvFile(envPath);
}
/**
* Spawn a Python process for task execution
*/
async spawnProcess(
taskId: string,
cwd: string,
args: string[],
extraEnv: Record<string, string> = {},
processType: ProcessType = 'task-execution'
): Promise<void> {
const isSpecRunner = processType === 'spec-creation';
this.killProcess(taskId);
const spawnId = this.state.generateSpawnId();
const env = this.setupProcessEnvironment(extraEnv);
// Get Python environment (PYTHONPATH for bundled packages, etc.)
const pythonEnv = pythonEnvManager.getPythonEnv();
// Get active API profile environment variables
let apiProfileEnv: Record<string, string> = {};
try {
apiProfileEnv = await getAPIProfileEnv();
} catch (error) {
console.error('[Agent Process] Failed to get API profile env:', error);
// Continue with empty profile env (falls back to OAuth mode)
}
// Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode)
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
// Parse Python commandto handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath());
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
cwd,
env: {
...env, // Already includes process.env, extraEnv, profileEnv, PYTHONUNBUFFERED, PYTHONUTF8
...pythonEnv, // Include Python environment (PYTHONPATH for bundled packages)
...oauthModeClearVars, // Clear stale ANTHROPIC_* vars when in OAuth mode
...apiProfileEnv // Include active API profile config (highest priority for ANTHROPIC_* vars)
}
});
this.state.addProcess(taskId, {
taskId,
process: childProcess,
startedAt: new Date(),
spawnId
});
let currentPhase: ExecutionProgressData['phase'] = isSpecRunner ? 'planning' : 'planning';
let phaseProgress = 0;
let currentSubtask: string | undefined;
let lastMessage: string | undefined;
let allOutput = '';
let stdoutBuffer = '';
let stderrBuffer = '';
let sequenceNumber = 0;
// FIX (ACS-203): Track completed phases to prevent phase overlaps
// When a phase completes, it's added to this array before transitioning to the next phase
const completedPhases: CompletablePhase[] = [];
this.emitter.emit('execution-progress', taskId, {
phase: currentPhase,
phaseProgress: 0,
overallProgress: this.events.calculateOverallProgress(currentPhase, 0),
message: isSpecRunner ? 'Starting spec creation...' : 'Starting build process...',
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
const isDebug = ['true', '1', 'yes', 'on'].includes(process.env.DEBUG?.toLowerCase() ?? '');
const processLog = (line: string) => {
allOutput = (allOutput + line).slice(-10000);
const hasMarker = line.includes('__EXEC_PHASE__');
if (isDebug && hasMarker) {
console.log(`[PhaseDebug:${taskId}] Found marker in line: "${line.substring(0, 200)}"`);
}
const phaseUpdate = this.events.parseExecutionPhase(line, currentPhase, isSpecRunner);
if (isDebug && hasMarker) {
console.log(`[PhaseDebug:${taskId}] Parse result:`, phaseUpdate);
}
if (phaseUpdate) {
const phaseChanged = phaseUpdate.phase !== currentPhase;
if (isDebug) {
console.log(`[PhaseDebug:${taskId}] Phase update: ${currentPhase} -> ${phaseUpdate.phase} (changed: ${phaseChanged})`);
}
// FIX (ACS-203): Manage completedPhases when phases transition
// When leaving a non-terminal phase (not complete/failed), add it to completedPhases
if (phaseChanged && currentPhase !== 'idle' && currentPhase !== phaseUpdate.phase) {
// Type guard to narrow currentPhase to CompletablePhase
const isCompletablePhase = (phase: ExecutionProgressData['phase']): phase is CompletablePhase => {
return ['planning', 'coding', 'qa_review', 'qa_fixing'].includes(phase);
};
if (isCompletablePhase(currentPhase) && !completedPhases.includes(currentPhase)) {
completedPhases.push(currentPhase);
if (isDebug) {
console.log(`[PhaseDebug:${taskId}] Marked phase as completed:`, { phase: currentPhase, completedPhases });
}
}
}
currentPhase = phaseUpdate.phase;
if (phaseUpdate.currentSubtask) {
currentSubtask = phaseUpdate.currentSubtask;
}
if (phaseUpdate.message) {
lastMessage = phaseUpdate.message;
}
if (phaseChanged) {
phaseProgress = 10;
} else {
phaseProgress = Math.min(90, phaseProgress + 5);
}
const overallProgress = this.events.calculateOverallProgress(currentPhase, phaseProgress);
if (isDebug) {
console.log(`[PhaseDebug:${taskId}] Emitting execution-progress:`, { phase: currentPhase, phaseProgress, overallProgress, completedPhases });
}
this.emitter.emit('execution-progress', taskId, {
phase: currentPhase,
phaseProgress,
overallProgress,
currentSubtask,
message: lastMessage,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}
};
const processBufferedOutput = (buffer: string, newData: string): string => {
if (isDebug && newData.includes('__EXEC_PHASE__')) {
console.log(`[PhaseDebug:${taskId}] Raw chunk with marker (${newData.length} bytes): "${newData.substring(0, 300)}"`);
console.log(`[PhaseDebug:${taskId}] Current buffer before append (${buffer.length} bytes): "${buffer.substring(0, 100)}"`);
}
buffer += newData;
const lines = buffer.split('\n');
const remaining = lines.pop() || '';
if (isDebug && newData.includes('__EXEC_PHASE__')) {
console.log(`[PhaseDebug:${taskId}] Split into ${lines.length} complete lines, remaining buffer: "${remaining.substring(0, 100)}"`);
}
for (const line of lines) {
if (line.trim()) {
this.emitter.emit('log', taskId, line + '\n');
processLog(line);
if (isDebug) {
console.log(`[Agent:${taskId}] ${line}`);
}
}
}
return remaining;
};
childProcess.stdout?.on('data', (data: Buffer) => {
stdoutBuffer = processBufferedOutput(stdoutBuffer, data.toString('utf8'));
});
childProcess.stderr?.on('data', (data: Buffer) => {
stderrBuffer = processBufferedOutput(stderrBuffer, data.toString('utf8'));
});
childProcess.on('exit', (code: number | null) => {
if (stdoutBuffer.trim()) {
this.emitter.emit('log', taskId, stdoutBuffer + '\n');
processLog(stdoutBuffer);
}
if (stderrBuffer.trim()) {
this.emitter.emit('log', taskId, stderrBuffer + '\n');
processLog(stderrBuffer);
}
this.state.deleteProcess(taskId);
if (this.state.wasSpawnKilled(spawnId)) {
this.state.clearKilledSpawn(spawnId);
return;
}
if (code !== 0) {
console.log('[AgentProcess] Process failed with code:', code, 'for task:', taskId);
const wasHandled = this.handleProcessFailure(taskId, allOutput, processType);
if (wasHandled) {
this.emitter.emit('exit', taskId, code, processType);
return;
}
}
if (code !== 0 && currentPhase !== 'complete' && currentPhase !== 'failed') {
this.emitter.emit('execution-progress', taskId, {
phase: 'failed',
phaseProgress: 0,
overallProgress: this.events.calculateOverallProgress(currentPhase, phaseProgress),
message: `Process exited with code ${code}`,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
}
this.emitter.emit('exit', taskId, code, processType);
});
// Handle process error
childProcess.on('error', (err: Error) => {
console.error('[AgentProcess] Process error:', err.message);
this.state.deleteProcess(taskId);
this.emitter.emit('execution-progress', taskId, {
phase: 'failed',
phaseProgress: 0,
overallProgress: 0,
message: `Error: ${err.message}`,
sequenceNumber: ++sequenceNumber,
completedPhases: [...completedPhases]
});
this.emitter.emit('error', taskId, err.message);
});
}
/**
* Kill a specific task's process
*/
killProcess(taskId: string): boolean {
const agentProcess = this.state.getProcess(taskId);
if (agentProcess) {
try {
// Mark this specific spawn as killed so its exit handler knows to ignore
this.state.markSpawnAsKilled(agentProcess.spawnId);
// Send SIGTERM first for graceful shutdown
agentProcess.process.kill('SIGTERM');
// Force kill after timeout
setTimeout(() => {
if (!agentProcess.process.killed) {
agentProcess.process.kill('SIGKILL');
}
}, 5000);
this.state.deleteProcess(taskId);
return true;
} catch {
return false;
}
}
return false;
}
/**
* Kill all running processes
*/
async killAllProcesses(): Promise<void> {
const killPromises = this.state.getRunningTaskIds().map((taskId) => {
return new Promise<void>((resolve) => {
this.killProcess(taskId);
resolve();
});
});
await Promise.all(killPromises);
}
/**
* Get combined environment variables for a project
*
* Priority (later sources override earlier):
* 1. App-wide memory settings from settings.json (NEW - enables memory from onboarding)
* 2. Backend source .env (apps/backend/.env) - CLI defaults
* 3. Project's .auto-claude/.env - Frontend-configured settings (memory, integrations)
* 4. Project settings (graphitiMcpUrl, useClaudeMd) - Runtime overrides
*/
getCombinedEnv(projectPath: string): Record<string, string> {
// Load app-wide memory settings from settings.json
// This bridges onboarding config to backend agents
const appSettings = (readSettingsFile() || {}) as Partial<AppSettings>;
const memoryEnv = buildMemoryEnvVars(appSettings as AppSettings);
// Existing env sources
const autoBuildEnv = this.loadAutoBuildEnv();
const projectFileEnv = this.loadProjectEnv(projectPath);
const projectSettingsEnv = this.getProjectEnvVars(projectPath);
// Priority: app-wide memory -> backend .env -> project .env -> project settings
// Later sources override earlier ones
return { ...memoryEnv, ...autoBuildEnv, ...projectFileEnv, ...projectSettingsEnv };
}
}