feat: add RespawnController for automatic Claude Code session respawning

Implements an autonomous respawn loop that keeps Claude Code productive:
- Detects idle state by monitoring PTY for prompt character + timeout
- Sends configurable update prompt when idle (default: "update all the docs and CLAUDE.md")
- Executes /clear and /init to reset session
- Cycles automatically

New features:
- RespawnController class with state machine (watching → update → clear → init)
- REST API endpoints for respawn control (/api/sessions/:id/respawn/*)
- Web UI controls: toggle, prompt config, idle timeout settings
- SSE events for real-time respawn status updates
- Full CLAUDE.md template with Ralph Loop instructions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-18 07:31:19 +01:00
co-authored by Claude Opus 4.5
parent 46f97becc9
commit 675893111b
8 changed files with 2234 additions and 152 deletions
+581 -68
View File
@@ -1,25 +1,162 @@
// Claudeman App
// Claudeman App with xterm.js terminal
class ClaudemanApp {
constructor() {
this.sessions = new Map();
this.cases = [];
this.currentRun = null;
this.totalCost = 0;
this.totalTasks = 0;
this.eventSource = null;
this.sessionsCollapsed = true;
this.settings = this.loadSettings();
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null; // Currently active interactive session
this.respawnStatus = {}; // Respawn status per session
this.respawnEnabled = true; // Whether respawn is enabled for new sessions
this.init();
}
init() {
this.initTerminal();
this.connectSSE();
this.loadState();
this.loadCases();
this.startTimerUpdates();
this.setupEventListeners();
// Start with sessions collapsed
document.getElementById('sessionsPanel').classList.add('collapsed');
}
initTerminal() {
// Initialize xterm.js
this.terminal = new Terminal({
theme: {
background: '#1a1a2e',
foreground: '#e0e0e0',
cursor: '#e0e0e0',
cursorAccent: '#1a1a2e',
selection: 'rgba(255, 255, 255, 0.3)',
black: '#1a1a2e',
red: '#ff6b6b',
green: '#51cf66',
yellow: '#ffd43b',
blue: '#339af0',
magenta: '#cc5de8',
cyan: '#22b8cf',
white: '#e0e0e0',
brightBlack: '#495057',
brightRed: '#ff8787',
brightGreen: '#69db7c',
brightYellow: '#ffe066',
brightBlue: '#5c7cfa',
brightMagenta: '#da77f2',
brightCyan: '#66d9e8',
brightWhite: '#ffffff',
},
fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, "Andale Mono", Consolas, monospace',
fontSize: 14,
lineHeight: 1.3,
cursorBlink: true,
cursorStyle: 'block',
scrollback: 10000,
allowTransparency: true,
});
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this.fitAddon.fit();
// Welcome message
this.terminal.writeln('\x1b[1;36m╔═══════════════════════════════════════════════════════════╗');
this.terminal.writeln('║ \x1b[1;33m⚡ Claudeman Terminal \x1b[1;36m ║');
this.terminal.writeln('║ \x1b[0;90mRun prompts to see output here\x1b[1;36m ║');
this.terminal.writeln('╚═══════════════════════════════════════════════════════════╝\x1b[0m');
this.terminal.writeln('');
// Handle resize
window.addEventListener('resize', () => {
if (this.fitAddon) {
this.fitAddon.fit();
}
});
// Resize observer for container changes
const resizeObserver = new ResizeObserver(() => {
if (this.fitAddon) {
this.fitAddon.fit();
// Notify server of resize
if (this.activeSessionId) {
const dims = this.fitAddon.proposeDimensions();
if (dims) {
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
});
}
}
}
});
resizeObserver.observe(container);
// Handle keyboard input - send to active session
this.terminal.onData((data) => {
if (this.activeSessionId) {
fetch(`/api/sessions/${this.activeSessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: data })
});
}
});
}
setupEventListeners() {
// Duration select
document.getElementById('durationSelect').addEventListener('change', (e) => {
const customGroup = document.getElementById('customDurationGroup');
customGroup.style.display = e.target.value === 'custom' ? 'block' : 'none';
});
}
// Settings
loadSettings() {
try {
return JSON.parse(localStorage.getItem('claudeman-settings') || '{}');
} catch {
return {};
}
}
saveSettings() {
this.settings = {
defaultDir: document.getElementById('defaultDirInput').value,
autoScroll: document.getElementById('autoScrollOutput').checked,
soundOnComplete: document.getElementById('soundOnComplete').checked,
};
localStorage.setItem('claudeman-settings', JSON.stringify(this.settings));
alert('Settings saved!');
}
// Tabs
switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
document.getElementById(`tab-${tabName}`).classList.add('active');
if (tabName === 'cases') {
this.loadCases();
}
}
// SSE Connection
connectSSE() {
this.eventSource = new EventSource('/api/events');
@@ -42,6 +179,7 @@ class ClaudemanApp {
const data = JSON.parse(e.data);
this.sessions.set(data.id, data);
this.renderSessions();
this.terminal.writeln(`\x1b[1;32m► Session created: ${data.id.slice(0, 8)}\x1b[0m`);
});
this.eventSource.addEventListener('session:updated', (e) => {
@@ -51,39 +189,45 @@ class ClaudemanApp {
this.renderSessions();
});
// Handle raw terminal data from PTY
this.eventSource.addEventListener('session:terminal', (e) => {
const data = JSON.parse(e.data);
this.terminal.write(data.data);
});
this.eventSource.addEventListener('session:output', (e) => {
const data = JSON.parse(e.data);
this.appendOutput(data.data);
// Terminal data is handled by session:terminal, so skip duplicate writes
this.updateSessionOutput(data.id, data.data);
});
this.eventSource.addEventListener('session:message', (e) => {
const data = JSON.parse(e.data);
const msg = data.message;
if (msg.type === 'assistant' && msg.message?.content) {
const text = msg.message.content
.filter(b => b.type === 'text')
.map(b => b.text)
.join('');
if (text) {
this.appendOutput(text);
}
}
// JSON messages - we can show these differently if needed
});
this.eventSource.addEventListener('session:completion', (e) => {
const data = JSON.parse(e.data);
this.totalCost += data.cost || 0;
this.totalTasks++;
this.appendOutput(`\n\n✓ Completed (Cost: $${(data.cost || 0).toFixed(4)})\n`);
this.terminal.writeln('');
this.terminal.writeln(`\x1b[1;32m✓ Completed (Cost: $${(data.cost || 0).toFixed(4)})\x1b[0m`);
this.updateStats();
this.setRunning(false);
if (this.settings.soundOnComplete) {
this.playSound();
}
});
this.eventSource.addEventListener('session:error', (e) => {
const data = JSON.parse(e.data);
this.appendOutput(`\n❌ Error: ${data.error}\n`, 'error');
this.terminal.writeln(`\x1b[1;31m❌ Error: ${data.error}\x1b[0m`);
});
this.eventSource.addEventListener('session:exit', (e) => {
const data = JSON.parse(e.data);
this.terminal.writeln('');
this.terminal.writeln(`\x1b[90m[Session ${data.id.slice(0, 8)} exited with code ${data.code}]\x1b[0m`);
});
this.eventSource.addEventListener('scheduled:created', (e) => {
@@ -102,20 +246,97 @@ class ClaudemanApp {
const data = JSON.parse(e.data);
this.currentRun = data;
this.hideTimer();
this.appendOutput(`\n\n🎉 Scheduled run completed! Tasks: ${data.completedTasks}, Cost: $${data.totalCost.toFixed(4)}\n`);
this.terminal.writeln('');
this.terminal.writeln(`\x1b[1;33m🎉 Scheduled run completed! Tasks: ${data.completedTasks}, Cost: $${data.totalCost.toFixed(4)}\x1b[0m`);
this.setRunning(false);
if (this.settings.soundOnComplete) {
this.playSound();
}
});
this.eventSource.addEventListener('scheduled:stopped', (e) => {
const data = JSON.parse(e.data);
this.currentRun = data;
this.hideTimer();
this.appendOutput(`\n\n⏹ Scheduled run stopped.\n`);
this.terminal.writeln('');
this.terminal.writeln('\x1b[1;33m⏹ Scheduled run stopped.\x1b[0m');
this.setRunning(false);
});
this.eventSource.addEventListener('scheduled:log', (e) => {
const data = JSON.parse(e.data);
this.appendOutput(`\n${data.log}`);
this.terminal.writeln(`\x1b[90m${data.log}\x1b[0m`);
});
this.eventSource.addEventListener('case:created', (e) => {
this.loadCases();
});
// Respawn events
this.eventSource.addEventListener('respawn:started', (e) => {
const data = JSON.parse(e.data);
this.respawnStatus[data.sessionId] = data.status;
this.showRespawnBanner();
this.terminal.writeln(`\x1b[1;32m🔄 Respawn loop started for session ${data.sessionId.slice(0, 8)}\x1b[0m`);
});
this.eventSource.addEventListener('respawn:stopped', (e) => {
const data = JSON.parse(e.data);
delete this.respawnStatus[data.sessionId];
this.hideRespawnBanner();
this.terminal.writeln(`\x1b[1;33m⏹ Respawn loop stopped\x1b[0m`);
});
this.eventSource.addEventListener('respawn:stateChanged', (e) => {
const data = JSON.parse(e.data);
if (this.respawnStatus[data.sessionId]) {
this.respawnStatus[data.sessionId].state = data.state;
}
this.updateRespawnBanner(data.state);
});
this.eventSource.addEventListener('respawn:cycleStarted', (e) => {
const data = JSON.parse(e.data);
if (this.respawnStatus[data.sessionId]) {
this.respawnStatus[data.sessionId].cycleCount = data.cycleNumber;
}
document.getElementById('respawnCycleCount').textContent = data.cycleNumber;
this.terminal.writeln(`\x1b[1;36m─── Respawn Cycle #${data.cycleNumber} Started ───\x1b[0m`);
});
this.eventSource.addEventListener('respawn:cycleCompleted', (e) => {
const data = JSON.parse(e.data);
this.terminal.writeln(`\x1b[1;32m✓ Respawn Cycle #${data.cycleNumber} Completed\x1b[0m`);
});
this.eventSource.addEventListener('respawn:stepSent', (e) => {
const data = JSON.parse(e.data);
document.getElementById('respawnStep').textContent = `Sending: ${data.input}`;
});
this.eventSource.addEventListener('respawn:stepCompleted', (e) => {
const data = JSON.parse(e.data);
document.getElementById('respawnStep').textContent = `Completed: ${data.step}`;
});
this.eventSource.addEventListener('respawn:log', (e) => {
const data = JSON.parse(e.data);
console.log('[Respawn]', data.message);
});
this.eventSource.addEventListener('respawn:error', (e) => {
const data = JSON.parse(e.data);
this.terminal.writeln(`\x1b[1;31m❌ Respawn error: ${data.error}\x1b[0m`);
});
}
playSound() {
try {
const audio = new Audio('data:audio/wav;base64,UklGRnoGAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQoGAACBhYqFbF1fdH2JkJOQjIJ5cGlpcXuFjpWYl5KKgHZsZWdveYSNlZmYk4yBdmtlZmt1gIqSlpeUjoV7cWlnbHV/iZGWl5SSiYB2bGdqcHqEjJOWlZGMhHpwa2ducHmEjJOUk5CMhHpwa2dqb3eFjJOUk4+LgnhvamdscHqEjJKTko6KgXdtaGltdH6HjpKTkY2Jf3Zsa2twd4GIjpKSkI2Jf3ZsamttdH2GjZGSkI2Jf3VramttdX2GjJCRj4yIf3VramtudX6GjJCQjo2If3VraWttdn6FjI+Pjo2If3VraWptdn+FjI+PjoyHfnRqaWptdn6Fi4+PjoyHfnRqaGlsdn6Fi46OjYyHfnRpZ2lsdX2FjI6OjYuHfXNpZ2lrdX2EjI2OjYuGfXNpZmlrdX2EjI2NjIuGfXNoZmlrdXyEi42NjIqGfHNnZWhqdHyDi4yMi4qFfHJnZWhpdHyDioyMi4qFfHJnZGdpdHuDiouLioqEe3FmZGdocnuCiYuLioqEe3FmZGZocnuCiYqKiYmDenBmZGZncnqBiIqJiYiDenBmY2ZncnqBiImIiIiDenBlY2VncXqAh4iIiIiCeW9lY2VmcXmAh4eHh4eCeW9kYmVmcHl/hoaGhoeBd25kYmRlcHh+hYWFhYaBd21jYWRlb3d9hYSEhYWAdmxiYWNkbnd8hIODhISAdjxhYWNkbXZ7g4KCg4N/dWtgYGJja3V6goGBgoJ+dGpfX2FiaXR5gYCAf4F9c2leX2BhZ3N4gH9/f4B8cmhcXV9fZnJ3f318fX57cGdcXF5eZXF1fXt7e3x6b2ZbW11dZG9zent6eXp5bWRaW1xbY25yfHl4eXh4a2JZWltaYWxxeXd2d3Z2aF9XWVlYX2xvdXR0dHRzZVxVV1dWXmpsdHJxcnFwYllUVVRUW2dqdnBvcHBuXldSU1JSWmVodW5tbm1sWlNQUU9PV2Nlc2xramxpV1FOUFE=');
audio.volume = 0.3;
audio.play();
} catch {}
}
setConnectionStatus(status) {
@@ -131,14 +352,27 @@ class ClaudemanApp {
this.sessions.clear();
data.sessions.forEach(s => this.sessions.set(s.id, s));
// Check for active scheduled run
const activeRun = data.scheduledRuns.find(r => r.status === 'running');
if (activeRun) {
this.currentRun = activeRun;
this.showTimer();
}
// Calculate totals
// Load respawn status
if (data.respawnStatus) {
this.respawnStatus = data.respawnStatus;
// Show respawn banner if any session has active respawn
const hasActiveRespawn = Object.values(this.respawnStatus).some(s => s.state !== 'stopped');
if (hasActiveRespawn) {
this.showRespawnBanner();
const activeStatus = Object.values(this.respawnStatus).find(s => s.state !== 'stopped');
if (activeStatus) {
this.updateRespawnBanner(activeStatus.state);
document.getElementById('respawnCycleCount').textContent = activeStatus.cycleCount || 0;
}
}
}
this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0);
this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0);
this.totalTasks = data.scheduledRuns.reduce((sum, r) => sum + (r.completedTasks || 0), 0);
@@ -157,11 +391,123 @@ class ClaudemanApp {
}
}
// Cases
async loadCases() {
try {
const res = await fetch('/api/cases');
this.cases = await res.json();
this.renderCases();
} catch (err) {
console.error('Failed to load cases:', err);
}
}
renderCases() {
const list = document.getElementById('casesList');
if (this.cases.length === 0) {
list.innerHTML = '<p class="empty-state">No cases yet. Create one to get started.</p>';
return;
}
list.innerHTML = this.cases.map(c => `
<div class="case-card" onclick="app.useCase('${c.path}')">
<span class="case-icon">📁</span>
<div class="case-info">
<div class="case-name">${this.escapeHtml(c.name)}</div>
<div class="case-path">${this.escapeHtml(c.path)}</div>
</div>
</div>
`).join('');
}
useCase(path) {
document.getElementById('dirInput').value = path;
this.switchTab('run');
}
showCreateCase() {
document.getElementById('createCaseForm').style.display = 'block';
document.getElementById('caseNameInput').focus();
}
hideCreateCase() {
document.getElementById('createCaseForm').style.display = 'none';
document.getElementById('caseNameInput').value = '';
document.getElementById('caseDescInput').value = '';
}
async createCase() {
const name = document.getElementById('caseNameInput').value.trim();
const description = document.getElementById('caseDescInput').value.trim();
if (!name) {
alert('Please enter a case name');
return;
}
try {
const res = await fetch('/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
});
const data = await res.json();
if (data.success) {
this.hideCreateCase();
this.loadCases();
alert(`Case "${name}" created!`);
} else {
alert('Error: ' + data.error);
}
} catch (err) {
alert('Error creating case: ' + err.message);
}
}
selectCase() {
this.loadCases().then(() => {
const grid = document.getElementById('caseSelectorGrid');
if (this.cases.length === 0) {
grid.innerHTML = '<p class="empty-state">No cases. Create one in the Cases tab.</p>';
} else {
grid.innerHTML = this.cases.map(c => `
<div class="case-card" onclick="app.selectCaseAndClose('${c.path}')">
<span class="case-icon">📁</span>
<div class="case-info">
<div class="case-name">${this.escapeHtml(c.name)}</div>
</div>
</div>
`).join('');
}
document.getElementById('caseSelectorModal').classList.add('active');
});
}
selectCaseAndClose(path) {
document.getElementById('dirInput').value = path;
this.closeCaseSelector();
}
closeCaseSelector() {
document.getElementById('caseSelectorModal').classList.remove('active');
}
// Actions
async startRun() {
const prompt = document.getElementById('promptInput').value.trim();
const dir = document.getElementById('dirInput').value.trim();
const duration = parseInt(document.getElementById('durationInput').value) || 0;
const durationSelect = document.getElementById('durationSelect').value;
let duration = 0;
if (durationSelect === 'custom') {
duration = parseInt(document.getElementById('customDuration').value) || 0;
} else {
duration = parseInt(durationSelect) || 0;
}
if (!prompt) {
alert('Please enter a prompt');
@@ -169,12 +515,12 @@ class ClaudemanApp {
}
this.setRunning(true);
this.clearOutput();
this.appendOutput('Starting...\n\n');
this.terminal.writeln('');
this.terminal.writeln(`\x1b[1;36m─── Starting: ${prompt.substring(0, 60)}${prompt.length > 60 ? '...' : ''} ───\x1b[0m`);
this.terminal.writeln('');
try {
if (duration > 0) {
// Scheduled run with timer
const res = await fetch('/api/scheduled', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -189,7 +535,6 @@ class ClaudemanApp {
throw new Error(data.error);
}
} else {
// Single run
const res = await fetch('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -204,11 +549,169 @@ class ClaudemanApp {
}
}
} catch (err) {
this.appendOutput(`❌ Error: ${err.message}\n`, 'error');
this.terminal.writeln(`\x1b[1;31m❌ Error: ${err.message}\x1b[0m`);
this.setRunning(false);
}
}
// Start an interactive Claude Code session
async startInteractive() {
const dir = document.getElementById('dirInput').value.trim();
const respawnEnabled = document.getElementById('respawnEnabled').checked;
this.terminal.clear();
this.terminal.writeln('\x1b[1;36m─── Starting Interactive Claude Code Session ───\x1b[0m');
if (respawnEnabled) {
this.terminal.writeln('\x1b[90mRespawn loop enabled. Claude will auto-restart when idle.\x1b[0m');
} else {
this.terminal.writeln('\x1b[90mType your prompts directly. Session persists even if you close the browser.\x1b[0m');
}
this.terminal.writeln('');
try {
// Create session
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: dir || undefined })
});
const createData = await createRes.json();
if (!createData.success) {
throw new Error(createData.error);
}
const sessionId = createData.session.id;
this.activeSessionId = sessionId;
// Get respawn config from UI
const respawnConfig = respawnEnabled ? {
enabled: true,
updatePrompt: document.getElementById('respawnPrompt').value || 'update all the docs and CLAUDE.md',
idleTimeoutMs: (parseInt(document.getElementById('respawnIdleTimeout').value) || 5) * 1000,
interStepDelayMs: (parseInt(document.getElementById('respawnStepDelay').value) || 1) * 1000,
} : null;
// Start interactive mode with respawn
let interactiveRes;
if (respawnConfig) {
interactiveRes = await fetch(`/api/sessions/${sessionId}/interactive-respawn`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ respawnConfig })
});
} else {
interactiveRes = await fetch(`/api/sessions/${sessionId}/interactive`, {
method: 'POST'
});
}
const interactiveData = await interactiveRes.json();
if (!interactiveData.success) {
throw new Error(interactiveData.error);
}
this.terminal.writeln(`\x1b[1;32m► Connected to session ${sessionId.slice(0, 8)}\x1b[0m`);
if (respawnConfig) {
this.terminal.writeln(`\x1b[90m Respawn: ${respawnConfig.updatePrompt.substring(0, 40)}...\x1b[0m`);
}
this.terminal.writeln('');
// Send initial resize
const dims = this.fitAddon.proposeDimensions();
if (dims) {
await fetch(`/api/sessions/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols: dims.cols, rows: dims.rows })
});
}
// Focus the terminal
this.terminal.focus();
} catch (err) {
this.terminal.writeln(`\x1b[1;31m❌ Error: ${err.message}\x1b[0m`);
this.activeSessionId = null;
}
}
// Respawn controls
toggleRespawnDetails() {
const details = document.getElementById('respawnDetails');
details.style.display = details.style.display === 'none' ? 'block' : 'none';
}
showRespawnBanner() {
document.getElementById('respawnBanner').style.display = 'flex';
}
hideRespawnBanner() {
document.getElementById('respawnBanner').style.display = 'none';
}
updateRespawnBanner(state) {
const stateEl = document.getElementById('respawnState');
stateEl.textContent = state.replace(/_/g, ' ');
// Update indicator animation based on state
const indicator = document.querySelector('.respawn-indicator');
if (state === 'watching') {
indicator.style.animationPlayState = 'paused';
} else {
indicator.style.animationPlayState = 'running';
}
}
async toggleRespawn() {
if (!this.activeSessionId) return;
const status = this.respawnStatus[this.activeSessionId];
if (!status) return;
// TODO: Add pause/resume endpoints if needed
this.terminal.writeln('\x1b[90mRespawn toggle not yet implemented\x1b[0m');
}
async stopRespawn() {
if (!this.activeSessionId) return;
try {
const res = await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, {
method: 'POST'
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Failed to stop respawn');
}
} catch (err) {
this.terminal.writeln(`\x1b[1;31m❌ Error stopping respawn: ${err.message}\x1b[0m`);
}
}
async startRespawnForSession(sessionId) {
const respawnConfig = {
enabled: true,
updatePrompt: document.getElementById('respawnPrompt').value || 'update all the docs and CLAUDE.md',
idleTimeoutMs: (parseInt(document.getElementById('respawnIdleTimeout').value) || 5) * 1000,
interStepDelayMs: (parseInt(document.getElementById('respawnStepDelay').value) || 1) * 1000,
};
try {
const res = await fetch(`/api/sessions/${sessionId}/respawn/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(respawnConfig)
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Failed to start respawn');
}
this.terminal.writeln(`\x1b[1;32m🔄 Respawn started for session ${sessionId.slice(0, 8)}\x1b[0m`);
} catch (err) {
this.terminal.writeln(`\x1b[1;31m❌ Error starting respawn: ${err.message}\x1b[0m`);
}
}
async stopCurrentRun() {
if (!this.currentRun) return;
@@ -232,31 +735,10 @@ class ClaudemanApp {
}
}
// Output
appendOutput(text, type = '') {
const container = document.getElementById('outputContainer');
// Remove placeholder if present
const placeholder = container.querySelector('.output-placeholder');
if (placeholder) {
container.innerHTML = '';
}
const span = document.createElement('span');
span.className = type ? `msg-${type}` : '';
span.textContent = text;
container.appendChild(span);
container.scrollTop = container.scrollHeight;
}
clearOutput() {
const container = document.getElementById('outputContainer');
container.innerHTML = `
<div class="output-placeholder">
<div class="placeholder-icon">💬</div>
<div class="placeholder-text">Run a prompt to see Claude's output here</div>
</div>
`;
// Terminal
clearTerminal() {
this.terminal.clear();
this.terminal.writeln('\x1b[90mTerminal cleared\x1b[0m');
}
// Timer
@@ -322,26 +804,57 @@ class ClaudemanApp {
return;
}
list.innerHTML = Array.from(this.sessions.values()).map(s => `
<div class="session-card" data-session="${s.id}">
<div class="session-card-header">
<div class="session-status">
<span class="session-status-dot ${s.status}"></span>
<span>${s.id.slice(0, 8)}</span>
</div>
<span class="session-cost">$${(s.totalCost || 0).toFixed(4)}</span>
list.innerHTML = Array.from(this.sessions.values()).map(s => {
const respawn = this.respawnStatus[s.id];
const respawnHtml = respawn ? `
<div class="session-respawn">
<span class="session-respawn-dot ${respawn.state === 'stopped' ? 'stopped' : ''}"></span>
<span class="session-respawn-state">${respawn.state.replace(/_/g, ' ')}</span>
<span class="session-respawn-actions">
<button class="btn btn-sm" onclick="app.stopRespawnForSession('${s.id}')">Stop</button>
</span>
</div>
<div class="session-output" id="session-output-${s.id}">${this.escapeHtml(s.textOutput || '')}</div>
</div>
`).join('');
` : `
<div class="session-respawn">
<span class="session-respawn-dot stopped"></span>
<span class="session-respawn-state">No respawn</span>
<span class="session-respawn-actions">
<button class="btn btn-sm" onclick="app.startRespawnForSession('${s.id}')">Start</button>
</span>
</div>
`;
return `
<div class="session-card" data-session="${s.id}">
<div class="session-card-header">
<div class="session-status">
<span class="session-status-dot ${s.status}"></span>
<span>${s.id.slice(0, 8)}</span>
</div>
<span class="session-cost">$${(s.totalCost || 0).toFixed(4)}</span>
</div>
${respawnHtml}
</div>
`;
}).join('');
}
async stopRespawnForSession(sessionId) {
try {
const res = await fetch(`/api/sessions/${sessionId}/respawn/stop`, {
method: 'POST'
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Failed to stop respawn');
}
} catch (err) {
this.terminal.writeln(`\x1b[1;31m❌ Error stopping respawn: ${err.message}\x1b[0m`);
}
}
updateSessionOutput(sessionId, text) {
const el = document.getElementById(`session-output-${sessionId}`);
if (el) {
el.textContent += text;
el.scrollTop = el.scrollHeight;
}
// Sessions list shows brief info, main terminal shows full output
}
escapeHtml(text) {