diff --git a/CLAUDE.md b/CLAUDE.md index 9c2b0127..9c96402e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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?" -**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 diff --git a/package.json b/package.json index 60c45d4c..bd82ac59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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", "type": "module", "main": "dist/index.js", diff --git a/src/screen-manager.ts b/src/screen-manager.ts index 15dd34b7..84f58592 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -312,12 +312,21 @@ export class ScreenManager extends EventEmitter { // This helps prevent Claude from attempting to kill its own screen session 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'}`; - // Base command for the mode - let baseCmd = mode === 'claude' - ? `${envVars} claude --dangerously-skip-permissions` - : `${envVars} $SHELL`; + // Environment variables must be exported, not passed inline to nice/cpulimit + // Using inline VAR=value before cpulimit/nice doesn't work because they try to + // execute VAR=value as a command + 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 const cpuConfig = cpuLimitConfig || DEFAULT_CPU_LIMIT_CONFIG; @@ -325,10 +334,13 @@ export class ScreenManager extends EventEmitter { try { // 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', [ '-dmS', screenName, '-c', '/dev/null', // Use empty config - 'bash', '-c', `${pathExport}cd "${workingDir}" && ${cmd}` + 'bash', '-c', fullCmd ], { cwd: workingDir, detached: true, @@ -618,54 +630,92 @@ export class ScreenManager extends EventEmitter { } // Get all screens with stats (batched for better performance) + // Now includes child process stats (the actual claude processes inside screens) async getScreensWithStats(): Promise { const screens = Array.from(this.screens.values()); if (screens.length === 0) { return []; } - // Batch all PIDs into a single ps call for better performance - const pids = screens.map(s => s.pid); + const screenPids = screens.map(s => s.pid); const statsMap = new Map(); try { - // Single ps call for all PIDs - const psOutput = execSync( - `ps -o pid=,rss=,pcpu= -p ${pids.join(',')} 2>/dev/null || true`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); + // Step 1: Get all descendant PIDs for each screen process + // This captures the claude process and any children it spawns + const descendantMap = new Map(); // screenPid -> [childPids] - // Parse output - each line: "PID RSS CPU" - for (const line of psOutput.split('\n')) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 3) { - const pid = parseInt(parts[0], 10); - const rss = parseFloat(parts[1]) || 0; - const cpu = parseFloat(parts[2]) || 0; - if (!isNaN(pid)) { - statsMap.set(pid, { - memoryMB: Math.round(rss / 1024 * 10) / 10, - cpuPercent: Math.round(cpu * 10) / 10, - childCount: 0, - updatedAt: Date.now() - }); - } - } - } - - // Batch child count query - single pgrep call + // Use pgrep to get all descendants recursively for each screen const pgrepOutput = execSync( - `for p in ${pids.join(' ')}; do echo "$p $(pgrep -P $p 2>/dev/null | wc -l)"; done`, + `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 } ).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; + 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(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(); + for (const line of psOutput.split('\n')) { + const parts = line.trim().split(/\s+/); + if (parts.length >= 3) { + const pid = parseInt(parts[0], 10); + const rss = parseFloat(parts[1]) || 0; + const cpu = parseFloat(parts[2]) || 0; + if (!isNaN(pid)) { + processStats.set(pid, { rss, cpu }); + } + } + } + + // 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() + }); } } } catch { @@ -775,9 +825,12 @@ export class ScreenManager extends EventEmitter { sendInput(sessionId: string, input: string): boolean { const screen = this.screens.get(sessionId); if (!screen) { + console.error(`[ScreenManager] sendInput failed: no screen found for session ${sessionId}. Known screens: ${Array.from(this.screens.keys()).join(', ')}`); return false; } + console.log(`[ScreenManager] sendInput to ${screen.screenName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`); + // Security: Validate screenName to prevent command injection if (!isValidScreenName(screen.screenName)) { console.error('[ScreenManager] Invalid screen name in sendInput:', screen.screenName); diff --git a/src/session.ts b/src/session.ts index e74194c7..a4b8e653 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1006,8 +1006,14 @@ export class Session extends EventEmitter { // Reset activity timeout - if no activity for 2 seconds after prompt, Claude is idle if (this.activityTimeout) clearTimeout(this.activityTimeout); 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._status = 'idle'; this._lastPromptTime = Date.now(); this.emit('idle'); } @@ -1021,6 +1027,7 @@ export class Session extends EventEmitter { data.includes('⠴') || data.includes('⠦') || data.includes('⠧')) { if (!this._isWorking) { this._isWorking = true; + this._status = 'busy'; this.emit('working'); } // Reset timeout since Claude is active diff --git a/src/state-store.ts b/src/state-store.ts index 88ab176b..d899b760 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -177,6 +177,32 @@ export class StateStore { 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): 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. */ getTasks() { return this.state.tasks; diff --git a/src/web/public/app.js b/src/web/public/app.js index ea74e4a7..b4b5e73f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2467,6 +2467,9 @@ class ClaudemanApp { planGenerated: false, skipPlanGeneration: false, planDetailLevel: 'standard', + // Existing plan detection + existingPlan: null, // { todos, stats, content } from @fix_plan.md + useExistingPlan: false, // User chose to use existing plan }; // Reset UI @@ -2480,6 +2483,9 @@ class ClaudemanApp { // Reset plan generation UI this.resetPlanGenerationUI(); + // Check for existing @fix_plan.md in selected case + this.checkExistingFixPlan(); + // Show wizard modal this.updateRalphWizardUI(); 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 = ` +
+
+ 📋 + Existing @fix_plan.md found +
+
+ ${pending} pending + ${completed} completed + ${total} total +
+
+ + +
+
+ `; + 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() { if (this.ralphWizardStep === 1) { // Validate step 1 @@ -2528,12 +2649,17 @@ class ClaudemanApp { this.ralphWizardConfig.completionPhrase = completionPhrase.toUpperCase(); this.ralphWizardConfig.caseName = caseName; - // Move to step 2 (plan generation) and auto-start + // Move to step 2 (plan generation) this.ralphWizardStep = 2; this.updateRalphWizardUI(); - // Auto-start plan generation - this.generatePlan(); + // 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(); + } } else if (this.ralphWizardStep === 2) { // Must have generated or skipped plan if (!this.ralphWizardConfig.planGenerated && !this.ralphWizardConfig.skipPlanGeneration) { @@ -2584,22 +2710,31 @@ class ClaudemanApp { updateRalphPromptPreview() { const config = this.ralphWizardConfig; 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; - - // Add completion instructions prompt += '\n\n---\n\n'; - prompt += '## Completion Criteria\n\n'; - prompt += 'When ALL requirements above are complete:\n'; - prompt += '1. Run all tests to verify\n'; - prompt += '2. Commit your changes\n'; - prompt += `3. Output: ${config.completionPhrase}\n`; + + if (hasPlan) { + prompt += '## Task Plan\n'; + prompt += '📋 @fix_plan.md will be created with your task items\n\n'; + } + + prompt += '## Iteration Protocol\n'; + prompt += '• Check previous work • Make progress • Commit changes\n\n'; + + prompt += '## Completion Criteria\n'; + prompt += `Output \`${config.completionPhrase}\` when done\n\n`; + + prompt += '## If Stuck\n'; + prompt += 'Output `BLOCKED` with explanation'; // Show preview with highlighting const highlightedPrompt = prompt .replace(//g, '<promise>') - .replace(/<\/promise>/g, '</promise>'); + .replace(/<\/promise>/g, '</promise>') + .replace(/`([^`]+)`/g, '$1'); preview.innerHTML = highlightedPrompt; @@ -2625,11 +2760,19 @@ class ClaudemanApp { // ========== Plan Generation ========== resetPlanGenerationUI() { - // Show initial controls, hide other states - document.getElementById('planGenerationControls')?.classList.remove('hidden'); + // Hide all plan generation states + document.getElementById('existingPlanSection')?.classList.add('hidden'); document.getElementById('planGenerationLoading')?.classList.add('hidden'); document.getElementById('planGenerationError')?.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() { @@ -2645,7 +2788,8 @@ class ClaudemanApp { 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('planEditor')?.classList.add('hidden'); document.getElementById('planGenerationLoading')?.classList.remove('hidden'); @@ -2689,21 +2833,12 @@ class ClaudemanApp { } }, 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 { const res = await fetch('/api/generate-plan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ taskDescription: config.taskDescription, - maxItems, detailLevel: config.planDetailLevel, }), }); @@ -2726,6 +2861,17 @@ class ClaudemanApp { 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 config.generatedPlan = data.data.items.map((item, idx) => ({ ...item, @@ -2734,6 +2880,7 @@ class ClaudemanApp { })); config.planGenerated = true; config.skipPlanGeneration = false; + config.planCost = data.data.costUsd || 0; // Show editor and update detail buttons this.renderPlanEditor(); @@ -2784,6 +2931,13 @@ class ClaudemanApp { list.innerHTML = ''; 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) => { const row = this.renderPlanItem(item, index); @@ -2950,14 +3104,46 @@ class ClaudemanApp { // Close wizard 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; 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 += 'When ALL requirements above are complete:\n'; - fullPrompt += '1. Run all tests to verify\n'; - fullPrompt += '2. Commit your changes\n'; - fullPrompt += `3. Output: ${config.completionPhrase}\n`; + fullPrompt += `Output \`${config.completionPhrase}\` when ALL of the following are true:\n`; + fullPrompt += '- All requirements from the task description are implemented\n'; + fullPrompt += '- All tests pass\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 `BLOCKED` with an explanation\n'; try { // Step 1: Create/verify case exists and start session @@ -2996,12 +3182,20 @@ class ClaudemanApp { 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`, { + // Import the plan content and get the todos back + const importRes = await fetch(`/api/sessions/${sessionId}/fix-plan/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, 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 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 await this.selectSession(sessionId); - // Wait a moment for session to be ready, then send the prompt - await new Promise(r => setTimeout(r, 2000)); + // Wait for Claude CLI to be ready (shows prompt) + // 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 - await fetch(`/api/sessions/${sessionId}/input`, { + if (!sessionReady) { + 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', 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'); } catch (err) { @@ -5929,16 +6165,20 @@ class ClaudemanApp { } 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) const miniProgress = this.$('ralphRingMiniProgress'); const miniText = this.$('ralphRingMiniText'); if (miniProgress) { // 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; } if (miniText) { - miniText.textContent = `${percent}%`; + miniText.textContent = `${safePercent}%`; } // Large ring (in expanded view) @@ -5946,11 +6186,12 @@ class ClaudemanApp { const largePercent = this.$('ralphRingPercent'); if (largeProgress) { // 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; } if (largePercent) { - largePercent.textContent = `${percent}%`; + largePercent.textContent = `${safePercent}%`; } } diff --git a/src/web/public/index.html b/src/web/public/index.html index 6f972826..437678e1 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -104,7 +104,7 @@ - + 0% @@ -150,7 +150,7 @@ - +
0%
@@ -984,9 +984,9 @@
- +
- +
@@ -1006,8 +1006,13 @@