mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat: add CPU limiting and Ralph Loop wizard plan generation
CPU Limiting: - Add CpuLimitConfig type and settings in App Settings - Use nice command for process priority (-20 to 19) - Optional cpulimit integration for hard CPU limits (if installed) - Applied to new sessions via screen-manager and session modules Ralph Loop Wizard: - 3-step wizard: Describe → Plan → Launch - New /api/generate-plan endpoint generates implementation steps via Claude - Plan editor with checkboxes, priorities (P0/P1/P2), reordering - Skip plan option for manual workflows - Compact wizard modal layout UI Updates: - Form section headers for grouped settings - CPU limit status indicator (cpulimit availability) - Wizard modal wider layout and inline fields Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+79
-3
@@ -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<ScreenSession> {
|
||||
async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell', name?: string, cpuLimitConfig?: CpuLimitConfig): Promise<ScreenSession> {
|
||||
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 {
|
||||
|
||||
+48
-3
@@ -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<CpuLimitConfig>): 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
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 = `
|
||||
<input type="checkbox" class="plan-item-checkbox" ${item.enabled ? 'checked' : ''}
|
||||
onchange="app.togglePlanItem(${index})">
|
||||
<input type="text" class="plan-item-content" value="${this.escapeHtml(item.content)}"
|
||||
onchange="app.updatePlanItemContent(${index}, this.value)"
|
||||
placeholder="Implementation step...">
|
||||
<select class="plan-item-priority" onchange="app.updatePlanItemPriority(${index}, this.value)">
|
||||
<option value="" ${!item.priority ? 'selected' : ''}>-</option>
|
||||
<option value="P0" ${item.priority === 'P0' ? 'selected' : ''}>P0</option>
|
||||
<option value="P1" ${item.priority === 'P1' ? 'selected' : ''}>P1</option>
|
||||
<option value="P2" ${item.priority === 'P2' ? 'selected' : ''}>P2</option>
|
||||
</select>
|
||||
<button class="plan-item-remove" onclick="app.removePlanItem(${index})" title="Remove">×</button>
|
||||
`;
|
||||
|
||||
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
|
||||
|
||||
+100
-42
@@ -782,6 +782,29 @@
|
||||
</label>
|
||||
<span class="form-hint">Auto-enable for new sessions (otherwise auto-enables on Ralph pattern detection)</span>
|
||||
</div>
|
||||
<!-- CPU Limiting Section -->
|
||||
<div class="form-section-header">CPU Limiting</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Enable CPU Limiting</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsCpuLimitEnabled">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Limit CPU usage for Claude sessions (reduces system impact, only affects new sessions)</span>
|
||||
</div>
|
||||
<div class="form-row" id="cpuLimitNiceRow">
|
||||
<label>Nice Value</label>
|
||||
<input type="number" id="appSettingsCpuNiceValue" min="-20" max="19" value="10" style="width: 80px;">
|
||||
<span class="form-hint">Process priority (-20 to 19, higher = lower priority, default: 10)</span>
|
||||
</div>
|
||||
<div class="form-row" id="cpuLimitPercentRow">
|
||||
<label>CPU Limit %</label>
|
||||
<div class="settings-item-actions">
|
||||
<input type="number" id="appSettingsCpuLimitPercent" min="1" max="100" value="80" style="width: 80px;">
|
||||
<span class="cpu-limit-status" id="cpuLimitStatus" title="cpulimit availability status"></span>
|
||||
</div>
|
||||
<span class="form-hint">Hard CPU limit (requires cpulimit installed, default: 80%)</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Paths Tab -->
|
||||
<div class="modal-tab-content hidden" id="settings-paths">
|
||||
@@ -921,7 +944,7 @@
|
||||
<!-- Ralph Loop Wizard Modal -->
|
||||
<div class="modal" id="ralphWizardModal">
|
||||
<div class="modal-backdrop" onclick="app.closeRalphWizard()"></div>
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-content modal-wizard">
|
||||
<div class="modal-header">
|
||||
<h3>Start Ralph Loop</h3>
|
||||
<button class="modal-close" onclick="app.closeRalphWizard()">×</button>
|
||||
@@ -931,73 +954,104 @@
|
||||
<div class="wizard-progress">
|
||||
<div class="wizard-step active" data-step="1">
|
||||
<span class="wizard-step-number">1</span>
|
||||
<span class="wizard-step-label">Task Setup</span>
|
||||
<span class="wizard-step-label">Describe</span>
|
||||
</div>
|
||||
<div class="wizard-step" data-step="2">
|
||||
<span class="wizard-step-number">2</span>
|
||||
<span class="wizard-step-label">Plan</span>
|
||||
</div>
|
||||
<div class="wizard-step" data-step="3">
|
||||
<span class="wizard-step-number">3</span>
|
||||
<span class="wizard-step-label">Launch</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- Step 1: Task & Completion Setup -->
|
||||
<div class="wizard-page" id="ralphWizardStep1">
|
||||
<div class="wizard-page wizard-page-compact" id="ralphWizardStep1">
|
||||
<div class="wizard-step-intro">
|
||||
<p>Describe your task below. Claude will generate an implementation plan with testing steps.</p>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Task Description</label>
|
||||
<label>What do you want to build?</label>
|
||||
<div class="textarea-with-assist">
|
||||
<textarea
|
||||
id="ralphTaskDescription"
|
||||
rows="6"
|
||||
placeholder="Describe what you want Claude to accomplish...
|
||||
|
||||
Example:
|
||||
Build a REST API for user authentication:
|
||||
- JWT token generation and validation
|
||||
- Login/logout endpoints
|
||||
- Password reset flow
|
||||
- Tests for all endpoints"></textarea>
|
||||
rows="4"
|
||||
placeholder="Build a REST API for user authentication with JWT tokens, login/logout endpoints, and password reset flow."></textarea>
|
||||
</div>
|
||||
<span class="form-hint">Be specific about requirements and success criteria</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Completion Phrase</label>
|
||||
<div class="phrase-input-group">
|
||||
<input type="text" id="ralphCompletionPhrase" value="COMPLETE" placeholder="COMPLETE" list="phrasesSuggestions">
|
||||
<datalist id="phrasesSuggestions">
|
||||
<option value="COMPLETE">
|
||||
<option value="DONE">
|
||||
<option value="FINISHED">
|
||||
<option value="TESTS_PASS">
|
||||
<option value="ALL_TASKS_COMPLETE">
|
||||
</datalist>
|
||||
<div class="wizard-inline-row">
|
||||
<div class="wizard-inline-field">
|
||||
<label>Working Directory</label>
|
||||
<div class="case-select-inline">
|
||||
<select id="ralphCaseSelect" class="form-select"></select>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.showCreateCaseModal()">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="form-hint">Claude outputs <code><promise>PHRASE</promise></code> when done</span>
|
||||
<div class="wizard-inline-field wizard-inline-field-small">
|
||||
<label>Iterations</label>
|
||||
<div class="iteration-presets-compact">
|
||||
<button type="button" class="iteration-preset-btn active" data-iterations="10" onclick="app.selectIterationPreset(10)">10</button>
|
||||
<button type="button" class="iteration-preset-btn" data-iterations="25" onclick="app.selectIterationPreset(25)">25</button>
|
||||
<button type="button" class="iteration-preset-btn" data-iterations="50" onclick="app.selectIterationPreset(50)">50</button>
|
||||
<button type="button" class="iteration-preset-btn" data-iterations="0" onclick="app.selectIterationPreset(0)">∞</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hidden completion phrase with default -->
|
||||
<input type="hidden" id="ralphCompletionPhrase" value="COMPLETE">
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Plan Generation -->
|
||||
<div class="wizard-page wizard-page-compact hidden" id="ralphWizardStep2">
|
||||
<!-- Loading state (shown immediately) -->
|
||||
<div id="planGenerationLoading" class="plan-loading-state">
|
||||
<div class="plan-loading-content">
|
||||
<div class="plan-spinner"></div>
|
||||
<div class="plan-loading-text">
|
||||
<span class="plan-loading-title">Generating implementation plan...</span>
|
||||
<span class="plan-loading-time" id="planLoadingTime">0s</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="plan-loading-hint">Claude is breaking down your task into actionable steps with tests</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Max Iterations</label>
|
||||
<div class="iteration-presets">
|
||||
<button type="button" class="iteration-preset-btn active" data-iterations="10" onclick="app.selectIterationPreset(10)">10</button>
|
||||
<button type="button" class="iteration-preset-btn" data-iterations="25" onclick="app.selectIterationPreset(25)">25</button>
|
||||
<button type="button" class="iteration-preset-btn" data-iterations="50" onclick="app.selectIterationPreset(50)">50</button>
|
||||
<button type="button" class="iteration-preset-btn" data-iterations="100" onclick="app.selectIterationPreset(100)">100</button>
|
||||
<button type="button" class="iteration-preset-btn" data-iterations="0" onclick="app.selectIterationPreset(0)">∞</button>
|
||||
<!-- Error state -->
|
||||
<div id="planGenerationError" class="plan-section hidden">
|
||||
<p class="plan-error-msg" id="planErrorMsg"></p>
|
||||
<div class="plan-actions">
|
||||
<button class="btn-toolbar btn-primary" onclick="app.generatePlan()">Retry</button>
|
||||
<button class="btn-toolbar" onclick="app.skipPlanGeneration()">Skip</button>
|
||||
</div>
|
||||
<span class="form-hint">Safety limit - loop stops after this many iterations</span>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label>Working Directory</label>
|
||||
<div class="case-select-inline">
|
||||
<select id="ralphCaseSelect" class="form-select"></select>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.showCreateCaseModal()">+ New</button>
|
||||
<!-- Editor state -->
|
||||
<div id="planEditor" class="plan-section hidden">
|
||||
<div class="plan-editor-header">
|
||||
<span>Implementation Plan</span>
|
||||
<div class="plan-editor-actions">
|
||||
<div class="plan-option-group">
|
||||
<label>Detail:</label>
|
||||
<div class="plan-detail-btns">
|
||||
<button type="button" class="plan-detail-btn" data-detail="brief" onclick="app.setPlanDetail('brief')">Brief</button>
|
||||
<button type="button" class="plan-detail-btn active" data-detail="standard" onclick="app.setPlanDetail('standard')">Standard</button>
|
||||
<button type="button" class="plan-detail-btn" data-detail="detailed" onclick="app.setPlanDetail('detailed')">Detailed</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-toolbar btn-sm" onclick="app.regeneratePlan()">Regenerate</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="planItemsList" class="plan-items-list"></div>
|
||||
<div class="plan-editor-footer">
|
||||
<button class="btn-toolbar btn-sm" onclick="app.addPlanItem()">+ Add Step</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Preview & Launch -->
|
||||
<div class="wizard-page hidden" id="ralphWizardStep2">
|
||||
<!-- Step 3: Preview & Launch -->
|
||||
<div class="wizard-page hidden" id="ralphWizardStep3">
|
||||
<div class="ralph-preview-section">
|
||||
<label>Prompt Preview</label>
|
||||
<div class="ralph-prompt-preview" id="ralphPromptPreview"></div>
|
||||
@@ -1016,6 +1070,10 @@ Build a REST API for user authentication:
|
||||
<span class="config-label">Case:</span>
|
||||
<span id="summaryCase">testcase</span>
|
||||
</div>
|
||||
<div class="config-summary-row" style="display: none;">
|
||||
<span class="config-label">Plan:</span>
|
||||
<span id="summaryPlan">-</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options (Collapsible) -->
|
||||
|
||||
+312
-2
@@ -2059,6 +2059,21 @@ body {
|
||||
}
|
||||
|
||||
/* Switch toggle for form rows */
|
||||
.form-section-header {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-section-header:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form-row-switch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2238,6 +2253,26 @@ body {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* CPU limit status indicator */
|
||||
.cpu-limit-status {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-dim);
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.cpu-limit-status.available {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.cpu-limit-status.unavailable {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -5331,6 +5366,281 @@ kbd {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Wizard Modal - wider layout */
|
||||
.modal-wizard {
|
||||
width: 720px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.wizard-page-compact {
|
||||
min-height: auto;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.wizard-step-intro {
|
||||
text-align: center;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.wizard-step-intro p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Wizard inline row for compact layout */
|
||||
.wizard-inline-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.wizard-inline-field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.wizard-inline-field-small {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.wizard-inline-field label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.iteration-presets-compact {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.iteration-presets-compact .iteration-preset-btn {
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
/* Plan Generation Step */
|
||||
.plan-section {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plan-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Plan Loading State */
|
||||
.plan-loading-state {
|
||||
padding: 1.5rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.plan-loading-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.plan-spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.plan-loading-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.plan-loading-title {
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.plan-loading-time {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
|
||||
.plan-loading-hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.plan-error-msg {
|
||||
color: var(--red);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Plan Options (detail level, model) */
|
||||
.plan-options {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.plan-option-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.plan-option-group label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.plan-option-group select {
|
||||
padding: 0.3rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.plan-detail-btns {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.plan-detail-btn {
|
||||
padding: 0.3rem 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.plan-detail-btn:hover {
|
||||
border-color: var(--border-light);
|
||||
}
|
||||
|
||||
.plan-detail-btn.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Plan Editor */
|
||||
.plan-editor-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.plan-editor-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.plan-items-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.plan-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.plan-item-checkbox {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.plan-item-content {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 0.2rem 0.4rem;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.plan-item-content:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.plan-item-priority {
|
||||
width: 50px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 0.2rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.plan-item-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 0.2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.plan-item-remove:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.plan-item:has(.plan-item-checkbox:not(:checked)) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.plan-item:has(.plan-item-checkbox:not(:checked)) .plan-item-content {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
#planEditor {
|
||||
text-align: left;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.plan-editor-footer {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Textarea with AI Assist button */
|
||||
.textarea-with-assist {
|
||||
position: relative;
|
||||
@@ -5338,7 +5648,7 @@ kbd {
|
||||
|
||||
.textarea-with-assist textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
padding: 0.6rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
@@ -5346,7 +5656,7 @@ kbd {
|
||||
font-size: 0.85rem;
|
||||
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
|
||||
resize: vertical;
|
||||
min-height: 140px;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.textarea-with-assist textarea:focus {
|
||||
|
||||
+200
-2
@@ -24,7 +24,7 @@ import { fileStreamManager } from '../file-stream-manager.js';
|
||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||||
import { SpawnOrchestrator, type SessionCreator } from '../spawn-orchestrator.js';
|
||||
import type { SpawnOrchestratorConfig } from '../spawn-types.js';
|
||||
import { ScreenManager } from '../screen-manager.js';
|
||||
import { ScreenManager, cpulimitAvailable } from '../screen-manager.js';
|
||||
import { getStore } from '../state-store.js';
|
||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
@@ -56,6 +56,8 @@ import {
|
||||
type QuickStartResponse,
|
||||
type CaseInfo,
|
||||
type PersistedRespawnConfig,
|
||||
type CpuLimitConfig,
|
||||
DEFAULT_CPU_LIMIT_CONFIG,
|
||||
} from '../types.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -518,12 +520,14 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
const body = req.body as CreateSessionRequest & { mode?: 'claude' | 'shell'; name?: string };
|
||||
const workingDir = body.workingDir || process.cwd();
|
||||
const globalCpuLimit = this.getGlobalCpuLimitConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
mode: body.mode || 'claude',
|
||||
name: body.name || '',
|
||||
screenManager: this.screenManager,
|
||||
useScreen: true
|
||||
useScreen: true,
|
||||
cpuLimitConfig: globalCpuLimit,
|
||||
});
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
@@ -1982,11 +1986,14 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
// Create a new session with the case as working directory
|
||||
// Apply global CPU limit config if enabled in settings
|
||||
const cpuLimitConfig = this.getGlobalCpuLimitConfig();
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
screenManager: this.screenManager,
|
||||
useScreen: true,
|
||||
mode: mode,
|
||||
cpuLimitConfig: cpuLimitConfig,
|
||||
});
|
||||
|
||||
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
|
||||
@@ -2045,6 +2052,106 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate implementation plan from task description using Claude
|
||||
interface GeneratePlanRequest {
|
||||
taskDescription: string;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
interface PlanItem {
|
||||
content: string;
|
||||
priority: 'P0' | 'P1' | 'P2' | null;
|
||||
}
|
||||
|
||||
this.app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
||||
const { taskDescription, maxItems = 8 } = req.body as GeneratePlanRequest;
|
||||
|
||||
if (!taskDescription || typeof taskDescription !== 'string') {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description is required');
|
||||
}
|
||||
|
||||
if (taskDescription.length > 10000) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description too long (max 10000 chars)');
|
||||
}
|
||||
|
||||
const prompt = `Break down this task into ${maxItems} or fewer actionable implementation steps.
|
||||
|
||||
TASK:
|
||||
${taskDescription}
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Return ONLY a JSON array. Each item must have:
|
||||
- content: specific, actionable step (verb phrase, 5-80 chars)
|
||||
- priority: "P0" (critical path), "P1" (standard), "P2" (nice-to-have), or null
|
||||
|
||||
Example output:
|
||||
[{"content": "Create auth database schema", "priority": "P0"}, {"content": "Implement JWT token generation", "priority": "P1"}]
|
||||
|
||||
RULES:
|
||||
- Start each step with a verb (Create, Implement, Add, Write, Configure, etc.)
|
||||
- Keep steps focused and achievable in one work session
|
||||
- Order by logical dependency (do first things first)
|
||||
- Use P0 sparingly for truly critical items`;
|
||||
|
||||
// Create temporary session for the AI call
|
||||
const session = new Session({
|
||||
workingDir: process.cwd(),
|
||||
screenManager: this.screenManager,
|
||||
useScreen: false, // No screen needed for one-shot
|
||||
mode: 'claude',
|
||||
});
|
||||
|
||||
try {
|
||||
const { result, cost } = await session.runPrompt(prompt);
|
||||
|
||||
// Parse JSON from result
|
||||
const jsonMatch = result.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan - no JSON array found');
|
||||
}
|
||||
|
||||
let items: PlanItem[];
|
||||
try {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array');
|
||||
}
|
||||
|
||||
// Validate and normalize items
|
||||
items = parsed.map((item: unknown, idx: number) => {
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return { content: `Step ${idx + 1}`, priority: null };
|
||||
}
|
||||
const obj = item as Record<string, unknown>;
|
||||
const content = typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`;
|
||||
let priority: 'P0' | 'P1' | 'P2' | null = null;
|
||||
if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') {
|
||||
priority = obj.priority;
|
||||
}
|
||||
return { content, priority };
|
||||
}).slice(0, maxItems);
|
||||
|
||||
} catch (parseErr) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan JSON: ' + getErrorMessage(parseErr));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { items, costUsd: cost },
|
||||
};
|
||||
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Plan generation failed: ' + getErrorMessage(err));
|
||||
} finally {
|
||||
// Clean up the temporary session
|
||||
try {
|
||||
await session.stop();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============ App Settings Endpoints ============
|
||||
const settingsPath = join(homedir(), '.claudeman', 'settings.json');
|
||||
|
||||
@@ -2086,6 +2193,64 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
});
|
||||
|
||||
// ============ CPU Limit Endpoints ============
|
||||
|
||||
// Get CPU limit system status (whether cpulimit is installed)
|
||||
this.app.get('/api/system/cpu-limit-status', async () => {
|
||||
return {
|
||||
success: true,
|
||||
cpulimitAvailable: cpulimitAvailable(),
|
||||
};
|
||||
});
|
||||
|
||||
// Get CPU limit config for a session
|
||||
this.app.get('/api/sessions/:id/cpu-limit', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
cpuLimit: session.cpuLimitConfig,
|
||||
cpulimitAvailable: cpulimitAvailable(),
|
||||
};
|
||||
});
|
||||
|
||||
// Update CPU limit config for a session
|
||||
// Note: Changes only apply to NEW sessions, not running ones
|
||||
this.app.post('/api/sessions/:id/cpu-limit', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
|
||||
}
|
||||
|
||||
const body = req.body as Partial<CpuLimitConfig>;
|
||||
|
||||
// Validate inputs
|
||||
if (body.niceValue !== undefined) {
|
||||
if (typeof body.niceValue !== 'number' || body.niceValue < -20 || body.niceValue > 19) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Nice value must be between -20 and 19');
|
||||
}
|
||||
}
|
||||
if (body.cpuLimitPercent !== undefined) {
|
||||
if (typeof body.cpuLimitPercent !== 'number' || body.cpuLimitPercent < 1 || body.cpuLimitPercent > 100) {
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CPU limit percent must be between 1 and 100');
|
||||
}
|
||||
}
|
||||
|
||||
session.setCpuLimit(body);
|
||||
this.persistSessionState(session);
|
||||
this.broadcast('session:updated', { session: session.toDetailedState() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
cpuLimit: session.cpuLimitConfig,
|
||||
note: 'CPU limiting only affects newly created screen sessions, not currently running ones.',
|
||||
};
|
||||
});
|
||||
|
||||
// ============ Subagent Window State Endpoints ============
|
||||
// Persists minimized/open window states for cross-browser sync
|
||||
const windowStatesPath = join(homedir(), '.claudeman', 'subagent-window-states.json');
|
||||
@@ -2955,12 +3120,14 @@ export class WebServer extends EventEmitter {
|
||||
private setupSpawnOrchestratorListeners(): void {
|
||||
const sessionCreator: SessionCreator = {
|
||||
createAgentSession: async (workingDir: string, name: string) => {
|
||||
const globalCpuLimit = this.getGlobalCpuLimitConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
screenManager: this.screenManager,
|
||||
useScreen: true,
|
||||
mode: 'claude',
|
||||
name: `spawn:${name}`,
|
||||
cpuLimitConfig: globalCpuLimit,
|
||||
});
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
@@ -3144,6 +3311,29 @@ export class WebServer extends EventEmitter {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Helper to get global CPU limit config from settings
|
||||
private getGlobalCpuLimitConfig(): CpuLimitConfig | undefined {
|
||||
const settingsPath = join(homedir(), '.claudeman', 'settings.json');
|
||||
|
||||
try {
|
||||
if (existsSync(settingsPath)) {
|
||||
const content = readFileSync(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
if (settings.cpuLimit && settings.cpuLimit.enabled) {
|
||||
return {
|
||||
enabled: settings.cpuLimit.enabled ?? false,
|
||||
niceValue: settings.cpuLimit.niceValue ?? DEFAULT_CPU_LIMIT_CONFIG.niceValue,
|
||||
cpuLimitPercent: settings.cpuLimit.cpuLimitPercent ?? DEFAULT_CPU_LIMIT_CONFIG.cpuLimitPercent,
|
||||
useCpulimitIfAvailable: settings.cpuLimit.useCpulimitIfAvailable ?? DEFAULT_CPU_LIMIT_CONFIG.useCpulimitIfAvailable,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to read CPU limit settings:', err);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write .mcp.json to a case directory for Claude Code to discover spawn MCP tools.
|
||||
*/
|
||||
@@ -3726,6 +3916,14 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
console.log(`[Server] Restored Ralph tracker for session ${session.id} (phrase: ${savedState.ralphCompletionPhrase || 'none'})`);
|
||||
}
|
||||
// CPU limit config
|
||||
if (savedState.cpuLimitEnabled !== undefined) {
|
||||
session.setCpuLimit({
|
||||
enabled: savedState.cpuLimitEnabled,
|
||||
niceValue: savedState.cpuLimitNiceValue,
|
||||
cpuLimitPercent: savedState.cpuLimitPercent,
|
||||
});
|
||||
}
|
||||
// Respawn controller
|
||||
if (savedState.respawnEnabled && savedState.respawnConfig) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user