feat: add @fix_plan.md integration (Phase 2.3)

API Endpoints:
- GET /api/sessions/:id/fix-plan - Generate fix plan markdown from todos
- POST /api/sessions/:id/fix-plan/import - Import todos from markdown
- POST /api/sessions/:id/fix-plan/write - Write @fix_plan.md to working dir
- POST /api/sessions/:id/fix-plan/read - Read and import from working dir

RalphTracker Methods:
- generateFixPlanMarkdown() - Creates structured markdown with priority sections
- importFixPlanMarkdown() - Parses markdown and imports todos with priorities

UI Features:
- Menu dropdown in Ralph panel (hamburger icon)
- View Fix Plan modal with copy/write buttons
- Import from file button
- Reset Circuit Breaker button
- Fix plan modal with syntax-highlighted textarea

Bump version to 0.1392

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-27 01:02:38 +01:00
co-authored by Claude Opus 4.5
parent 925ea09cca
commit 3ab03cfd19
7 changed files with 1371 additions and 129 deletions
+1 -1
View File
@@ -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.1391 (must match `package.json`)
**Version**: 0.1392 (must match `package.json`)
**Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "claudeman",
"version": "0.1391",
"version": "0.1392",
"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",
+178
View File
@@ -2039,4 +2039,182 @@ export class RalphTracker extends EventEmitter {
}
}
}
// ========== @fix_plan.md Generation & Import ==========
/**
* Generate @fix_plan.md content from current todos.
* Groups todos by priority and status.
*
* @returns Markdown content for @fix_plan.md
*/
generateFixPlanMarkdown(): string {
const todos = this.todos;
const lines: string[] = ['# Fix Plan', ''];
// Group by priority
const p0: RalphTodoItem[] = [];
const p1: RalphTodoItem[] = [];
const p2: RalphTodoItem[] = [];
const noPriority: RalphTodoItem[] = [];
const completed: RalphTodoItem[] = [];
for (const todo of todos) {
if (todo.status === 'completed') {
completed.push(todo);
} else if (todo.priority === 'P0') {
p0.push(todo);
} else if (todo.priority === 'P1') {
p1.push(todo);
} else if (todo.priority === 'P2') {
p2.push(todo);
} else {
noPriority.push(todo);
}
}
// High Priority (P0)
if (p0.length > 0) {
lines.push('## High Priority (P0)');
for (const todo of p0) {
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
lines.push(`- ${checkbox} ${todo.content}`);
}
lines.push('');
}
// Standard (P1)
if (p1.length > 0) {
lines.push('## Standard (P1)');
for (const todo of p1) {
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
lines.push(`- ${checkbox} ${todo.content}`);
}
lines.push('');
}
// Nice to Have (P2)
if (p2.length > 0) {
lines.push('## Nice to Have (P2)');
for (const todo of p2) {
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
lines.push(`- ${checkbox} ${todo.content}`);
}
lines.push('');
}
// Tasks (no priority)
if (noPriority.length > 0) {
lines.push('## Tasks');
for (const todo of noPriority) {
const checkbox = todo.status === 'in_progress' ? '[-]' : '[ ]';
lines.push(`- ${checkbox} ${todo.content}`);
}
lines.push('');
}
// Completed
if (completed.length > 0) {
lines.push('## Completed');
for (const todo of completed) {
lines.push(`- [x] ${todo.content}`);
}
lines.push('');
}
return lines.join('\n');
}
/**
* Parse @fix_plan.md content and import todos.
* Replaces current todos with imported ones.
*
* @param content - Markdown content from @fix_plan.md
* @returns Number of todos imported
*/
importFixPlanMarkdown(content: string): number {
const lines = content.split('\n');
const newTodos: RalphTodoItem[] = [];
let currentPriority: RalphTodoPriority = null;
// Patterns for section headers
const p0HeaderPattern = /^##\s*(High Priority|Critical|P0)/i;
const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i;
const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i;
const completedHeaderPattern = /^##\s*Completed/i;
const tasksHeaderPattern = /^##\s*Tasks/i;
// Pattern for todo items
const todoPattern = /^-\s*\[([ x\-])\]\s*(.+)$/;
let inCompletedSection = false;
for (const line of lines) {
const trimmed = line.trim();
// Check for section headers
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;
}
if (tasksHeaderPattern.test(trimmed)) {
currentPriority = null;
inCompletedSection = false;
continue;
}
// Parse todo item
const match = trimmed.match(todoPattern);
if (match) {
const [, checkboxState, content] = match;
let status: RalphTodoStatus;
if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') {
status = 'completed';
} else if (checkboxState === '-') {
status = 'in_progress';
} else {
status = 'pending';
}
// Parse priority from content if not in a priority section
const parsedPriority = inCompletedSection ? null : (currentPriority || this.parsePriority(content));
const id = this.generateTodoId(content);
newTodos.push({
id,
content: content.trim(),
status,
detectedAt: Date.now(),
priority: parsedPriority,
});
}
}
// Replace current todos with imported ones
this._todos.clear();
for (const todo of newTodos) {
this._todos.set(todo.id, todo);
}
// Emit update
this.emit('todoUpdate', this.todos);
return newTodos.length;
}
}
+207 -8
View File
@@ -4243,10 +4243,11 @@ class ClaudemanApp {
document.getElementById('appSettingsShowSystemStats').checked = settings.showSystemStats ?? true;
document.getElementById('appSettingsShowTokenCount').checked = settings.showTokenCount ?? true;
document.getElementById('appSettingsShowMonitor').checked = settings.showMonitor ?? true;
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? false;
document.getElementById('appSettingsShowProjectInsights').checked = settings.showProjectInsights ?? true;
document.getElementById('appSettingsShowFileBrowser').checked = settings.showFileBrowser ?? false;
document.getElementById('appSettingsShowSubagents').checked = settings.showSubagents ?? true;
document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? true;
document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? false;
// Claude CLI settings
const claudeModeSelect = document.getElementById('appSettingsClaudeMode');
const allowedToolsRow = document.getElementById('allowedToolsRow');
@@ -4266,10 +4267,14 @@ class ClaudemanApp {
document.getElementById('appSettingsNotifCritical').checked = !notifPrefs.muteCritical;
document.getElementById('appSettingsNotifWarning').checked = !notifPrefs.muteWarning;
document.getElementById('appSettingsNotifInfo').checked = !notifPrefs.muteInfo;
// Update permission status display
// Update permission status display (compact format for new grid layout)
const permStatus = document.getElementById('notifPermissionStatus');
if (permStatus && typeof Notification !== 'undefined') {
permStatus.textContent = `Status: ${Notification.permission}`;
const perm = Notification.permission;
permStatus.textContent = perm === 'granted' ? '\u2713' : perm === 'denied' ? '\u2717' : '?';
permStatus.classList.remove('granted', 'denied');
if (perm === 'granted') permStatus.classList.add('granted');
else if (perm === 'denied') permStatus.classList.add('denied');
}
// Reset to first tab and wire up tab switching
this.switchSettingsTab('settings-display');
@@ -4306,10 +4311,11 @@ class ClaudemanApp {
showSystemStats: document.getElementById('appSettingsShowSystemStats').checked,
showTokenCount: document.getElementById('appSettingsShowTokenCount').checked,
showMonitor: document.getElementById('appSettingsShowMonitor').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
showProjectInsights: document.getElementById('appSettingsShowProjectInsights').checked,
showFileBrowser: document.getElementById('appSettingsShowFileBrowser').checked,
showSubagents: document.getElementById('appSettingsShowSubagents').checked,
subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked,
subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked,
// Claude CLI settings
claudeMode: document.getElementById('appSettingsClaudeMode').value,
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
@@ -4411,6 +4417,7 @@ class ClaudemanApp {
const settings = this.loadAppSettingsFromStorage();
const showMonitor = settings.showMonitor ?? true;
const showSubagents = settings.showSubagents ?? true;
const showFileBrowser = settings.showFileBrowser ?? false;
const monitorPanel = document.getElementById('monitorPanel');
if (monitorPanel) {
@@ -4425,6 +4432,17 @@ class ClaudemanApp {
subagentsPanel.classList.add('hidden');
}
}
// File browser panel visibility
const fileBrowserPanel = document.getElementById('fileBrowserPanel');
if (fileBrowserPanel) {
if (showFileBrowser && this.activeSessionId) {
fileBrowserPanel.classList.add('visible');
this.loadFileBrowser(this.activeSessionId);
} else {
fileBrowserPanel.classList.remove('visible');
}
}
}
closeMonitor() {
@@ -5140,6 +5158,187 @@ class ClaudemanApp {
this.renderRalphStatePanel();
}
// ========== @fix_plan.md Integration ==========
toggleRalphMenu() {
const dropdown = document.getElementById('ralphDropdown');
if (dropdown) {
dropdown.classList.toggle('show');
}
}
closeRalphMenu() {
const dropdown = document.getElementById('ralphDropdown');
if (dropdown) {
dropdown.classList.remove('show');
}
}
async resetCircuitBreaker() {
if (!this.activeSessionId) return;
try {
const response = await fetch(`/api/sessions/${this.activeSessionId}/ralph-circuit-breaker/reset`, {
method: 'POST',
});
const data = await response.json();
if (data.success) {
this.notificationManager?.notify({
urgency: 'info',
category: 'circuit-breaker',
title: 'Reset',
message: 'Circuit breaker reset to CLOSED',
});
}
} catch (error) {
console.error('Error resetting circuit breaker:', error);
}
}
/**
* Generate @fix_plan.md content and show in a modal.
*/
async showFixPlan() {
if (!this.activeSessionId) return;
try {
const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan`);
const data = await response.json();
if (!data.success) {
this.notificationManager?.notify({
urgency: 'error',
category: 'fix-plan',
title: 'Error',
message: data.error || 'Failed to generate fix plan',
});
return;
}
// Show in a modal
this.showFixPlanModal(data.data.content, data.data.todoCount);
} catch (error) {
console.error('Error fetching fix plan:', error);
}
}
/**
* Show fix plan content in a modal.
*/
showFixPlanModal(content, todoCount) {
// Create modal if it doesn't exist
let modal = document.getElementById('fixPlanModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'fixPlanModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content fix-plan-modal">
<div class="modal-header">
<h3>@fix_plan.md</h3>
<button class="btn-close" onclick="app.closeFixPlanModal()">&times;</button>
</div>
<div class="modal-body">
<textarea id="fixPlanContent" class="fix-plan-textarea" readonly></textarea>
</div>
<div class="modal-footer">
<span class="fix-plan-stats" id="fixPlanStats"></span>
<button class="btn btn-secondary" onclick="app.copyFixPlan()">Copy</button>
<button class="btn btn-primary" onclick="app.writeFixPlanToFile()">Write to File</button>
<button class="btn btn-secondary" onclick="app.closeFixPlanModal()">Close</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
document.getElementById('fixPlanContent').value = content;
document.getElementById('fixPlanStats').textContent = `${todoCount} tasks`;
modal.classList.add('show');
}
closeFixPlanModal() {
const modal = document.getElementById('fixPlanModal');
if (modal) {
modal.classList.remove('show');
}
}
async copyFixPlan() {
const content = document.getElementById('fixPlanContent')?.value;
if (content) {
await navigator.clipboard.writeText(content);
this.notificationManager?.notify({
urgency: 'info',
category: 'fix-plan',
title: 'Copied',
message: 'Fix plan copied to clipboard',
});
}
}
async writeFixPlanToFile() {
if (!this.activeSessionId) return;
try {
const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan/write`, {
method: 'POST',
});
const data = await response.json();
if (data.success) {
this.notificationManager?.notify({
urgency: 'info',
category: 'fix-plan',
title: 'Written',
message: `@fix_plan.md written to ${data.data.filePath}`,
});
this.closeFixPlanModal();
} else {
this.notificationManager?.notify({
urgency: 'error',
category: 'fix-plan',
title: 'Error',
message: data.error || 'Failed to write file',
});
}
} catch (error) {
console.error('Error writing fix plan:', error);
}
}
async importFixPlanFromFile() {
if (!this.activeSessionId) return;
try {
const response = await fetch(`/api/sessions/${this.activeSessionId}/fix-plan/read`, {
method: 'POST',
});
const data = await response.json();
if (data.success) {
this.notificationManager?.notify({
urgency: 'info',
category: 'fix-plan',
title: 'Imported',
message: `Imported ${data.data.importedCount} tasks from @fix_plan.md`,
});
// Refresh ralph panel
this.updateRalphState(this.activeSessionId, { todos: data.data.todos });
} else {
this.notificationManager?.notify({
urgency: 'warning',
category: 'fix-plan',
title: 'Not Found',
message: data.error || '@fix_plan.md not found',
});
}
} catch (error) {
console.error('Error importing fix plan:', error);
}
}
toggleRalphDetach() {
const panel = this.$('ralphStatePanel');
const detachBtn = this.$('ralphDetachBtn');
+179 -118
View File
@@ -122,6 +122,16 @@
<span class="ralph-toggle" id="ralphToggle">&#x25BC;</span>
</div>
<div class="ralph-controls">
<div class="ralph-menu-container">
<button class="btn-icon-sm" onclick="event.stopPropagation(); app.toggleRalphMenu()" title="Fix Plan Menu" id="ralphMenuBtn">&#x2630;</button>
<div class="ralph-dropdown" id="ralphDropdown">
<button onclick="event.stopPropagation(); app.showFixPlan(); app.closeRalphMenu()">View Fix Plan</button>
<button onclick="event.stopPropagation(); app.writeFixPlanToFile(); app.closeRalphMenu()">Write to File</button>
<button onclick="event.stopPropagation(); app.importFixPlanFromFile(); app.closeRalphMenu()">Import from File</button>
<hr>
<button onclick="event.stopPropagation(); app.resetCircuitBreaker(); app.closeRalphMenu()">Reset Circuit Breaker</button>
</div>
</div>
<button class="btn-icon-sm" onclick="event.stopPropagation(); app.toggleRalphDetach()" title="Detach/Attach panel" id="ralphDetachBtn">&#x29C9;</button>
<button class="btn-icon-sm" onclick="event.stopPropagation(); app.closeRalphTracker()" title="Close tracker">&times;</button>
</div>
@@ -185,6 +195,40 @@
<div class="project-insights-resize-handle"></div>
</div>
<!-- File Browser Panel -->
<div class="file-browser-panel" id="fileBrowserPanel">
<div class="file-browser-header">
<span class="file-browser-title">Files</span>
<div class="file-browser-actions">
<button class="btn-icon-sm" onclick="app.refreshFileBrowser()" title="Refresh">&#x21BB;</button>
<button class="btn-icon-sm" onclick="app.toggleFileBrowserExpand()" title="Expand/Collapse All" id="fileBrowserExpandBtn">&#x229E;</button>
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">&times;</button>
</div>
</div>
<div class="file-browser-search">
<input type="text" id="fileBrowserSearch" placeholder="Filter files..." oninput="app.filterFileBrowser(this.value)">
</div>
<div class="file-browser-body" id="fileBrowserTree">
<div class="file-browser-empty">Select a session to view files</div>
</div>
<div class="file-browser-status" id="fileBrowserStatus"></div>
</div>
<!-- File Preview Overlay -->
<div class="file-preview-overlay" id="filePreviewOverlay">
<div class="file-preview-window">
<div class="file-preview-header">
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
<div class="file-preview-actions">
<button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">&#x2398;</button>
<button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">&times;</button>
</div>
</div>
<div class="file-preview-body" id="filePreviewBody"></div>
<div class="file-preview-footer" id="filePreviewFooter"></div>
</div>
</div>
<!-- Bottom Toolbar -->
<footer class="toolbar">
<div class="toolbar-left">
@@ -640,69 +684,78 @@
<div class="modal-body">
<!-- Display Tab -->
<div class="modal-tab-content" id="settings-display">
<div class="form-row form-row-switch">
<label>Show Font Controls</label>
<label class="switch">
<input type="checkbox" id="appSettingsShowFontControls" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Show A-/A+ font size buttons in header</span>
</div>
<div class="form-row form-row-switch">
<label>Show System Stats</label>
<label class="switch">
<input type="checkbox" id="appSettingsShowSystemStats" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Show CPU and memory usage in header</span>
</div>
<div class="form-row form-row-switch">
<label>Show Token Count</label>
<label class="switch">
<input type="checkbox" id="appSettingsShowTokenCount" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Show total token count in header</span>
</div>
<div class="form-row form-row-switch">
<label>Show Monitor Panel</label>
<label class="switch">
<input type="checkbox" id="appSettingsShowMonitor" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Show Monitor panel at bottom right</span>
</div>
<div class="form-row form-row-switch">
<label>Enable Subagent Tracking</label>
<label class="switch">
<input type="checkbox" id="appSettingsSubagentTracking" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Monitor Claude Code background agents in real-time</span>
</div>
<div class="form-row form-row-switch">
<label>Show Subagents Panel</label>
<label class="switch">
<input type="checkbox" id="appSettingsShowSubagents" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Show the Subagents panel (independent from Monitor)</span>
</div>
<div class="form-row form-row-switch">
<label>Subagents for Active Tab Only</label>
<label class="switch">
<input type="checkbox" id="appSettingsSubagentActiveTabOnly">
<span class="slider"></span>
</label>
<span class="form-hint">Only show subagent windows when their parent tab is selected</span>
</div>
<div class="form-row form-row-switch">
<label>Show Project Insights Panel</label>
<label class="switch">
<input type="checkbox" id="appSettingsShowProjectInsights" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Show active tools and file viewers in a floating panel</span>
<div class="settings-grid">
<!-- Header Displays Section -->
<div class="settings-section-header">Header Displays</div>
<div class="settings-item" title="Show A-/A+ font size buttons in header">
<span class="settings-item-label">Font Controls</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowFontControls" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show CPU and memory usage in header">
<span class="settings-item-label">System Stats</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowSystemStats" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show total token count in header">
<span class="settings-item-label">Token Count</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowTokenCount" checked>
<span class="slider"></span>
</label>
</div>
<!-- Panels Section -->
<div class="settings-section-header">Panels</div>
<div class="settings-item" title="Show Monitor panel at bottom right">
<span class="settings-item-label">Monitor</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowMonitor" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show active tools and file viewers in a floating panel">
<span class="settings-item-label">Project Insights</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowProjectInsights" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show file browser panel on the right side">
<span class="settings-item-label">File Browser</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowFileBrowser">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show the Subagents panel (independent from Monitor)">
<span class="settings-item-label">Subagents</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsShowSubagents" checked>
<span class="slider"></span>
</label>
</div>
<!-- Subagent Options Section -->
<div class="settings-section-header">Subagent Options</div>
<div class="settings-item" title="Monitor Claude Code background agents in real-time">
<span class="settings-item-label">Enable Tracking</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsSubagentTracking" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Only show subagent windows when their parent tab is selected">
<span class="settings-item-label">Active Tab Only</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsSubagentActiveTabOnly">
<span class="slider"></span>
</label>
</div>
</div>
</div>
<!-- Claude CLI Tab -->
@@ -745,62 +798,70 @@
</div>
<!-- Notifications Tab -->
<div class="modal-tab-content hidden" id="settings-notifications">
<div class="form-row form-row-switch">
<label>Enable Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifEnabled" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Master toggle for all notification layers</span>
<div class="settings-grid">
<!-- Master Control Section -->
<div class="settings-section-header">Master Control</div>
<div class="settings-item" title="Master toggle for all notification layers">
<span class="settings-item-label">Enable Notifications</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsNotifEnabled" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Show OS-level notifications when tab is hidden">
<span class="settings-item-label">Browser</span>
<div class="settings-item-actions">
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsNotifBrowser">
<span class="slider"></span>
</label>
<button class="btn-toolbar btn-sm" onclick="app.notificationManager?.requestPermission()" title="Request browser notification permission">Ask</button>
<span class="settings-status" id="notifPermissionStatus">?</span>
</div>
</div>
<!-- Alerts Section -->
<div class="settings-section-header">Alerts</div>
<div class="settings-item" title="Play a short beep for critical events">
<span class="settings-item-label">Audio Alerts</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsNotifAudio">
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Notify when a session is idle longer than this">
<span class="settings-item-label">Idle Threshold</span>
<div class="settings-item-actions">
<input type="number" id="appSettingsNotifStuckMins" class="settings-item-input" value="10" min="1" max="120">
<span style="font-size: 0.65rem; color: var(--text-muted);">min</span>
</div>
</div>
<!-- Notification Levels Section -->
<div class="settings-section-header">Notification Levels</div>
</div>
<div class="form-row form-row-switch">
<label>Browser Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifBrowser">
<span class="slider"></span>
</label>
<span class="form-hint">Show OS-level notifications when tab is hidden</span>
</div>
<div class="form-row">
<button class="btn-toolbar btn-sm" onclick="app.notificationManager?.requestPermission()">Request Permission</button>
<span class="form-hint" id="notifPermissionStatus">Status: unknown</span>
</div>
<div class="form-row form-row-switch">
<label>Audio Alerts</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifAudio">
<span class="slider"></span>
</label>
<span class="form-hint">Play a short beep for critical events</span>
</div>
<div class="form-row">
<label>Idle Threshold (minutes)</label>
<input type="number" id="appSettingsNotifStuckMins" value="10" min="1" max="120" style="width: 80px;">
<span class="form-hint">Notify when a session is idle longer than this</span>
</div>
<div class="form-row form-row-switch">
<label>Critical Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifCritical" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Errors, crashes, agent failures</span>
</div>
<div class="form-row form-row-switch">
<label>Warning Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifWarning" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Completions, budget warnings, stuck sessions</span>
</div>
<div class="form-row form-row-switch">
<label>Info Notifications</label>
<label class="switch">
<input type="checkbox" id="appSettingsNotifInfo" checked>
<span class="slider"></span>
</label>
<span class="form-hint">Auto-accepts, auto-clears, agent completions</span>
<div class="settings-grid settings-grid-3col">
<div class="settings-item" title="Errors, crashes, agent failures">
<span class="settings-item-label">Critical</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsNotifCritical" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Completions, budget warnings, stuck sessions">
<span class="settings-item-label">Warning</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsNotifWarning" checked>
<span class="slider"></span>
</label>
</div>
<div class="settings-item" title="Auto-accepts, auto-clears, agent completions">
<span class="settings-item-label">Info</span>
<label class="switch switch-sm">
<input type="checkbox" id="appSettingsNotifInfo" checked>
<span class="slider"></span>
</label>
</div>
</div>
</div>
</div>
+470
View File
@@ -1385,6 +1385,50 @@ body {
flex-shrink: 0;
}
/* Ralph Menu Dropdown */
.ralph-menu-container {
position: relative;
}
.ralph-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px;
min-width: 160px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.ralph-dropdown.show {
display: block;
}
.ralph-dropdown button {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
color: var(--text);
text-align: left;
font-size: 0.75rem;
cursor: pointer;
}
.ralph-dropdown button:hover {
background: var(--bg-hover);
}
.ralph-dropdown hr {
margin: 0.25rem 0;
border: none;
border-top: 1px solid var(--border);
}
/* Detached Ralph Panel */
.ralph-panel.detached {
position: fixed;
@@ -2085,6 +2129,115 @@ body {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
/* Smaller switch variant for compact settings grid */
.switch-sm {
width: 28px;
height: 16px;
}
.switch-sm .slider:before {
height: 10px;
width: 10px;
left: 2px;
bottom: 2px;
}
.switch-sm input:checked + .slider:before {
transform: translateX(12px);
}
/* Settings Grid Layout - 2 column compact view */
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem 1rem;
}
.settings-section-header {
grid-column: 1 / -1;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
padding: 0.5rem 0 0.25rem 0;
border-bottom: 1px solid var(--border);
margin-top: 0.75rem;
}
.settings-section-header:first-child {
margin-top: 0;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.6rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.75rem;
}
.settings-item-full {
grid-column: 1 / -1;
}
.settings-item-label {
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-item-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
/* Three column variant for notification levels */
.settings-grid-3col {
grid-template-columns: 1fr 1fr 1fr;
}
/* Inline input in settings item */
.settings-item-input {
width: 60px;
padding: 0.2rem 0.4rem;
font-size: 0.7rem;
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 3px;
color: var(--text);
text-align: right;
}
.settings-item-input:focus {
outline: none;
border-color: var(--accent);
}
/* Status badge in settings */
.settings-status {
font-size: 0.6rem;
padding: 2px 6px;
border-radius: 3px;
background: var(--bg-hover);
color: var(--text-dim);
}
.settings-status.granted {
background: rgba(34, 197, 94, 0.2);
color: var(--green);
}
.settings-status.denied {
background: rgba(239, 68, 68, 0.2);
color: var(--red);
}
.form-actions {
display: flex;
justify-content: flex-end;
@@ -2153,6 +2306,32 @@ body {
max-width: 520px;
}
/* Fix Plan Modal */
.fix-plan-modal {
max-width: 600px;
width: 95%;
}
.fix-plan-textarea {
width: 100%;
height: 400px;
font-family: 'SF Mono', Monaco, 'Menlo', monospace;
font-size: 0.75rem;
line-height: 1.4;
background: var(--bg-dark);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.75rem;
resize: vertical;
}
.fix-plan-stats {
color: var(--text-muted);
font-size: 0.75rem;
margin-right: auto;
}
/* Run Summary Modal */
.run-summary-stats {
display: grid;
@@ -4626,6 +4805,297 @@ kbd {
color: white;
}
/* ========== File Browser Panel ========== */
.file-browser-panel {
position: fixed;
top: calc(var(--header-height) + 10px);
right: 20px;
width: 280px;
height: calc(100vh - var(--header-height) - var(--toolbar-height) - 40px);
max-height: 600px;
min-width: 200px;
min-height: 300px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
z-index: 100;
display: none;
flex-direction: column;
overflow: hidden;
resize: both;
}
.file-browser-panel.visible {
display: flex;
}
.file-browser-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.6rem;
background: var(--bg-input);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.file-browser-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text);
}
.file-browser-actions {
display: flex;
gap: 0.25rem;
}
.file-browser-search {
padding: 0.4rem;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.file-browser-search input {
width: 100%;
padding: 0.3rem 0.5rem;
font-size: 0.75rem;
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
}
.file-browser-search input:focus {
outline: none;
border-color: var(--accent);
}
.file-browser-body {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0.25rem 0;
}
.file-browser-body::-webkit-scrollbar {
width: 6px;
}
.file-browser-body::-webkit-scrollbar-track {
background: transparent;
}
.file-browser-body::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.file-browser-body::-webkit-scrollbar-thumb:hover {
background: var(--border-light);
}
.file-browser-status {
padding: 0.25rem 0.5rem;
font-size: 0.65rem;
color: var(--text-muted);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.file-browser-empty {
padding: 1rem;
text-align: center;
color: var(--text-muted);
font-size: 0.75rem;
}
.file-browser-loading {
padding: 1rem;
text-align: center;
color: var(--text-dim);
font-size: 0.75rem;
}
/* File Tree Item */
.file-tree-item {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
font-size: 0.75rem;
white-space: nowrap;
user-select: none;
transition: background 0.1s;
}
.file-tree-item:hover {
background: var(--bg-hover);
}
.file-tree-item.selected {
background: rgba(59, 130, 246, 0.15);
}
.file-tree-item.hidden-by-filter {
display: none;
}
.file-tree-icon {
width: 1rem;
text-align: center;
flex-shrink: 0;
font-size: 0.9rem;
}
.file-tree-expand {
width: 0.75rem;
color: var(--text-muted);
font-size: 0.6rem;
flex-shrink: 0;
transition: transform 0.15s;
}
.file-tree-expand.expanded {
transform: rotate(90deg);
}
.file-tree-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text);
}
.file-tree-name.directory {
color: var(--accent-hover);
font-weight: 500;
}
.file-tree-size {
font-size: 0.65rem;
color: var(--text-muted);
margin-left: auto;
padding-left: 0.5rem;
}
/* File tree indent levels */
.file-tree-item[data-depth="0"] { padding-left: 0.5rem; }
.file-tree-item[data-depth="1"] { padding-left: 1.25rem; }
.file-tree-item[data-depth="2"] { padding-left: 2rem; }
.file-tree-item[data-depth="3"] { padding-left: 2.75rem; }
.file-tree-item[data-depth="4"] { padding-left: 3.5rem; }
.file-tree-item[data-depth="5"] { padding-left: 4.25rem; }
.file-tree-item[data-depth="6"] { padding-left: 5rem; }
.file-tree-item[data-depth="7"] { padding-left: 5.75rem; }
.file-tree-item[data-depth="8"] { padding-left: 6.5rem; }
.file-tree-item[data-depth="9"] { padding-left: 7.25rem; }
/* ========== File Preview Overlay ========== */
.file-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(2px);
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
}
.file-preview-overlay.visible {
display: flex;
}
.file-preview-window {
width: 80vw;
max-width: 900px;
height: 80vh;
max-height: 700px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
overflow: hidden;
}
.file-preview-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: var(--bg-input);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.file-preview-title {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-preview-actions {
display: flex;
gap: 0.25rem;
}
.file-preview-body {
flex: 1;
overflow: auto;
padding: 0;
background: #111;
}
.file-preview-body pre {
margin: 0;
padding: 0.75rem;
font-family: var(--font-mono);
font-size: 0.8rem;
line-height: 1.5;
color: #d4d4d4;
white-space: pre-wrap;
word-break: break-all;
}
.file-preview-body pre code {
font-family: inherit;
}
.file-preview-body img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.file-preview-body video {
max-width: 100%;
max-height: 100%;
}
.file-preview-body .binary-message {
padding: 2rem;
text-align: center;
color: var(--text-muted);
}
.file-preview-footer {
padding: 0.35rem 0.75rem;
font-size: 0.7rem;
color: var(--text-muted);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
/* ========== Log Viewer Windows (Floating) ========== */
.log-viewer-window {
+335 -1
View File
@@ -12,9 +12,10 @@
import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
import fastifyStatic from '@fastify/static';
import { join, dirname, resolve } from 'node:path';
import path, { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
import { EventEmitter } from 'node:events';
@@ -676,6 +677,226 @@ export class WebServer extends EventEmitter {
};
});
// Get file tree for session's working directory (File Browser)
this.app.get('/api/sessions/:id/files', async (req) => {
const { id } = req.params as { id: string };
const { depth, showHidden } = req.query as { depth?: string; showHidden?: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const maxDepth = Math.min(parseInt(depth || '5', 10), 10);
const includeHidden = showHidden === 'true';
const workingDir = session.workingDir;
// Default excludes - large/generated directories
const excludeDirs = new Set(['.git', 'node_modules', 'dist', 'build', '__pycache__', '.cache', '.next', '.nuxt', 'coverage', '.venv', 'venv', '.tox', 'target', 'vendor']);
interface FileTreeNode {
name: string;
path: string;
type: 'file' | 'directory';
size?: number;
extension?: string;
children?: FileTreeNode[];
}
let totalFiles = 0;
let totalDirectories = 0;
let truncated = false;
const maxFiles = 5000;
const scanDirectory = async (dirPath: string, currentDepth: number): Promise<FileTreeNode[]> => {
if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) {
truncated = true;
return [];
}
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const nodes: FileTreeNode[] = [];
// Sort: directories first, then alphabetically
entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
});
for (const entry of entries) {
if (totalFiles + totalDirectories > maxFiles) {
truncated = true;
break;
}
// Skip hidden files unless requested
if (!includeHidden && entry.name.startsWith('.')) continue;
// Skip excluded directories
if (entry.isDirectory() && excludeDirs.has(entry.name)) continue;
const fullPath = join(dirPath, entry.name);
const relativePath = fullPath.slice(workingDir.length + 1);
if (entry.isDirectory()) {
totalDirectories++;
const children = await scanDirectory(fullPath, currentDepth + 1);
nodes.push({
name: entry.name,
path: relativePath,
type: 'directory',
children,
});
} else {
totalFiles++;
const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined;
let size: number | undefined;
try {
const stat = await fs.stat(fullPath);
size = stat.size;
} catch {
// Skip if can't stat
}
nodes.push({
name: entry.name,
path: relativePath,
type: 'file',
size,
extension: ext,
});
}
}
return nodes;
} catch (err) {
// Can't read directory (permission denied, etc.)
return [];
}
};
const tree = await scanDirectory(workingDir, 1);
return {
success: true,
data: {
root: workingDir,
tree,
totalFiles,
totalDirectories,
truncated,
}
};
});
// Get file content for preview (File Browser)
this.app.get('/api/sessions/:id/file-content', async (req) => {
const { id } = req.params as { id: string };
const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (!filePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
}
// Validate path is within working directory (security)
const fullPath = resolve(session.workingDir, filePath);
if (!fullPath.startsWith(session.workingDir)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
}
try {
const stat = await fs.stat(fullPath);
// Check if it's a binary/media file
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const binaryExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'svg', 'bmp', 'mp4', 'webm', 'mov', 'avi', 'mp3', 'wav', 'ogg', 'pdf', 'zip', 'tar', 'gz', 'exe', 'dll', 'so', 'woff', 'woff2', 'ttf', 'eot']);
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']);
if (raw === 'true' || binaryExts.has(ext)) {
// Return metadata for binary files
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary',
extension: ext,
url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`,
}
};
}
// Read text file with line limit
const maxLines = parseInt(lines || '500', 10);
const content = await fs.readFile(fullPath, 'utf-8');
const allLines = content.split('\n');
const truncatedContent = allLines.length > maxLines;
const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content;
return {
success: true,
data: {
path: filePath,
content: displayContent,
size: stat.size,
totalLines: allLines.length,
truncated: truncatedContent,
extension: ext,
}
};
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`);
}
});
// Serve raw file content (for images/binary files)
this.app.get('/api/sessions/:id/file-raw', async (req, reply) => {
const { id } = req.params as { id: string };
const { path: filePath } = req.query as { path?: string };
const session = this.sessions.get(id);
if (!session) {
reply.code(404).send({ success: false, error: 'Session not found' });
return;
}
if (!filePath) {
reply.code(400).send({ success: false, error: 'Missing path parameter' });
return;
}
// Validate path is within working directory
const fullPath = resolve(session.workingDir, filePath);
if (!fullPath.startsWith(session.workingDir)) {
reply.code(400).send({ success: false, error: 'Path must be within working directory' });
return;
}
try {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const mimeTypes: Record<string, string> = {
'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif',
'webp': 'image/webp', 'svg': 'image/svg+xml', 'ico': 'image/x-icon', 'bmp': 'image/bmp',
'mp4': 'video/mp4', 'webm': 'video/webm', 'mov': 'video/quicktime',
'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'ogg': 'audio/ogg',
'pdf': 'application/pdf', 'json': 'application/json',
};
const content = await fs.readFile(fullPath);
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.send(content);
} catch (err) {
reply.code(500).send({ success: false, error: `Failed to read file: ${getErrorMessage(err)}` });
}
});
// Stream file content via tail -f (SSE endpoint)
this.app.get('/api/sessions/:id/tail-file', async (req, reply) => {
const { id } = req.params as { id: string };
@@ -854,6 +1075,119 @@ export class WebServer extends EventEmitter {
};
});
// Generate @fix_plan.md content from todos
this.app.get('/api/sessions/:id/fix-plan', async (req) => {
const { id } = req.params as { id: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const content = session.ralphTracker.generateFixPlanMarkdown();
return {
success: true,
data: {
content,
todoCount: session.ralphTracker.todos.length,
}
};
});
// Import todos from @fix_plan.md content
this.app.post('/api/sessions/:id/fix-plan/import', async (req) => {
const { id } = req.params as { id: string };
const { content } = req.body as { content: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (!content || typeof content !== 'string') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Content is required');
}
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
this.persistSessionState(session);
return {
success: true,
data: {
importedCount,
todos: session.ralphTracker.todos,
}
};
});
// Write @fix_plan.md to session's working directory
this.app.post('/api/sessions/:id/fix-plan/write', async (req) => {
const { id } = req.params as { id: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const workingDir = session.workingDir;
if (!workingDir) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
}
const content = session.ralphTracker.generateFixPlanMarkdown();
const filePath = path.join(workingDir, '@fix_plan.md');
try {
await fs.writeFile(filePath, content, 'utf-8');
return {
success: true,
data: {
filePath,
todoCount: session.ralphTracker.todos.length,
}
};
} catch (error) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`);
}
});
// Read @fix_plan.md from session's working directory and import
this.app.post('/api/sessions/:id/fix-plan/read', async (req) => {
const { id } = req.params as { id: string };
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const workingDir = session.workingDir;
if (!workingDir) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory');
}
const filePath = path.join(workingDir, '@fix_plan.md');
try {
const content = await fs.readFile(filePath, 'utf-8');
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
this.persistSessionState(session);
return {
success: true,
data: {
filePath,
importedCount,
todos: session.ralphTracker.todos,
}
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
}
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`);
}
});
// Run prompt in session
this.app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };