mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 01:09:43 +02:00
fix: Ralph Loop wizard screen creation and session idle detection
- Fix screen creation failing when using cpulimit/nice (env vars were passed as command args instead of shell variables) - Fix session status never becoming 'idle' after startInteractive() - Add automatic state file cleanup on startup (removes orphaned sessions) - Add /api/cleanup-state endpoint for manual cleanup - Make frontend session readiness check more robust (20 attempts, multiple checks) - Add fallback to direct PTY write if writeViaScreen fails Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ Claudeman is a Claude Code session manager with a web interface and autonomous R
|
|||||||
|
|
||||||
When adding new features, always ask: "Will this maintain responsiveness with 20 sessions and 50 agent windows?"
|
When adding new features, always ask: "Will this maintain responsiveness with 20 sessions and 50 agent windows?"
|
||||||
|
|
||||||
**Version**: 0.1394 (must match `package.json`)
|
**Version**: 0.1395 (must match `package.json`)
|
||||||
|
|
||||||
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"name": "claudeman",
|
||||||
"version": "0.1394",
|
"version": "0.1395",
|
||||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
+86
-33
@@ -312,12 +312,21 @@ export class ScreenManager extends EventEmitter {
|
|||||||
// This helps prevent Claude from attempting to kill its own screen session
|
// This helps prevent Claude from attempting to kill its own screen session
|
||||||
const claudeDir = findClaudeDir();
|
const claudeDir = findClaudeDir();
|
||||||
const pathExport = claudeDir ? `export PATH="${claudeDir}:$PATH" && ` : '';
|
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'}`;
|
|
||||||
|
|
||||||
// Base command for the mode
|
// Environment variables must be exported, not passed inline to nice/cpulimit
|
||||||
let baseCmd = mode === 'claude'
|
// Using inline VAR=value before cpulimit/nice doesn't work because they try to
|
||||||
? `${envVars} claude --dangerously-skip-permissions`
|
// execute VAR=value as a command
|
||||||
: `${envVars} $SHELL`;
|
const envExports = [
|
||||||
|
'export CLAUDEMAN_SCREEN=1',
|
||||||
|
`export CLAUDEMAN_SESSION_ID=${sessionId}`,
|
||||||
|
`export CLAUDEMAN_SCREEN_NAME=${screenName}`,
|
||||||
|
`export CLAUDEMAN_API_URL=${process.env.CLAUDEMAN_API_URL || 'http://localhost:3000'}`,
|
||||||
|
].join(' && ');
|
||||||
|
|
||||||
|
// Base command for the mode (just the executable, env vars are exported separately)
|
||||||
|
const baseCmd = mode === 'claude'
|
||||||
|
? 'claude --dangerously-skip-permissions'
|
||||||
|
: '$SHELL';
|
||||||
|
|
||||||
// Apply CPU limiting if configured
|
// Apply CPU limiting if configured
|
||||||
const cpuConfig = cpuLimitConfig || DEFAULT_CPU_LIMIT_CONFIG;
|
const cpuConfig = cpuLimitConfig || DEFAULT_CPU_LIMIT_CONFIG;
|
||||||
@@ -325,10 +334,13 @@ export class ScreenManager extends EventEmitter {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Start screen in detached mode
|
// Start screen in detached mode
|
||||||
|
// Order: cd to dir, export PATH, export env vars, then run command
|
||||||
|
const fullCmd = `cd "${workingDir}" && ${pathExport}${envExports} && ${cmd}`;
|
||||||
|
|
||||||
const screenProcess = spawn('screen', [
|
const screenProcess = spawn('screen', [
|
||||||
'-dmS', screenName,
|
'-dmS', screenName,
|
||||||
'-c', '/dev/null', // Use empty config
|
'-c', '/dev/null', // Use empty config
|
||||||
'bash', '-c', `${pathExport}cd "${workingDir}" && ${cmd}`
|
'bash', '-c', fullCmd
|
||||||
], {
|
], {
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
detached: true,
|
detached: true,
|
||||||
@@ -618,24 +630,57 @@ export class ScreenManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get all screens with stats (batched for better performance)
|
// Get all screens with stats (batched for better performance)
|
||||||
|
// Now includes child process stats (the actual claude processes inside screens)
|
||||||
async getScreensWithStats(): Promise<ScreenSessionWithStats[]> {
|
async getScreensWithStats(): Promise<ScreenSessionWithStats[]> {
|
||||||
const screens = Array.from(this.screens.values());
|
const screens = Array.from(this.screens.values());
|
||||||
if (screens.length === 0) {
|
if (screens.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch all PIDs into a single ps call for better performance
|
const screenPids = screens.map(s => s.pid);
|
||||||
const pids = screens.map(s => s.pid);
|
|
||||||
const statsMap = new Map<number, ProcessStats>();
|
const statsMap = new Map<number, ProcessStats>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Single ps call for all PIDs
|
// Step 1: Get all descendant PIDs for each screen process
|
||||||
const psOutput = execSync(
|
// This captures the claude process and any children it spawns
|
||||||
`ps -o pid=,rss=,pcpu= -p ${pids.join(',')} 2>/dev/null || true`,
|
const descendantMap = new Map<number, number[]>(); // screenPid -> [childPids]
|
||||||
|
|
||||||
|
// Use pgrep to get all descendants recursively for each screen
|
||||||
|
const pgrepOutput = execSync(
|
||||||
|
`for p in ${screenPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`,
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||||
).trim();
|
).trim();
|
||||||
|
|
||||||
// Parse output - each line: "PID RSS CPU"
|
for (const line of pgrepOutput.split('\n')) {
|
||||||
|
const [pidStr, childrenStr] = line.split(':');
|
||||||
|
const screenPid = parseInt(pidStr, 10);
|
||||||
|
if (!isNaN(screenPid)) {
|
||||||
|
const children = (childrenStr || '')
|
||||||
|
.split(',')
|
||||||
|
.map(s => parseInt(s.trim(), 10))
|
||||||
|
.filter(n => !isNaN(n) && n > 0);
|
||||||
|
descendantMap.set(screenPid, children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Collect all PIDs we need stats for (screens + all their children)
|
||||||
|
const allPids = new Set<number>(screenPids);
|
||||||
|
for (const children of descendantMap.values()) {
|
||||||
|
for (const child of children) {
|
||||||
|
allPids.add(child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Single ps call for ALL PIDs (screens + children)
|
||||||
|
const pidArray = Array.from(allPids);
|
||||||
|
if (pidArray.length > 0) {
|
||||||
|
const psOutput = execSync(
|
||||||
|
`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`,
|
||||||
|
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
// Parse individual process stats
|
||||||
|
const processStats = new Map<number, { rss: number; cpu: number }>();
|
||||||
for (const line of psOutput.split('\n')) {
|
for (const line of psOutput.split('\n')) {
|
||||||
const parts = line.trim().split(/\s+/);
|
const parts = line.trim().split(/\s+/);
|
||||||
if (parts.length >= 3) {
|
if (parts.length >= 3) {
|
||||||
@@ -643,31 +688,36 @@ export class ScreenManager extends EventEmitter {
|
|||||||
const rss = parseFloat(parts[1]) || 0;
|
const rss = parseFloat(parts[1]) || 0;
|
||||||
const cpu = parseFloat(parts[2]) || 0;
|
const cpu = parseFloat(parts[2]) || 0;
|
||||||
if (!isNaN(pid)) {
|
if (!isNaN(pid)) {
|
||||||
statsMap.set(pid, {
|
processStats.set(pid, { rss, cpu });
|
||||||
memoryMB: Math.round(rss / 1024 * 10) / 10,
|
}
|
||||||
cpuPercent: Math.round(cpu * 10) / 10,
|
}
|
||||||
childCount: 0,
|
}
|
||||||
|
|
||||||
|
// Step 4: Aggregate stats for each screen (screen + all descendants)
|
||||||
|
for (const screenPid of screenPids) {
|
||||||
|
const children = descendantMap.get(screenPid) || [];
|
||||||
|
const screenStats = processStats.get(screenPid) || { rss: 0, cpu: 0 };
|
||||||
|
|
||||||
|
// Sum up stats from all children
|
||||||
|
let totalRss = screenStats.rss;
|
||||||
|
let totalCpu = screenStats.cpu;
|
||||||
|
|
||||||
|
for (const childPid of children) {
|
||||||
|
const childStats = processStats.get(childPid);
|
||||||
|
if (childStats) {
|
||||||
|
totalRss += childStats.rss;
|
||||||
|
totalCpu += childStats.cpu;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statsMap.set(screenPid, {
|
||||||
|
memoryMB: Math.round(totalRss / 1024 * 10) / 10,
|
||||||
|
cpuPercent: Math.round(totalCpu * 10) / 10,
|
||||||
|
childCount: children.length,
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Batch child count query - single pgrep call
|
|
||||||
const pgrepOutput = execSync(
|
|
||||||
`for p in ${pids.join(' ')}; do echo "$p $(pgrep -P $p 2>/dev/null | wc -l)"; done`,
|
|
||||||
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
for (const line of pgrepOutput.split('\n')) {
|
|
||||||
const [pidStr, countStr] = line.trim().split(/\s+/);
|
|
||||||
const pid = parseInt(pidStr, 10);
|
|
||||||
const count = parseInt(countStr, 10) || 0;
|
|
||||||
const stats = statsMap.get(pid);
|
|
||||||
if (stats) {
|
|
||||||
stats.childCount = count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to individual queries if batch fails
|
// Fall back to individual queries if batch fails
|
||||||
const statsPromises = screens.map(screen => this.getProcessStats(screen.sessionId));
|
const statsPromises = screens.map(screen => this.getProcessStats(screen.sessionId));
|
||||||
@@ -775,9 +825,12 @@ export class ScreenManager extends EventEmitter {
|
|||||||
sendInput(sessionId: string, input: string): boolean {
|
sendInput(sessionId: string, input: string): boolean {
|
||||||
const screen = this.screens.get(sessionId);
|
const screen = this.screens.get(sessionId);
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
|
console.error(`[ScreenManager] sendInput failed: no screen found for session ${sessionId}. Known screens: ${Array.from(this.screens.keys()).join(', ')}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[ScreenManager] sendInput to ${screen.screenName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`);
|
||||||
|
|
||||||
// Security: Validate screenName to prevent command injection
|
// Security: Validate screenName to prevent command injection
|
||||||
if (!isValidScreenName(screen.screenName)) {
|
if (!isValidScreenName(screen.screenName)) {
|
||||||
console.error('[ScreenManager] Invalid screen name in sendInput:', screen.screenName);
|
console.error('[ScreenManager] Invalid screen name in sendInput:', screen.screenName);
|
||||||
|
|||||||
+8
-1
@@ -1006,8 +1006,14 @@ export class Session extends EventEmitter {
|
|||||||
// Reset activity timeout - if no activity for 2 seconds after prompt, Claude is idle
|
// Reset activity timeout - if no activity for 2 seconds after prompt, Claude is idle
|
||||||
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
if (this.activityTimeout) clearTimeout(this.activityTimeout);
|
||||||
this.activityTimeout = setTimeout(() => {
|
this.activityTimeout = setTimeout(() => {
|
||||||
if (this._isWorking) {
|
// Emit idle if either:
|
||||||
|
// 1. Claude was working and is now at prompt (normal case)
|
||||||
|
// 2. Session just started and is ready (status is 'busy' but _isWorking is false)
|
||||||
|
const wasWorking = this._isWorking;
|
||||||
|
const isInitialReady = this._status === 'busy' && !this._isWorking;
|
||||||
|
if (wasWorking || isInitialReady) {
|
||||||
this._isWorking = false;
|
this._isWorking = false;
|
||||||
|
this._status = 'idle';
|
||||||
this._lastPromptTime = Date.now();
|
this._lastPromptTime = Date.now();
|
||||||
this.emit('idle');
|
this.emit('idle');
|
||||||
}
|
}
|
||||||
@@ -1021,6 +1027,7 @@ export class Session extends EventEmitter {
|
|||||||
data.includes('⠴') || data.includes('⠦') || data.includes('⠧')) {
|
data.includes('⠴') || data.includes('⠦') || data.includes('⠧')) {
|
||||||
if (!this._isWorking) {
|
if (!this._isWorking) {
|
||||||
this._isWorking = true;
|
this._isWorking = true;
|
||||||
|
this._status = 'busy';
|
||||||
this.emit('working');
|
this.emit('working');
|
||||||
}
|
}
|
||||||
// Reset timeout since Claude is active
|
// Reset timeout since Claude is active
|
||||||
|
|||||||
@@ -177,6 +177,32 @@ export class StateStore {
|
|||||||
this.save();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up stale sessions from state that don't have corresponding active sessions.
|
||||||
|
* @param activeSessionIds - Set of currently active session IDs
|
||||||
|
* @returns Number of sessions cleaned up
|
||||||
|
*/
|
||||||
|
cleanupStaleSessions(activeSessionIds: Set<string>): number {
|
||||||
|
const allSessionIds = Object.keys(this.state.sessions);
|
||||||
|
let cleanedCount = 0;
|
||||||
|
|
||||||
|
for (const sessionId of allSessionIds) {
|
||||||
|
if (!activeSessionIds.has(sessionId)) {
|
||||||
|
delete this.state.sessions[sessionId];
|
||||||
|
// Also clean up Ralph state for this session
|
||||||
|
this.ralphStates.delete(sessionId);
|
||||||
|
cleanedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleanedCount > 0) {
|
||||||
|
console.log(`[StateStore] Cleaned up ${cleanedCount} stale session(s) from state`);
|
||||||
|
this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedCount;
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns all task states keyed by task ID. */
|
/** Returns all task states keyed by task ID. */
|
||||||
getTasks() {
|
getTasks() {
|
||||||
return this.state.tasks;
|
return this.state.tasks;
|
||||||
|
|||||||
+280
-39
@@ -2467,6 +2467,9 @@ class ClaudemanApp {
|
|||||||
planGenerated: false,
|
planGenerated: false,
|
||||||
skipPlanGeneration: false,
|
skipPlanGeneration: false,
|
||||||
planDetailLevel: 'standard',
|
planDetailLevel: 'standard',
|
||||||
|
// Existing plan detection
|
||||||
|
existingPlan: null, // { todos, stats, content } from @fix_plan.md
|
||||||
|
useExistingPlan: false, // User chose to use existing plan
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reset UI
|
// Reset UI
|
||||||
@@ -2480,6 +2483,9 @@ class ClaudemanApp {
|
|||||||
// Reset plan generation UI
|
// Reset plan generation UI
|
||||||
this.resetPlanGenerationUI();
|
this.resetPlanGenerationUI();
|
||||||
|
|
||||||
|
// Check for existing @fix_plan.md in selected case
|
||||||
|
this.checkExistingFixPlan();
|
||||||
|
|
||||||
// Show wizard modal
|
// Show wizard modal
|
||||||
this.updateRalphWizardUI();
|
this.updateRalphWizardUI();
|
||||||
document.getElementById('ralphWizardModal').classList.add('active');
|
document.getElementById('ralphWizardModal').classList.add('active');
|
||||||
@@ -2510,6 +2516,121 @@ class ClaudemanApp {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for existing @fix_plan.md in the selected case
|
||||||
|
async checkExistingFixPlan() {
|
||||||
|
const caseName = this.ralphWizardConfig.caseName;
|
||||||
|
if (!caseName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/cases/${encodeURIComponent(caseName)}/fix-plan`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success && data.exists && data.todos?.length > 0) {
|
||||||
|
this.ralphWizardConfig.existingPlan = {
|
||||||
|
todos: data.todos,
|
||||||
|
stats: data.stats,
|
||||||
|
content: data.content,
|
||||||
|
};
|
||||||
|
this.updateExistingPlanUI();
|
||||||
|
} else {
|
||||||
|
this.ralphWizardConfig.existingPlan = null;
|
||||||
|
this.updateExistingPlanUI();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check for existing plan:', err);
|
||||||
|
this.ralphWizardConfig.existingPlan = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called when case selector changes
|
||||||
|
onRalphCaseChange() {
|
||||||
|
const caseName = document.getElementById('ralphCaseSelect')?.value;
|
||||||
|
if (caseName) {
|
||||||
|
this.ralphWizardConfig.caseName = caseName;
|
||||||
|
this.ralphWizardConfig.existingPlan = null;
|
||||||
|
this.ralphWizardConfig.useExistingPlan = false;
|
||||||
|
this.checkExistingFixPlan();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI to show existing plan indicator
|
||||||
|
updateExistingPlanUI() {
|
||||||
|
const existingPlanBadge = document.getElementById('existingPlanBadge');
|
||||||
|
const existingPlanSection = document.getElementById('existingPlanSection');
|
||||||
|
const plan = this.ralphWizardConfig.existingPlan;
|
||||||
|
|
||||||
|
if (existingPlanBadge) {
|
||||||
|
if (plan) {
|
||||||
|
const pending = plan.stats?.pending || 0;
|
||||||
|
const total = plan.stats?.total || 0;
|
||||||
|
existingPlanBadge.textContent = `${pending}/${total} tasks remaining`;
|
||||||
|
existingPlanBadge.style.display = '';
|
||||||
|
} else {
|
||||||
|
existingPlanBadge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingPlanSection) {
|
||||||
|
if (plan) {
|
||||||
|
const pending = plan.stats?.pending || 0;
|
||||||
|
const completed = plan.stats?.completed || 0;
|
||||||
|
const total = plan.stats?.total || 0;
|
||||||
|
existingPlanSection.innerHTML = `
|
||||||
|
<div class="existing-plan-card">
|
||||||
|
<div class="existing-plan-header">
|
||||||
|
<span class="existing-plan-icon">📋</span>
|
||||||
|
<span>Existing @fix_plan.md found</span>
|
||||||
|
</div>
|
||||||
|
<div class="existing-plan-stats">
|
||||||
|
<span class="stat pending">${pending} pending</span>
|
||||||
|
<span class="stat completed">${completed} completed</span>
|
||||||
|
<span class="stat total">${total} total</span>
|
||||||
|
</div>
|
||||||
|
<div class="existing-plan-actions">
|
||||||
|
<button class="btn-toolbar btn-primary btn-sm" onclick="app.useExistingPlan()">
|
||||||
|
Use Existing Plan
|
||||||
|
</button>
|
||||||
|
<button class="btn-toolbar btn-sm" onclick="app.generateNewPlan()">
|
||||||
|
Generate New
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
existingPlanSection.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
existingPlanSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the existing @fix_plan.md
|
||||||
|
useExistingPlan() {
|
||||||
|
const plan = this.ralphWizardConfig.existingPlan;
|
||||||
|
if (!plan) return;
|
||||||
|
|
||||||
|
// Convert existing todos to generatedPlan format (only pending items)
|
||||||
|
const pendingTodos = plan.todos.filter(t => t.status === 'pending' || t.status === 'in_progress');
|
||||||
|
this.ralphWizardConfig.generatedPlan = pendingTodos.map((todo, idx) => ({
|
||||||
|
content: todo.content,
|
||||||
|
priority: todo.priority,
|
||||||
|
enabled: true,
|
||||||
|
id: `existing-${Date.now()}-${idx}`,
|
||||||
|
}));
|
||||||
|
this.ralphWizardConfig.planGenerated = true;
|
||||||
|
this.ralphWizardConfig.useExistingPlan = true;
|
||||||
|
this.ralphWizardConfig.planCost = 0; // No cost for existing plan
|
||||||
|
|
||||||
|
this.renderPlanEditor();
|
||||||
|
this.updateDetailLevelButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new plan (ignore existing)
|
||||||
|
generateNewPlan() {
|
||||||
|
this.ralphWizardConfig.useExistingPlan = false;
|
||||||
|
document.getElementById('existingPlanSection')?.classList.add('hidden');
|
||||||
|
this.generatePlan();
|
||||||
|
}
|
||||||
|
|
||||||
ralphWizardNext() {
|
ralphWizardNext() {
|
||||||
if (this.ralphWizardStep === 1) {
|
if (this.ralphWizardStep === 1) {
|
||||||
// Validate step 1
|
// Validate step 1
|
||||||
@@ -2528,12 +2649,17 @@ class ClaudemanApp {
|
|||||||
this.ralphWizardConfig.completionPhrase = completionPhrase.toUpperCase();
|
this.ralphWizardConfig.completionPhrase = completionPhrase.toUpperCase();
|
||||||
this.ralphWizardConfig.caseName = caseName;
|
this.ralphWizardConfig.caseName = caseName;
|
||||||
|
|
||||||
// Move to step 2 (plan generation) and auto-start
|
// Move to step 2 (plan generation)
|
||||||
this.ralphWizardStep = 2;
|
this.ralphWizardStep = 2;
|
||||||
this.updateRalphWizardUI();
|
this.updateRalphWizardUI();
|
||||||
|
|
||||||
// Auto-start plan generation
|
// If there's an existing plan, show the option to use it
|
||||||
|
// Otherwise auto-start plan generation
|
||||||
|
if (this.ralphWizardConfig.existingPlan) {
|
||||||
|
this.updateExistingPlanUI();
|
||||||
|
} else {
|
||||||
this.generatePlan();
|
this.generatePlan();
|
||||||
|
}
|
||||||
} else if (this.ralphWizardStep === 2) {
|
} else if (this.ralphWizardStep === 2) {
|
||||||
// Must have generated or skipped plan
|
// Must have generated or skipped plan
|
||||||
if (!this.ralphWizardConfig.planGenerated && !this.ralphWizardConfig.skipPlanGeneration) {
|
if (!this.ralphWizardConfig.planGenerated && !this.ralphWizardConfig.skipPlanGeneration) {
|
||||||
@@ -2584,22 +2710,31 @@ class ClaudemanApp {
|
|||||||
updateRalphPromptPreview() {
|
updateRalphPromptPreview() {
|
||||||
const config = this.ralphWizardConfig;
|
const config = this.ralphWizardConfig;
|
||||||
const preview = document.getElementById('ralphPromptPreview');
|
const preview = document.getElementById('ralphPromptPreview');
|
||||||
|
const hasPlan = config.generatedPlan && config.generatedPlan.filter(i => i.enabled).length > 0;
|
||||||
|
|
||||||
// Build the formatted prompt
|
// Build the formatted prompt (abbreviated for preview)
|
||||||
let prompt = config.taskDescription;
|
let prompt = config.taskDescription;
|
||||||
|
|
||||||
// Add completion instructions
|
|
||||||
prompt += '\n\n---\n\n';
|
prompt += '\n\n---\n\n';
|
||||||
prompt += '## Completion Criteria\n\n';
|
|
||||||
prompt += 'When ALL requirements above are complete:\n';
|
if (hasPlan) {
|
||||||
prompt += '1. Run all tests to verify\n';
|
prompt += '## Task Plan\n';
|
||||||
prompt += '2. Commit your changes\n';
|
prompt += '📋 @fix_plan.md will be created with your task items\n\n';
|
||||||
prompt += `3. Output: <promise>${config.completionPhrase}</promise>\n`;
|
}
|
||||||
|
|
||||||
|
prompt += '## Iteration Protocol\n';
|
||||||
|
prompt += '• Check previous work • Make progress • Commit changes\n\n';
|
||||||
|
|
||||||
|
prompt += '## Completion Criteria\n';
|
||||||
|
prompt += `Output \`<promise>${config.completionPhrase}</promise>\` when done\n\n`;
|
||||||
|
|
||||||
|
prompt += '## If Stuck\n';
|
||||||
|
prompt += 'Output `<promise>BLOCKED</promise>` with explanation';
|
||||||
|
|
||||||
// Show preview with highlighting
|
// Show preview with highlighting
|
||||||
const highlightedPrompt = prompt
|
const highlightedPrompt = prompt
|
||||||
.replace(/<promise>/g, '<span class="preview-highlight"><promise>')
|
.replace(/<promise>/g, '<span class="preview-highlight"><promise>')
|
||||||
.replace(/<\/promise>/g, '</promise></span>');
|
.replace(/<\/promise>/g, '</promise></span>')
|
||||||
|
.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||||
|
|
||||||
preview.innerHTML = highlightedPrompt;
|
preview.innerHTML = highlightedPrompt;
|
||||||
|
|
||||||
@@ -2625,11 +2760,19 @@ class ClaudemanApp {
|
|||||||
// ========== Plan Generation ==========
|
// ========== Plan Generation ==========
|
||||||
|
|
||||||
resetPlanGenerationUI() {
|
resetPlanGenerationUI() {
|
||||||
// Show initial controls, hide other states
|
// Hide all plan generation states
|
||||||
document.getElementById('planGenerationControls')?.classList.remove('hidden');
|
document.getElementById('existingPlanSection')?.classList.add('hidden');
|
||||||
document.getElementById('planGenerationLoading')?.classList.add('hidden');
|
document.getElementById('planGenerationLoading')?.classList.add('hidden');
|
||||||
document.getElementById('planGenerationError')?.classList.add('hidden');
|
document.getElementById('planGenerationError')?.classList.add('hidden');
|
||||||
document.getElementById('planEditor')?.classList.add('hidden');
|
document.getElementById('planEditor')?.classList.add('hidden');
|
||||||
|
|
||||||
|
// Reset spinner visibility (in case it was hidden after "Done!")
|
||||||
|
const spinnerEl = document.querySelector('.plan-spinner');
|
||||||
|
if (spinnerEl) spinnerEl.style.display = '';
|
||||||
|
|
||||||
|
// Reset existing plan badge
|
||||||
|
const badge = document.getElementById('existingPlanBadge');
|
||||||
|
if (badge) badge.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
async generatePlan() {
|
async generatePlan() {
|
||||||
@@ -2645,7 +2788,8 @@ class ClaudemanApp {
|
|||||||
this.planPhaseTimer = null;
|
this.planPhaseTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state, hide other sections
|
||||||
|
document.getElementById('existingPlanSection')?.classList.add('hidden');
|
||||||
document.getElementById('planGenerationError')?.classList.add('hidden');
|
document.getElementById('planGenerationError')?.classList.add('hidden');
|
||||||
document.getElementById('planEditor')?.classList.add('hidden');
|
document.getElementById('planEditor')?.classList.add('hidden');
|
||||||
document.getElementById('planGenerationLoading')?.classList.remove('hidden');
|
document.getElementById('planGenerationLoading')?.classList.remove('hidden');
|
||||||
@@ -2689,21 +2833,12 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// Determine max items based on detail level
|
|
||||||
const detailConfig = {
|
|
||||||
brief: { maxItems: 10 },
|
|
||||||
standard: { maxItems: 15 },
|
|
||||||
detailed: { maxItems: 20 },
|
|
||||||
};
|
|
||||||
const { maxItems } = detailConfig[config.planDetailLevel] || detailConfig.standard;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/generate-plan', {
|
const res = await fetch('/api/generate-plan', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
taskDescription: config.taskDescription,
|
taskDescription: config.taskDescription,
|
||||||
maxItems,
|
|
||||||
detailLevel: config.planDetailLevel,
|
detailLevel: config.planDetailLevel,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -2726,6 +2861,17 @@ class ClaudemanApp {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show "Done!" briefly before transitioning
|
||||||
|
const titleEl = document.getElementById('planLoadingTitle');
|
||||||
|
const hintEl = document.getElementById('planLoadingHint');
|
||||||
|
const spinnerEl = document.querySelector('.plan-spinner');
|
||||||
|
if (titleEl) titleEl.textContent = 'Done!';
|
||||||
|
if (hintEl) hintEl.textContent = `Generated ${data.data.items.length} steps`;
|
||||||
|
if (spinnerEl) spinnerEl.style.display = 'none';
|
||||||
|
|
||||||
|
// Brief pause to show "Done!" before showing editor
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
// Store plan with enabled state and IDs
|
// Store plan with enabled state and IDs
|
||||||
config.generatedPlan = data.data.items.map((item, idx) => ({
|
config.generatedPlan = data.data.items.map((item, idx) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -2734,6 +2880,7 @@ class ClaudemanApp {
|
|||||||
}));
|
}));
|
||||||
config.planGenerated = true;
|
config.planGenerated = true;
|
||||||
config.skipPlanGeneration = false;
|
config.skipPlanGeneration = false;
|
||||||
|
config.planCost = data.data.costUsd || 0;
|
||||||
|
|
||||||
// Show editor and update detail buttons
|
// Show editor and update detail buttons
|
||||||
this.renderPlanEditor();
|
this.renderPlanEditor();
|
||||||
@@ -2784,6 +2931,13 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
list.innerHTML = '';
|
list.innerHTML = '';
|
||||||
const items = this.ralphWizardConfig.generatedPlan || [];
|
const items = this.ralphWizardConfig.generatedPlan || [];
|
||||||
|
const cost = this.ralphWizardConfig.planCost || 0;
|
||||||
|
|
||||||
|
// Update header with item count and cost
|
||||||
|
const statsEl = document.getElementById('planStats');
|
||||||
|
if (statsEl) {
|
||||||
|
statsEl.textContent = `${items.length} steps · $${cost.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const row = this.renderPlanItem(item, index);
|
const row = this.renderPlanItem(item, index);
|
||||||
@@ -2950,14 +3104,46 @@ class ClaudemanApp {
|
|||||||
// Close wizard
|
// Close wizard
|
||||||
this.closeRalphWizard();
|
this.closeRalphWizard();
|
||||||
|
|
||||||
// Build the full prompt
|
// Determine if we have a task plan
|
||||||
|
const hasPlan = config.generatedPlan && config.generatedPlan.filter(i => i.enabled).length > 0;
|
||||||
|
|
||||||
|
// Build the full prompt following Ralph Wiggum methodology
|
||||||
let fullPrompt = config.taskDescription;
|
let fullPrompt = config.taskDescription;
|
||||||
fullPrompt += '\n\n---\n\n';
|
fullPrompt += '\n\n---\n\n';
|
||||||
|
|
||||||
|
// Add @fix_plan.md reference if plan was generated
|
||||||
|
if (hasPlan) {
|
||||||
|
fullPrompt += '## Task Plan\n\n';
|
||||||
|
fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n';
|
||||||
|
fullPrompt += '- Reference the plan at the start of each iteration\n';
|
||||||
|
fullPrompt += '- Update task checkboxes as you complete items\n';
|
||||||
|
fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPrompt += '## Iteration Protocol\n\n';
|
||||||
|
fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n';
|
||||||
|
fullPrompt += '1. Check what work has already been done\n';
|
||||||
|
fullPrompt += '2. Make incremental progress toward completion\n';
|
||||||
|
fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n';
|
||||||
|
|
||||||
|
fullPrompt += '## Verification\n\n';
|
||||||
|
fullPrompt += 'After each significant change:\n';
|
||||||
|
fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n';
|
||||||
|
fullPrompt += '- Check for type/lint errors if applicable\n';
|
||||||
|
fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n';
|
||||||
|
|
||||||
fullPrompt += '## Completion Criteria\n\n';
|
fullPrompt += '## Completion Criteria\n\n';
|
||||||
fullPrompt += 'When ALL requirements above are complete:\n';
|
fullPrompt += `Output \`<promise>${config.completionPhrase}</promise>\` when ALL of the following are true:\n`;
|
||||||
fullPrompt += '1. Run all tests to verify\n';
|
fullPrompt += '- All requirements from the task description are implemented\n';
|
||||||
fullPrompt += '2. Commit your changes\n';
|
fullPrompt += '- All tests pass\n';
|
||||||
fullPrompt += `3. Output: <promise>${config.completionPhrase}</promise>\n`;
|
fullPrompt += '- Changes are committed\n\n';
|
||||||
|
|
||||||
|
fullPrompt += '## If Stuck\n\n';
|
||||||
|
fullPrompt += 'If you encounter the same error for 3+ iterations:\n';
|
||||||
|
fullPrompt += '1. Document what you\'ve tried\n';
|
||||||
|
fullPrompt += '2. Identify the specific blocker\n';
|
||||||
|
fullPrompt += '3. Try an alternative approach\n';
|
||||||
|
fullPrompt += '4. If truly blocked, output `<promise>BLOCKED</promise>` with an explanation\n';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Create/verify case exists and start session
|
// Step 1: Create/verify case exists and start session
|
||||||
@@ -2996,12 +3182,20 @@ class ClaudemanApp {
|
|||||||
const enabledItems = config.generatedPlan.filter(i => i.enabled);
|
const enabledItems = config.generatedPlan.filter(i => i.enabled);
|
||||||
if (enabledItems.length > 0) {
|
if (enabledItems.length > 0) {
|
||||||
const planContent = this.generateFixPlanContent(enabledItems);
|
const planContent = this.generateFixPlanContent(enabledItems);
|
||||||
// Import the plan content
|
// Import the plan content and get the todos back
|
||||||
await fetch(`/api/sessions/${sessionId}/fix-plan/import`, {
|
const importRes = await fetch(`/api/sessions/${sessionId}/fix-plan/import`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: planContent })
|
body: JSON.stringify({ content: planContent })
|
||||||
});
|
});
|
||||||
|
const importData = await importRes.json();
|
||||||
|
|
||||||
|
// Update local ralph state immediately to avoid 100% display bug
|
||||||
|
// (SSE event might arrive after selectSession is called)
|
||||||
|
if (importData.success && importData.data?.todos) {
|
||||||
|
this.updateRalphState(sessionId, { todos: importData.data.todos });
|
||||||
|
}
|
||||||
|
|
||||||
// Write to disk
|
// Write to disk
|
||||||
await fetch(`/api/sessions/${sessionId}/fix-plan/write`, { method: 'POST' });
|
await fetch(`/api/sessions/${sessionId}/fix-plan/write`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
@@ -3023,16 +3217,58 @@ class ClaudemanApp {
|
|||||||
// Step 4: Switch to session and wait for it to be ready
|
// Step 4: Switch to session and wait for it to be ready
|
||||||
await this.selectSession(sessionId);
|
await this.selectSession(sessionId);
|
||||||
|
|
||||||
// Wait a moment for session to be ready, then send the prompt
|
// Wait for Claude CLI to be ready (shows prompt)
|
||||||
await new Promise(r => setTimeout(r, 2000));
|
// Check session status multiple times - Claude CLI can take a while to initialize
|
||||||
|
let attempts = 0;
|
||||||
|
const maxAttempts = 20; // 10 seconds total
|
||||||
|
let sessionReady = false;
|
||||||
|
console.log('[RalphWizard] Waiting for session to become ready...');
|
||||||
|
while (attempts < maxAttempts) {
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
try {
|
||||||
|
const statusRes = await fetch(`/api/sessions/${sessionId}`);
|
||||||
|
const statusData = await statusRes.json();
|
||||||
|
// Session is ready if:
|
||||||
|
// 1. Status is explicitly 'idle', OR
|
||||||
|
// 2. isWorking is false (Claude is at prompt), OR
|
||||||
|
// 3. Terminal buffer contains the prompt character
|
||||||
|
const isIdle = statusData?.status === 'idle';
|
||||||
|
const notWorking = statusData?.isWorking === false;
|
||||||
|
const hasPrompt = statusData?.terminalBuffer?.includes('❯');
|
||||||
|
if (isIdle || notWorking || hasPrompt) {
|
||||||
|
console.log(`[RalphWizard] Session ready after ${attempts + 1} attempts (idle=${isIdle}, notWorking=${notWorking}, hasPrompt=${hasPrompt})`);
|
||||||
|
sessionReady = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[RalphWizard] Error checking session status:', e);
|
||||||
|
}
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
// Send the prompt to start the loop
|
if (!sessionReady) {
|
||||||
await fetch(`/api/sessions/${sessionId}/input`, {
|
console.warn(`[RalphWizard] Session did not become ready after ${maxAttempts} attempts, sending prompt anyway`);
|
||||||
|
this.showToast('Session took longer than expected to initialize, sending prompt...', 'warning');
|
||||||
|
// Add extra delay to give Claude CLI more time
|
||||||
|
await new Promise(r => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the prompt to start the loop using writeViaScreen (more reliable)
|
||||||
|
// Use \r for Enter (carriage return), not \n (newline)
|
||||||
|
console.log('[RalphWizard] Sending prompt to session...');
|
||||||
|
const inputRes = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ input: fullPrompt + '\n' })
|
body: JSON.stringify({ input: fullPrompt + '\r', useScreen: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!inputRes.ok) {
|
||||||
|
const errorData = await inputRes.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `Failed to send input: ${inputRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[RalphWizard] Prompt sent successfully');
|
||||||
|
|
||||||
this.showToast(`Ralph Loop started in ${config.caseName}`, 'success');
|
this.showToast(`Ralph Loop started in ${config.caseName}`, 'success');
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -5929,16 +6165,20 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateRalphRing(percent) {
|
updateRalphRing(percent) {
|
||||||
|
// Ensure percent is a valid number between 0-100
|
||||||
|
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
|
||||||
|
|
||||||
// Mini ring (in summary)
|
// Mini ring (in summary)
|
||||||
const miniProgress = this.$('ralphRingMiniProgress');
|
const miniProgress = this.$('ralphRingMiniProgress');
|
||||||
const miniText = this.$('ralphRingMiniText');
|
const miniText = this.$('ralphRingMiniText');
|
||||||
if (miniProgress) {
|
if (miniProgress) {
|
||||||
// Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100
|
// Circumference = 2 * PI * r = 2 * PI * 15.9 ≈ 100
|
||||||
const offset = 100 - percent;
|
// offset = 100 means 0% visible, offset = 0 means 100% visible
|
||||||
|
const offset = 100 - safePercent;
|
||||||
miniProgress.style.strokeDashoffset = offset;
|
miniProgress.style.strokeDashoffset = offset;
|
||||||
}
|
}
|
||||||
if (miniText) {
|
if (miniText) {
|
||||||
miniText.textContent = `${percent}%`;
|
miniText.textContent = `${safePercent}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Large ring (in expanded view)
|
// Large ring (in expanded view)
|
||||||
@@ -5946,11 +6186,12 @@ class ClaudemanApp {
|
|||||||
const largePercent = this.$('ralphRingPercent');
|
const largePercent = this.$('ralphRingPercent');
|
||||||
if (largeProgress) {
|
if (largeProgress) {
|
||||||
// Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264
|
// Circumference = 2 * PI * r = 2 * PI * 42 ≈ 264
|
||||||
const offset = 264 - (264 * percent / 100);
|
// offset = 264 means 0% visible, offset = 0 means 100% visible
|
||||||
|
const offset = 264 - (264 * safePercent / 100);
|
||||||
largeProgress.style.strokeDashoffset = offset;
|
largeProgress.style.strokeDashoffset = offset;
|
||||||
}
|
}
|
||||||
if (largePercent) {
|
if (largePercent) {
|
||||||
largePercent.textContent = `${percent}%`;
|
largePercent.textContent = `${safePercent}%`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
|
<circle class="ralph-ring-bg" cx="18" cy="18" r="15.9" />
|
||||||
<circle class="ralph-ring-progress-mini" cx="18" cy="18" r="15.9" id="ralphRingMiniProgress" />
|
<circle class="ralph-ring-progress-mini" cx="18" cy="18" r="15.9" id="ralphRingMiniProgress" style="stroke-dashoffset: 100" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ralph-ring-text" id="ralphRingMiniText">0%</span>
|
<span class="ralph-ring-text" id="ralphRingMiniText">0%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
<circle class="ralph-ring-track" cx="50" cy="50" r="42" />
|
<circle class="ralph-ring-track" cx="50" cy="50" r="42" />
|
||||||
<circle class="ralph-ring-fill" cx="50" cy="50" r="42" id="ralphRingProgress" />
|
<circle class="ralph-ring-fill" cx="50" cy="50" r="42" id="ralphRingProgress" style="stroke-dashoffset: 264" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="ralph-ring-center">
|
<div class="ralph-ring-center">
|
||||||
<div class="ralph-ring-percent" id="ralphRingPercent">0%</div>
|
<div class="ralph-ring-percent" id="ralphRingPercent">0%</div>
|
||||||
@@ -984,9 +984,9 @@
|
|||||||
|
|
||||||
<div class="wizard-inline-row">
|
<div class="wizard-inline-row">
|
||||||
<div class="wizard-inline-field">
|
<div class="wizard-inline-field">
|
||||||
<label>Working Directory</label>
|
<label>Working Directory <span id="existingPlanBadge" class="existing-plan-badge" style="display:none"></span></label>
|
||||||
<div class="case-select-inline">
|
<div class="case-select-inline">
|
||||||
<select id="ralphCaseSelect" class="form-select"></select>
|
<select id="ralphCaseSelect" class="form-select" onchange="app.onRalphCaseChange()"></select>
|
||||||
<button class="btn-toolbar btn-sm" onclick="app.showCreateCaseModal()">+</button>
|
<button class="btn-toolbar btn-sm" onclick="app.showCreateCaseModal()">+</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1006,8 +1006,13 @@
|
|||||||
|
|
||||||
<!-- Step 2: Plan Generation -->
|
<!-- Step 2: Plan Generation -->
|
||||||
<div class="wizard-page wizard-page-compact hidden" id="ralphWizardStep2">
|
<div class="wizard-page wizard-page-compact hidden" id="ralphWizardStep2">
|
||||||
<!-- Loading state (shown immediately) -->
|
<!-- Existing plan detected (shown when @fix_plan.md exists) -->
|
||||||
<div id="planGenerationLoading" class="plan-loading-state">
|
<div id="existingPlanSection" class="plan-section hidden">
|
||||||
|
<!-- Content populated by app.updateExistingPlanUI() -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state (shown during generation) -->
|
||||||
|
<div id="planGenerationLoading" class="plan-loading-state hidden">
|
||||||
<div class="plan-loading-content">
|
<div class="plan-loading-content">
|
||||||
<div class="plan-spinner"></div>
|
<div class="plan-spinner"></div>
|
||||||
<div class="plan-loading-text">
|
<div class="plan-loading-text">
|
||||||
@@ -1031,7 +1036,7 @@
|
|||||||
<!-- Editor state -->
|
<!-- Editor state -->
|
||||||
<div id="planEditor" class="plan-section hidden">
|
<div id="planEditor" class="plan-section hidden">
|
||||||
<div class="plan-editor-header">
|
<div class="plan-editor-header">
|
||||||
<span>Implementation Plan</span>
|
<span>Implementation Plan <span class="plan-stats" id="planStats"></span></span>
|
||||||
<div class="plan-editor-actions">
|
<div class="plan-editor-actions">
|
||||||
<div class="plan-option-group">
|
<div class="plan-option-group">
|
||||||
<label>Detail:</label>
|
<label>Detail:</label>
|
||||||
|
|||||||
@@ -5433,6 +5433,68 @@ kbd {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Existing Plan Badge (in case selector) */
|
||||||
|
.existing-plan-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
padding: 0.1rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Existing Plan Card (Step 2) */
|
||||||
|
.existing-plan-card {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-plan-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-plan-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-plan-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-plan-stats .stat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-plan-stats .stat.pending {
|
||||||
|
color: var(--yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-plan-stats .stat.completed {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-plan-stats .stat.total {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-plan-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Plan Loading State */
|
/* Plan Loading State */
|
||||||
.plan-loading-state {
|
.plan-loading-state {
|
||||||
padding: 1.5rem 1rem;
|
padding: 1.5rem 1rem;
|
||||||
@@ -5563,6 +5625,13 @@ kbd {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.plan-stats {
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.plan-editor-actions {
|
.plan-editor-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
@@ -5572,7 +5641,7 @@ kbd {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
max-height: 160px;
|
max-height: 300px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+157
-13
@@ -417,6 +417,12 @@ export class WebServer extends EventEmitter {
|
|||||||
// API Routes
|
// API Routes
|
||||||
this.app.get('/api/status', async () => this.getFullState());
|
this.app.get('/api/status', async () => this.getFullState());
|
||||||
|
|
||||||
|
// Cleanup stale sessions from state file
|
||||||
|
this.app.post('/api/cleanup-state', async () => {
|
||||||
|
const cleaned = this.cleanupStaleSessions();
|
||||||
|
return { success: true, cleanedSessions: cleaned };
|
||||||
|
});
|
||||||
|
|
||||||
// Global stats endpoint
|
// Global stats endpoint
|
||||||
this.app.get('/api/stats', async () => {
|
this.app.get('/api/stats', async () => {
|
||||||
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
const activeSessionTokens: Record<string, { inputTokens?: number; outputTokens?: number; totalCost?: number }> = {};
|
||||||
@@ -1271,9 +1277,10 @@ export class WebServer extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Send input to interactive session
|
// Send input to interactive session
|
||||||
|
// useScreen: true uses writeViaScreen which is more reliable for programmatic input
|
||||||
this.app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
this.app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
const { input } = req.body as SessionInputRequest;
|
const { input, useScreen } = req.body as SessionInputRequest & { useScreen?: boolean };
|
||||||
const session = this.sessions.get(id);
|
const session = this.sessions.get(id);
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
@@ -1289,8 +1296,21 @@ export class WebServer extends EventEmitter {
|
|||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use writeViaScreen for programmatic input (more reliable for screen sessions)
|
||||||
|
let success = false;
|
||||||
|
if (useScreen) {
|
||||||
|
success = session.writeViaScreen(inputStr);
|
||||||
|
if (!success) {
|
||||||
|
console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`);
|
||||||
|
// Fallback to direct write if screen write fails
|
||||||
session.write(inputStr);
|
session.write(inputStr);
|
||||||
return { success: true };
|
success = true; // Direct write doesn't return status, assume success
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
session.write(inputStr);
|
||||||
|
success = true;
|
||||||
|
}
|
||||||
|
return { success };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resize session terminal
|
// Resize session terminal
|
||||||
@@ -1939,6 +1959,112 @@ export class WebServer extends EventEmitter {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Read @fix_plan.md from a case directory (for wizard to detect existing plans)
|
||||||
|
this.app.get('/api/cases/:name/fix-plan', async (req) => {
|
||||||
|
const { name } = req.params as { name: string };
|
||||||
|
|
||||||
|
// Get case path (check linked cases first, then casesDir)
|
||||||
|
let casePath: string | null = null;
|
||||||
|
|
||||||
|
const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json');
|
||||||
|
try {
|
||||||
|
if (existsSync(linkedCasesFile)) {
|
||||||
|
const linkedCases: Record<string, string> = JSON.parse(readFileSync(linkedCasesFile, 'utf-8'));
|
||||||
|
if (linkedCases[name]) {
|
||||||
|
casePath = linkedCases[name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!casePath) {
|
||||||
|
casePath = join(casesDir, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||||||
|
|
||||||
|
if (!existsSync(fixPlanPath)) {
|
||||||
|
return { success: true, exists: false, content: null, todos: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(fixPlanPath, 'utf-8');
|
||||||
|
|
||||||
|
// Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown)
|
||||||
|
const todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed'; priority: string | null }> = [];
|
||||||
|
const todoPattern = /^-\s*\[([ xX\-])\]\s*(.+)$/;
|
||||||
|
const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i;
|
||||||
|
const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i;
|
||||||
|
const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i;
|
||||||
|
const completedHeaderPattern = /^##\s*Completed/i;
|
||||||
|
|
||||||
|
let currentPriority: string | null = null;
|
||||||
|
let inCompletedSection = false;
|
||||||
|
|
||||||
|
for (const line of content.split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (p0HeaderPattern.test(trimmed)) {
|
||||||
|
currentPriority = 'P0';
|
||||||
|
inCompletedSection = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p1HeaderPattern.test(trimmed)) {
|
||||||
|
currentPriority = 'P1';
|
||||||
|
inCompletedSection = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p2HeaderPattern.test(trimmed)) {
|
||||||
|
currentPriority = 'P2';
|
||||||
|
inCompletedSection = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (completedHeaderPattern.test(trimmed)) {
|
||||||
|
inCompletedSection = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = trimmed.match(todoPattern);
|
||||||
|
if (match) {
|
||||||
|
const [, checkboxState, taskContent] = match;
|
||||||
|
let status: 'pending' | 'in_progress' | 'completed';
|
||||||
|
|
||||||
|
if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') {
|
||||||
|
status = 'completed';
|
||||||
|
} else if (checkboxState === '-') {
|
||||||
|
status = 'in_progress';
|
||||||
|
} else {
|
||||||
|
status = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
todos.push({
|
||||||
|
content: taskContent.trim(),
|
||||||
|
status,
|
||||||
|
priority: inCompletedSection ? null : currentPriority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: todos.length,
|
||||||
|
pending: todos.filter(t => t.status === 'pending').length,
|
||||||
|
inProgress: todos.filter(t => t.status === 'in_progress').length,
|
||||||
|
completed: todos.filter(t => t.status === 'completed').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
exists: true,
|
||||||
|
content,
|
||||||
|
todos,
|
||||||
|
stats,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Quick Start: Create case (if needed) and start interactive session in one click
|
// Quick Start: Create case (if needed) and start interactive session in one click
|
||||||
this.app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => {
|
this.app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => {
|
||||||
// Prevent unbounded session creation
|
// Prevent unbounded session creation
|
||||||
@@ -2055,7 +2181,6 @@ export class WebServer extends EventEmitter {
|
|||||||
// Generate implementation plan from task description using Claude
|
// Generate implementation plan from task description using Claude
|
||||||
interface GeneratePlanRequest {
|
interface GeneratePlanRequest {
|
||||||
taskDescription: string;
|
taskDescription: string;
|
||||||
maxItems?: number;
|
|
||||||
detailLevel?: 'brief' | 'standard' | 'detailed';
|
detailLevel?: 'brief' | 'standard' | 'detailed';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2067,7 +2192,6 @@ export class WebServer extends EventEmitter {
|
|||||||
this.app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
this.app.post('/api/generate-plan', async (req): Promise<ApiResponse> => {
|
||||||
const {
|
const {
|
||||||
taskDescription,
|
taskDescription,
|
||||||
maxItems = 12,
|
|
||||||
detailLevel = 'standard'
|
detailLevel = 'standard'
|
||||||
} = req.body as GeneratePlanRequest;
|
} = req.body as GeneratePlanRequest;
|
||||||
|
|
||||||
@@ -2081,11 +2205,11 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
// Build sophisticated prompt based on Ralph Wiggum methodology
|
// Build sophisticated prompt based on Ralph Wiggum methodology
|
||||||
const detailConfig = {
|
const detailConfig = {
|
||||||
brief: { minSteps: 5, maxSteps: maxItems, testDepth: 'basic' },
|
brief: { style: 'high-level milestones', testDepth: 'basic' },
|
||||||
standard: { minSteps: 8, maxSteps: maxItems, testDepth: 'thorough' },
|
standard: { style: 'balanced implementation steps', testDepth: 'thorough' },
|
||||||
detailed: { minSteps: 12, maxSteps: Math.max(maxItems, 15), testDepth: 'comprehensive' },
|
detailed: { style: 'granular sub-tasks with full TDD coverage', testDepth: 'comprehensive' },
|
||||||
};
|
};
|
||||||
const config = detailConfig[detailLevel] || detailConfig.standard;
|
const levelConfig = detailConfig[detailLevel] || detailConfig.standard;
|
||||||
|
|
||||||
const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan.
|
const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan.
|
||||||
|
|
||||||
@@ -2095,12 +2219,18 @@ ${taskDescription}
|
|||||||
## YOUR MISSION
|
## YOUR MISSION
|
||||||
Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology.
|
Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology.
|
||||||
Think deeply about:
|
Think deeply about:
|
||||||
- What are all the components needed?
|
- What are ALL the components, modules, and features needed?
|
||||||
- What could go wrong? Add defensive steps.
|
- What could go wrong? Add defensive steps for error handling.
|
||||||
- How will we verify each part works?
|
- How will we verify each part works? Tests before implementation.
|
||||||
- What edge cases need handling?
|
- What edge cases need handling?
|
||||||
|
- What's the logical order of dependencies?
|
||||||
|
|
||||||
## PLAN STRUCTURE (${config.minSteps}-${config.maxSteps} steps)
|
## DETAIL LEVEL: ${detailLevel.toUpperCase()}
|
||||||
|
Style: ${levelConfig.style}
|
||||||
|
Generate as many steps as needed to properly cover the task - don't artificially limit yourself.
|
||||||
|
For complex projects, this could be 30, 50, or even 100+ steps. Quality over brevity.
|
||||||
|
|
||||||
|
## PLAN STRUCTURE
|
||||||
|
|
||||||
Your plan MUST include these phases in order:
|
Your plan MUST include these phases in order:
|
||||||
|
|
||||||
@@ -2197,7 +2327,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
priority = obj.priority;
|
priority = obj.priority;
|
||||||
}
|
}
|
||||||
return { content, priority };
|
return { content, priority };
|
||||||
}).slice(0, maxItems);
|
});
|
||||||
|
// No artificial limit - let Claude generate what's needed
|
||||||
|
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan JSON: ' + getErrorMessage(parseErr));
|
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan JSON: ' + getErrorMessage(parseErr));
|
||||||
@@ -3594,6 +3725,16 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up stale sessions from state file that don't have active sessions.
|
||||||
|
* Called on startup and can be called via API endpoint.
|
||||||
|
* @returns Number of sessions cleaned up
|
||||||
|
*/
|
||||||
|
private cleanupStaleSessions(): number {
|
||||||
|
const activeSessionIds = new Set(this.sessions.keys());
|
||||||
|
return this.store.cleanupStaleSessions(activeSessionIds);
|
||||||
|
}
|
||||||
|
|
||||||
private getFullState() {
|
private getFullState() {
|
||||||
// Build respawn status map
|
// Build respawn status map
|
||||||
const respawnStatus: Record<string, ReturnType<RespawnController['getStatus']>> = {};
|
const respawnStatus: Record<string, ReturnType<RespawnController['getStatus']>> = {};
|
||||||
@@ -3865,6 +4006,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
|||||||
// This prevents race conditions where clients connect before state is ready
|
// This prevents race conditions where clients connect before state is ready
|
||||||
await this.restoreScreenSessions();
|
await this.restoreScreenSessions();
|
||||||
|
|
||||||
|
// Clean up stale sessions from state file that don't have active screens
|
||||||
|
this.cleanupStaleSessions();
|
||||||
|
|
||||||
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
await this.app.listen({ port: this.port, host: '0.0.0.0' });
|
||||||
const protocol = this.https ? 'https' : 'http';
|
const protocol = this.https ? 'https' : 'http';
|
||||||
console.log(`Claudeman web interface running at ${protocol}://localhost:${this.port}`);
|
console.log(`Claudeman web interface running at ${protocol}://localhost:${this.port}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user