diff --git a/src/screen-manager.ts b/src/screen-manager.ts index 4b42b0b8..15dd34b7 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -19,7 +19,7 @@ import { spawn, execSync } from 'node:child_process'; import { existsSync, readFileSync, mkdirSync, writeFile } from 'node:fs'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; -import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage } from './types.js'; +import { ScreenSession, ProcessStats, ScreenSessionWithStats, PersistedRespawnConfig, getErrorMessage, CpuLimitConfig, DEFAULT_CPU_LIMIT_CONFIG } from './types.js'; // ============================================================================ // Claude CLI PATH Resolution @@ -56,6 +56,70 @@ const DEFAULT_STATS_INTERVAL_MS = 2000; /** Maximum retry attempts for carriage return (3) */ const CR_MAX_ATTEMPTS = 3; +/** Cached result for cpulimit availability check */ +let _cpulimitAvailable: boolean | null = null; + +/** + * Checks if cpulimit is available on the system. + * Result is cached after the first check. + * + * @returns true if cpulimit is installed and accessible + */ +export function cpulimitAvailable(): boolean { + if (_cpulimitAvailable !== null) return _cpulimitAvailable; + + try { + execSync('which cpulimit', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }); + _cpulimitAvailable = true; + } catch { + _cpulimitAvailable = false; + } + + return _cpulimitAvailable; +} + +/** + * Wraps a command with CPU limiting options. + * + * Uses `nice` to set process priority and optionally `cpulimit` for + * hard CPU usage limits (if installed). + * + * @param cmd - The command to wrap + * @param config - CPU limit configuration + * @returns The wrapped command string + * + * @example + * ```typescript + * // With cpulimit installed: + * wrapWithCpuLimit('claude --dangerously-skip-permissions', { enabled: true, niceValue: 10, cpuLimitPercent: 80 }) + * // Returns: 'cpulimit -l 80 -f -- nice -n 10 claude --dangerously-skip-permissions' + * + * // Without cpulimit: + * wrapWithCpuLimit('claude --dangerously-skip-permissions', { enabled: true, niceValue: 10, cpuLimitPercent: 80 }) + * // Returns: 'nice -n 10 claude --dangerously-skip-permissions' + * ``` + */ +export function wrapWithCpuLimit(cmd: string, config: CpuLimitConfig): string { + if (!config.enabled) return cmd; + + // Clamp nice value to valid range (-20 to 19) + const niceValue = Math.max(-20, Math.min(19, config.niceValue)); + + // Clamp CPU limit to valid range (1-100) + const cpuLimit = Math.max(1, Math.min(100, config.cpuLimitPercent)); + + // Always apply nice for priority reduction + let wrappedCmd = `nice -n ${niceValue} ${cmd}`; + + // Add cpulimit if available and requested + if (config.useCpulimitIfAvailable && cpulimitAvailable()) { + // -l: limit percentage, -f: follow forked processes + wrappedCmd = `cpulimit -l ${cpuLimit} -f -- ${wrappedCmd}`; + } + + return wrappedCmd; +} + /** Cached directory containing the claude binary */ let _claudeDir: string | null = null; @@ -228,10 +292,11 @@ export class ScreenManager extends EventEmitter { * @param workingDir - Working directory for the screen session * @param mode - 'claude' for Claude CLI or 'shell' for bash * @param name - Optional display name for the session + * @param cpuLimitConfig - Optional CPU limiting configuration * @returns The created screen session metadata * @throws {Error} If screen creation fails */ - async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell', name?: string): Promise { + async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell', name?: string, cpuLimitConfig?: CpuLimitConfig): Promise { const screenName = `claudeman-${sessionId.slice(0, 8)}`; // Security: Validate screenName and workingDir to prevent command injection @@ -248,10 +313,16 @@ export class ScreenManager extends EventEmitter { const claudeDir = findClaudeDir(); const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : ''; const envVars = `CLAUDEMAN_SCREEN=1 CLAUDEMAN_SESSION_ID=${sessionId} CLAUDEMAN_SCREEN_NAME=${screenName} CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`; - const cmd = mode === 'claude' + + // Base command for the mode + let baseCmd = mode === 'claude' ? `${envVars} claude --dangerously-skip-permissions` : `${envVars} $SHELL`; + // Apply CPU limiting if configured + const cpuConfig = cpuLimitConfig || DEFAULT_CPU_LIMIT_CONFIG; + const cmd = wrapWithCpuLimit(baseCmd, cpuConfig); + try { // Start screen in detached mode const screenProcess = spawn('screen', [ @@ -694,6 +765,11 @@ export class ScreenManager extends EventEmitter { } } + // Check if cpulimit is available on the system (for hard CPU limiting) + static isCpulimitAvailable(): boolean { + return cpulimitAvailable(); + } + // Send input directly to screen session using screen -X stuff // This bypasses the attached PTY and sends input directly to the screen sendInput(sessionId: string, input: string): boolean { diff --git a/src/session.ts b/src/session.ts index 93ef6993..f8bfaa8d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -21,7 +21,7 @@ import { existsSync } from 'node:fs'; import { dirname } from 'node:path'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; -import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem, ActiveBashTool } from './types.js'; +import { SessionState, SessionStatus, SessionConfig, ScreenSession, RalphTrackerState, RalphTodoItem, ActiveBashTool, CpuLimitConfig, DEFAULT_CPU_LIMIT_CONFIG } from './types.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; @@ -435,6 +435,9 @@ export class Session extends EventEmitter { private _parentAgentId: string | null = null; private _childAgentIds: string[] = []; + // CPU limiting configuration + private _cpuLimitConfig: CpuLimitConfig = { ...DEFAULT_CPU_LIMIT_CONFIG }; + // Store handler references for cleanup (prevents memory leaks) private _taskTrackerHandlers: { taskCreated: (task: BackgroundTask) => void; @@ -472,6 +475,7 @@ export class Session extends EventEmitter { screenManager?: ScreenManager; useScreen?: boolean; screenSession?: ScreenSession; // For restored sessions - pass the existing screen + cpuLimitConfig?: CpuLimitConfig; // CPU limiting configuration }) { super(); this.id = config.id || uuidv4(); @@ -484,6 +488,11 @@ export class Session extends EventEmitter { this._useScreen = config.useScreen ?? (this._screenManager !== null && ScreenManager.isScreenAvailable()); this._screenSession = config.screenSession || null; // Use existing screen if provided + // Apply CPU limit configuration if provided + if (config.cpuLimitConfig) { + this._cpuLimitConfig = { ...config.cpuLimitConfig }; + } + // Initialize task tracker and forward events (store handlers for cleanup) this._taskTracker = new TaskTracker(); this._taskTrackerHandlers = { @@ -644,6 +653,32 @@ export class Session extends EventEmitter { if (idx >= 0) this._childAgentIds.splice(idx, 1); } + // CPU limit config getters and setters + get cpuLimitConfig(): CpuLimitConfig { + return { ...this._cpuLimitConfig }; + } + + /** + * Set CPU limit configuration. + * Note: This only affects new sessions; existing running processes won't be limited. + */ + setCpuLimit(config: Partial): void { + if (config.enabled !== undefined) { + this._cpuLimitConfig.enabled = config.enabled; + } + if (config.niceValue !== undefined) { + // Clamp to valid range + this._cpuLimitConfig.niceValue = Math.max(-20, Math.min(19, config.niceValue)); + } + if (config.cpuLimitPercent !== undefined) { + // Clamp to valid range + this._cpuLimitConfig.cpuLimitPercent = Math.max(1, Math.min(100, config.cpuLimitPercent)); + } + if (config.useCpulimitIfAvailable !== undefined) { + this._cpuLimitConfig.useCpulimitIfAvailable = config.useCpulimitIfAvailable; + } + } + // Token tracking getters and setters get totalTokens(): number { return this._totalInputTokens + this._totalOutputTokens; @@ -759,6 +794,9 @@ export class Session extends EventEmitter { ralphCompletionPhrase: this._ralphTracker.loopState.completionPhrase || undefined, parentAgentId: this._parentAgentId || undefined, childAgentIds: this._childAgentIds.length > 0 ? this._childAgentIds : undefined, + cpuLimitEnabled: this._cpuLimitConfig.enabled, + cpuLimitNiceValue: this._cpuLimitConfig.niceValue, + cpuLimitPercent: this._cpuLimitConfig.cpuLimitPercent, }; } @@ -796,6 +834,13 @@ export class Session extends EventEmitter { enabled: this._autoClearEnabled, threshold: this._autoClearThreshold, }, + // CPU limit configuration + cpuLimit: { + enabled: this._cpuLimitConfig.enabled, + niceValue: this._cpuLimitConfig.niceValue, + cpuLimitPercent: this._cpuLimitConfig.cpuLimitPercent, + useCpulimitIfAvailable: this._cpuLimitConfig.useCpulimitIfAvailable, + }, // Ralph tracking state ralphLoop: this._ralphTracker.loopState, ralphTodos: this._ralphTracker.todos, @@ -845,7 +890,7 @@ export class Session extends EventEmitter { console.log('[Session] Attaching to existing screen session:', this._screenSession!.screenName); } else { // Create a new screen session - this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'claude', this._name); + this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'claude', this._name, this._cpuLimitConfig); console.log('[Session] Created screen session:', this._screenSession.screenName); // Wait a moment for screen to fully start @@ -1051,7 +1096,7 @@ export class Session extends EventEmitter { console.log('[Session] Attaching to existing screen session:', this._screenSession!.screenName); } else { // Create a new screen session - this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'shell', this._name); + this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'shell', this._name, this._cpuLimitConfig); console.log('[Session] Created screen session:', this._screenSession.screenName); // Wait a moment for screen to fully start diff --git a/src/types.ts b/src/types.ts index 851ce05f..23ac601d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -86,6 +86,12 @@ export interface SessionState { parentAgentId?: string; /** Child agent IDs spawned by this session */ childAgentIds?: string[]; + /** CPU limiting enabled */ + cpuLimitEnabled?: boolean; + /** Nice value for CPU priority (-20 to 19) */ + cpuLimitNiceValue?: number; + /** CPU limit percentage (1-100) */ + cpuLimitPercent?: number; } // ========== Global Stats Types ========== @@ -233,6 +239,35 @@ export interface AppState { tokenStats?: TokenStats; } +// ========== CPU Limiting Types ========== + +/** + * Configuration for CPU limiting of Claude sessions. + * + * Uses `nice` (always available) to lower process priority and + * optionally `cpulimit` (if installed) for hard CPU usage limits. + */ +export interface CpuLimitConfig { + /** Whether CPU limiting is enabled */ + enabled: boolean; + /** Nice value (-20 to 19, default: 10 = lower priority) */ + niceValue: number; + /** CPU limit percentage (1-100, default: 80) */ + cpuLimitPercent: number; + /** Whether to use cpulimit if available (default: true) */ + useCpulimitIfAvailable: boolean; +} + +/** + * Default CPU limit configuration + */ +export const DEFAULT_CPU_LIMIT_CONFIG: CpuLimitConfig = { + enabled: false, + niceValue: 10, + cpuLimitPercent: 80, + useCpulimitIfAvailable: true, +}; + // ========== Respawn Controller Types ========== /** diff --git a/src/web/public/app.js b/src/web/public/app.js index ef725ffc..385ee2ee 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2601,6 +2601,246 @@ class ClaudemanApp { document.getElementById('summaryIterations').textContent = config.maxIterations === 0 ? 'Unlimited' : config.maxIterations; document.getElementById('summaryCase').textContent = config.caseName; + + // Show plan status in summary if plan was generated + const planSummary = document.getElementById('summaryPlan'); + if (planSummary) { + if (config.generatedPlan && config.generatedPlan.length > 0) { + const enabledCount = config.generatedPlan.filter(i => i.enabled).length; + planSummary.textContent = `${enabledCount} item${enabledCount !== 1 ? 's' : ''}`; + planSummary.parentElement.style.display = ''; + } else { + planSummary.parentElement.style.display = 'none'; + } + } + } + + // ========== Plan Generation ========== + + resetPlanGenerationUI() { + // Show initial controls, hide other states + document.getElementById('planGenerationControls')?.classList.remove('hidden'); + document.getElementById('planGenerationLoading')?.classList.add('hidden'); + document.getElementById('planGenerationError')?.classList.add('hidden'); + document.getElementById('planEditor')?.classList.add('hidden'); + } + + async generatePlan() { + const config = this.ralphWizardConfig; + + // Show loading state + document.getElementById('planGenerationControls')?.classList.add('hidden'); + document.getElementById('planGenerationError')?.classList.add('hidden'); + document.getElementById('planEditor')?.classList.add('hidden'); + document.getElementById('planGenerationLoading')?.classList.remove('hidden'); + + try { + const res = await fetch('/api/generate-plan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + taskDescription: config.taskDescription, + maxItems: 8, + }), + }); + + const data = await res.json(); + + if (!data.success) { + this.showPlanError(data.error || 'Failed to generate plan'); + return; + } + + if (!data.data?.items || data.data.items.length === 0) { + this.showPlanError('No plan items generated. Try adding more detail to your task.'); + return; + } + + // Store plan with enabled state and IDs + config.generatedPlan = data.data.items.map((item, idx) => ({ + ...item, + enabled: true, + id: `plan-${Date.now()}-${idx}`, + })); + config.planGenerated = true; + config.skipPlanGeneration = false; + + // Show editor + this.renderPlanEditor(); + + } catch (err) { + console.error('Plan generation failed:', err); + this.showPlanError('Network error: ' + err.message); + } + } + + showPlanError(message) { + document.getElementById('planGenerationLoading')?.classList.add('hidden'); + document.getElementById('planGenerationControls')?.classList.add('hidden'); + document.getElementById('planEditor')?.classList.add('hidden'); + + const errorEl = document.getElementById('planGenerationError'); + const msgEl = document.getElementById('planErrorMsg'); + if (msgEl) msgEl.textContent = message; + errorEl?.classList.remove('hidden'); + } + + renderPlanEditor() { + document.getElementById('planGenerationLoading')?.classList.add('hidden'); + document.getElementById('planGenerationControls')?.classList.add('hidden'); + document.getElementById('planGenerationError')?.classList.add('hidden'); + document.getElementById('planEditor')?.classList.remove('hidden'); + + const list = document.getElementById('planItemsList'); + if (!list) return; + + list.innerHTML = ''; + const items = this.ralphWizardConfig.generatedPlan || []; + + items.forEach((item, index) => { + const row = this.renderPlanItem(item, index); + list.appendChild(row); + }); + } + + renderPlanItem(item, index) { + const row = document.createElement('div'); + row.className = 'plan-item'; + row.dataset.index = index; + + row.innerHTML = ` + + + + + `; + + return row; + } + + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + togglePlanItem(index) { + const plan = this.ralphWizardConfig.generatedPlan; + if (plan && plan[index]) { + plan[index].enabled = !plan[index].enabled; + this.renderPlanEditor(); + } + } + + updatePlanItemContent(index, value) { + const plan = this.ralphWizardConfig.generatedPlan; + if (plan && plan[index]) { + plan[index].content = value.trim(); + } + } + + updatePlanItemPriority(index, value) { + const plan = this.ralphWizardConfig.generatedPlan; + if (plan && plan[index]) { + plan[index].priority = value || null; + } + } + + removePlanItem(index) { + const plan = this.ralphWizardConfig.generatedPlan; + if (plan) { + plan.splice(index, 1); + this.renderPlanEditor(); + } + } + + addPlanItem() { + const plan = this.ralphWizardConfig.generatedPlan || []; + plan.push({ + content: '', + priority: null, + enabled: true, + id: `plan-${Date.now()}-new`, + }); + this.ralphWizardConfig.generatedPlan = plan; + this.renderPlanEditor(); + + // Focus the new item's input + setTimeout(() => { + const list = document.getElementById('planItemsList'); + const lastInput = list?.querySelector('.plan-item:last-child .plan-item-content'); + lastInput?.focus(); + }, 50); + } + + skipPlanGeneration() { + this.ralphWizardConfig.skipPlanGeneration = true; + this.ralphWizardConfig.planGenerated = false; + this.ralphWizardConfig.generatedPlan = null; + + // Generate preview and go to step 3 + this.updateRalphPromptPreview(); + this.ralphWizardStep = 3; + this.updateRalphWizardUI(); + } + + regeneratePlan() { + this.ralphWizardConfig.generatedPlan = null; + this.ralphWizardConfig.planGenerated = false; + this.generatePlan(); + } + + generateFixPlanContent(items) { + // Group items by priority + const p0Items = items.filter(i => i.priority === 'P0'); + const p1Items = items.filter(i => i.priority === 'P1'); + const p2Items = items.filter(i => i.priority === 'P2'); + const noPriorityItems = items.filter(i => !i.priority); + + let content = '# Implementation Plan\n\n'; + content += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`; + + if (p0Items.length > 0) { + content += '## Critical Path (P0)\n\n'; + p0Items.forEach(item => { + content += `- [ ] ${item.content}\n`; + }); + content += '\n'; + } + + if (p1Items.length > 0) { + content += '## Standard (P1)\n\n'; + p1Items.forEach(item => { + content += `- [ ] ${item.content}\n`; + }); + content += '\n'; + } + + if (p2Items.length > 0) { + content += '## Nice-to-Have (P2)\n\n'; + p2Items.forEach(item => { + content += `- [ ] ${item.content}\n`; + }); + content += '\n'; + } + + if (noPriorityItems.length > 0) { + content += '## Tasks\n\n'; + noPriorityItems.forEach(item => { + content += `- [ ] ${item.content}\n`; + }); + content += '\n'; + } + + return content; } async startRalphLoop() { @@ -2653,6 +2893,22 @@ class ClaudemanApp { }) }); + // Step 2.5: Write @fix_plan.md if plan was generated + if (config.generatedPlan && config.generatedPlan.length > 0) { + const enabledItems = config.generatedPlan.filter(i => i.enabled); + if (enabledItems.length > 0) { + const planContent = this.generateFixPlanContent(enabledItems); + // Import the plan content + await fetch(`/api/sessions/${sessionId}/fix-plan/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: planContent }) + }); + // Write to disk + await fetch(`/api/sessions/${sessionId}/fix-plan/write`, { method: 'POST' }); + } + } + // Step 3: Enable respawn if requested if (config.enableRespawn) { await fetch(`/api/sessions/${sessionId}/respawn/enable`, { @@ -4296,6 +4552,13 @@ class ClaudemanApp { claudeModeSelect.onchange = () => { allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none'; }; + // CPU Limiting settings + const cpuLimitSettings = settings.cpuLimit || {}; + document.getElementById('appSettingsCpuLimitEnabled').checked = cpuLimitSettings.enabled ?? false; + document.getElementById('appSettingsCpuNiceValue').value = cpuLimitSettings.niceValue ?? 10; + document.getElementById('appSettingsCpuLimitPercent').value = cpuLimitSettings.cpuLimitPercent ?? 80; + // Check cpulimit availability and update status indicator + this.checkCpulimitAvailability(); // Notification settings const notifPrefs = this.notificationManager?.preferences || {}; document.getElementById('appSettingsNotifEnabled').checked = notifPrefs.enabled ?? true; @@ -4357,6 +4620,13 @@ class ClaudemanApp { // Claude CLI settings claudeMode: document.getElementById('appSettingsClaudeMode').value, allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(), + // CPU Limiting settings + cpuLimit: { + enabled: document.getElementById('appSettingsCpuLimitEnabled').checked, + niceValue: parseInt(document.getElementById('appSettingsCpuNiceValue').value) || 10, + cpuLimitPercent: parseInt(document.getElementById('appSettingsCpuLimitPercent').value) || 80, + useCpulimitIfAvailable: true, + }, }; // Save to localStorage @@ -4417,6 +4687,36 @@ class ClaudemanApp { return {}; } + // Check if cpulimit is available and update the UI status indicator + async checkCpulimitAvailability() { + const statusEl = document.getElementById('cpuLimitStatus'); + const percentInput = document.getElementById('appSettingsCpuLimitPercent'); + if (!statusEl) return; + + try { + const res = await fetch('/api/system/cpu-limit-status'); + if (res.ok) { + const data = await res.json(); + if (data.cpulimitAvailable) { + statusEl.textContent = '\u2713'; // Checkmark + statusEl.title = 'cpulimit is installed'; + statusEl.classList.add('available'); + statusEl.classList.remove('unavailable'); + if (percentInput) percentInput.disabled = false; + } else { + statusEl.textContent = '\u2717'; // X mark + statusEl.title = 'cpulimit not installed (install with: apt install cpulimit)'; + statusEl.classList.add('unavailable'); + statusEl.classList.remove('available'); + // Don't disable input, still allow setting the value for when it gets installed + } + } + } catch (err) { + statusEl.textContent = '?'; + statusEl.title = 'Could not check cpulimit availability'; + } + } + applyHeaderVisibilitySettings() { const settings = this.loadAppSettingsFromStorage(); // Default all to true (enabled) if not set diff --git a/src/web/public/index.html b/src/web/public/index.html index 136690bb..8566bc69 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -782,6 +782,29 @@ Auto-enable for new sessions (otherwise auto-enables on Ralph pattern detection) + +
CPU Limiting
+
+ + + Limit CPU usage for Claude sessions (reduces system impact, only affects new sessions) +
+
+ + + Process priority (-20 to 19, higher = lower priority, default: 10) +
+
+ +
+ + +
+ Hard CPU limit (requires cpulimit installed, default: 80%) +