mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -1,89 +1,122 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
- **Project Name**: Claudeman
|
||||
- **Description**: Claude Code session manager with web interface and autonomous Ralph Loop
|
||||
- **Tech Stack**: TypeScript, Node.js, Fastify, Server-Sent Events
|
||||
- **Last Updated**: 2026-01-18
|
||||
Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs.
|
||||
|
||||
## Architecture
|
||||
**Tech Stack**: TypeScript, Node.js, Fastify, Server-Sent Events, node-pty
|
||||
|
||||
```
|
||||
claudeman/
|
||||
├── src/
|
||||
│ ├── index.ts # CLI entry point
|
||||
│ ├── cli.ts # Commander.js CLI commands
|
||||
│ ├── types.ts # TypeScript type definitions
|
||||
│ ├── session.ts # Claude CLI subprocess wrapper
|
||||
│ ├── session-manager.ts # Session registry and lifecycle
|
||||
│ ├── task.ts # Task definitions
|
||||
│ ├── task-queue.ts # Priority queue with dependencies
|
||||
│ ├── ralph-loop.ts # Autonomous loop controller
|
||||
│ ├── state-store.ts # JSON persistence (~/.claudeman/state.json)
|
||||
│ └── web/
|
||||
│ ├── server.ts # Fastify server with SSE
|
||||
│ └── public/ # Static frontend files
|
||||
│ ├── index.html
|
||||
│ ├── styles.css
|
||||
│ └── app.js
|
||||
├── dist/ # Compiled output
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
npm run build # Compile TypeScript + copy static files
|
||||
npm run build # Compile TypeScript + copy static files to dist/web/
|
||||
npm run dev # Run with tsx (no build needed)
|
||||
|
||||
# Usage
|
||||
# Production
|
||||
npm link # Make 'claudeman' globally available
|
||||
claudeman web # Start web interface on port 3000
|
||||
claudeman web -p 8080 # Custom port
|
||||
|
||||
# CLI commands
|
||||
claudeman status # Show overall status
|
||||
claudeman task add "<prompt>" --priority N
|
||||
claudeman ralph start --min-hours 4
|
||||
# CLI examples
|
||||
claudeman status # Overall status
|
||||
claudeman session start --dir /path # Start Claude session
|
||||
claudeman task add "Fix bug" --priority 5 # Add task to queue
|
||||
claudeman ralph start --min-hours 4 # Start autonomous loop
|
||||
```
|
||||
|
||||
## How It Works
|
||||
## Architecture
|
||||
|
||||
1. **Session**: Wraps `claude -p --output-format stream-json` subprocess
|
||||
2. **Web Server**: Fastify serves static files + REST API + SSE for real-time
|
||||
3. **Timed Runs**: Loop that repeatedly runs prompts until duration expires
|
||||
4. **State**: Persisted to `~/.claudeman/state.json`
|
||||
### Core Data Flow
|
||||
|
||||
1. **Session** (`src/session.ts`) spawns Claude CLI via `node-pty` with `--dangerously-skip-permissions`
|
||||
2. PTY output is buffered and parsed for JSON messages (types: `system`, `assistant`, `result`)
|
||||
3. **WebServer** (`src/web/server.ts`) broadcasts events to connected SSE clients
|
||||
4. State persists to `~/.claudeman/state.json` via **StateStore**
|
||||
|
||||
### Key Components
|
||||
|
||||
- **Session**: Wraps `claude -p --output-format stream-json` as PTY subprocess. Emits `output`, `terminal`, `message`, `completion`, `exit` events.
|
||||
- **WebServer**: Fastify server exposing REST API + SSE endpoint at `/api/events`. Manages ScheduledRuns (timed loops that repeatedly run prompts).
|
||||
- **RespawnController**: Manages automatic respawning of interactive Claude sessions. Detects idle state → sends update prompt → `/clear` → `/init` → repeats.
|
||||
- **RalphLoop**: Autonomous controller that assigns tasks to idle sessions and detects completion via `<promise>PHRASE</promise>` markers.
|
||||
- **TaskQueue**: Priority-based queue with dependency support.
|
||||
|
||||
### REST API Endpoints
|
||||
|
||||
```
|
||||
GET /api/status # Full state (sessions + scheduled + respawn)
|
||||
GET /api/sessions # List all sessions
|
||||
POST /api/sessions # Create session { workingDir }
|
||||
POST /api/sessions/:id/run # Run prompt { prompt }
|
||||
POST /api/sessions/:id/input # Send input to interactive session
|
||||
POST /api/sessions/:id/interactive-respawn # Start interactive + respawn
|
||||
|
||||
# Respawn control
|
||||
GET /api/sessions/:id/respawn # Get respawn status
|
||||
POST /api/sessions/:id/respawn/start # Start respawn controller
|
||||
POST /api/sessions/:id/respawn/stop # Stop respawn controller
|
||||
PUT /api/sessions/:id/respawn/config # Update respawn config
|
||||
|
||||
GET /api/scheduled # List scheduled runs
|
||||
POST /api/scheduled # Create { prompt, workingDir, durationMinutes }
|
||||
POST /api/cases # Create case directory with CLAUDE.md template
|
||||
```
|
||||
|
||||
### SSE Events
|
||||
|
||||
Events broadcast to `/api/events` clients:
|
||||
- `session:output`, `session:terminal`, `session:message`
|
||||
- `session:completion`, `session:exit`, `session:working`, `session:idle`
|
||||
- `scheduled:created`, `scheduled:updated`, `scheduled:log`, `scheduled:completed`
|
||||
- `respawn:started`, `respawn:stopped`, `respawn:stateChanged`
|
||||
- `respawn:cycleStarted`, `respawn:cycleCompleted`, `respawn:stepSent`, `respawn:stepCompleted`, `respawn:log`
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Session JSON Parsing
|
||||
Claude CLI outputs newline-delimited JSON with types: `system`, `assistant`, `result`
|
||||
### Claude Message Parsing
|
||||
|
||||
Claude CLI outputs newline-delimited JSON. Strip ANSI codes before parsing:
|
||||
|
||||
```typescript
|
||||
// Parse streaming JSON lines
|
||||
const msg = JSON.parse(line) as ClaudeMessage;
|
||||
if (msg.type === 'assistant' && msg.message?.content) {
|
||||
// Extract text from content blocks
|
||||
}
|
||||
const cleanLine = line.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
const msg = JSON.parse(cleanLine) as ClaudeMessage;
|
||||
// msg.type: 'system' | 'assistant' | 'result'
|
||||
// msg.message?.content: Array<{ type: 'text', text: string }>
|
||||
// msg.total_cost_usd: number (on result messages)
|
||||
```
|
||||
|
||||
### SSE Broadcasting
|
||||
```typescript
|
||||
private broadcast(event: string, data: unknown): void {
|
||||
for (const client of this.sseClients) {
|
||||
client.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
}
|
||||
}
|
||||
### Idle Detection
|
||||
|
||||
Session detects idle state by watching for prompt character (`❯` or `\u276f`) and waiting 2 seconds without activity.
|
||||
|
||||
### Respawn Controller State Machine
|
||||
|
||||
`RespawnController` (`src/respawn-controller.ts`) cycles through states to keep Claude productive:
|
||||
|
||||
```
|
||||
WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING
|
||||
```
|
||||
|
||||
## Work Principles
|
||||
**Default sequence:**
|
||||
1. Detect idle (5s timeout after prompt with no activity)
|
||||
2. Send: `update all the docs and CLAUDE.md`
|
||||
3. Wait for completion (detects prompt after work stops)
|
||||
4. Send: `/clear`
|
||||
5. Send: `/init`
|
||||
6. Return to watching
|
||||
|
||||
- Full permissions granted via `.claude/settings.json`
|
||||
- Commit after every meaningful change
|
||||
- Use conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`
|
||||
**Configuration options** (`RespawnConfig`):
|
||||
- `idleTimeoutMs`: How long to wait after prompt (default: 5000)
|
||||
- `updatePrompt`: Custom prompt to send (default: "update all the docs and CLAUDE.md")
|
||||
- `interStepDelayMs`: Delay between steps (default: 1000)
|
||||
- `enabled`: Toggle respawn on/off
|
||||
|
||||
### Template Generation
|
||||
|
||||
`src/templates/claude-md.ts` generates CLAUDE.md files for new cases via the `/api/cases` endpoint.
|
||||
|
||||
## Session Log
|
||||
|
||||
@@ -91,3 +124,4 @@ private broadcast(event: string, data: unknown): void {
|
||||
|------|-----------------|---------------|-------|
|
||||
| 2026-01-18 | Initial implementation | All files | Core CLI + web interface |
|
||||
| 2026-01-18 | Add web interface | src/web/* | Fastify + SSE + responsive UI |
|
||||
| 2026-01-18 | Add RespawnController | src/respawn-controller.ts, src/web/server.ts, src/types.ts | Auto-respawn loop with state machine |
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session } from './session.js';
|
||||
|
||||
/**
|
||||
* Respawn sequence states
|
||||
*
|
||||
* The controller cycles through these states:
|
||||
* WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING
|
||||
*/
|
||||
export type RespawnState =
|
||||
| 'watching' // Watching for idle, ready to start respawn sequence
|
||||
| 'sending_update' // About to send the update docs prompt
|
||||
| 'waiting_update' // Waiting for update to complete
|
||||
| 'sending_clear' // About to send /clear
|
||||
| 'waiting_clear' // Waiting for clear to complete
|
||||
| 'sending_init' // About to send /init
|
||||
| 'waiting_init' // Waiting for init to complete
|
||||
| 'stopped'; // Controller stopped
|
||||
|
||||
export interface RespawnConfig {
|
||||
/** How long to wait after seeing prompt before considering truly idle (ms) */
|
||||
idleTimeoutMs: number;
|
||||
/** The prompt to send for updating docs */
|
||||
updatePrompt: string;
|
||||
/** Delay between sending steps (ms) */
|
||||
interStepDelayMs: number;
|
||||
/** Whether to enable respawn loop */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface RespawnEvents {
|
||||
stateChanged: (state: RespawnState, prevState: RespawnState) => void;
|
||||
respawnCycleStarted: (cycleNumber: number) => void;
|
||||
respawnCycleCompleted: (cycleNumber: number) => void;
|
||||
stepSent: (step: string, input: string) => void;
|
||||
stepCompleted: (step: string) => void;
|
||||
error: (error: Error) => void;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: RespawnConfig = {
|
||||
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
|
||||
updatePrompt: 'update all the docs and CLAUDE.md',
|
||||
interStepDelayMs: 1000, // 1 second between steps
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* RespawnController manages automatic respawning of Claude Code sessions
|
||||
*
|
||||
* When Claude finishes working (detected by idle prompt), it:
|
||||
* 1. Sends an update docs prompt
|
||||
* 2. Waits for completion
|
||||
* 3. Sends /clear
|
||||
* 4. Sends /init
|
||||
* 5. Repeats
|
||||
*/
|
||||
export class RespawnController extends EventEmitter {
|
||||
private session: Session;
|
||||
private config: RespawnConfig;
|
||||
private _state: RespawnState = 'stopped';
|
||||
private idleTimer: NodeJS.Timeout | null = null;
|
||||
private stepTimer: NodeJS.Timeout | null = null;
|
||||
private cycleCount: number = 0;
|
||||
private lastActivityTime: number = 0;
|
||||
private terminalBuffer: string = '';
|
||||
private promptDetected: boolean = false;
|
||||
private workingDetected: boolean = false;
|
||||
|
||||
// Terminal patterns
|
||||
private readonly PROMPT_PATTERNS = [
|
||||
'❯', // Standard prompt
|
||||
'\u276f', // Unicode variant
|
||||
'⏵', // Claude Code prompt variant
|
||||
'> ', // Fallback
|
||||
'tokens', // The status line shows "X tokens" when at prompt
|
||||
];
|
||||
private readonly WORKING_PATTERNS = [
|
||||
'Thinking', 'Writing', 'Reading', 'Running', 'Searching',
|
||||
'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing',
|
||||
'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars
|
||||
];
|
||||
private readonly CLEAR_COMPLETE_PATTERN = /conversation cleared|cleared|❯|⏵/i;
|
||||
private readonly INIT_COMPLETE_PATTERN = /initialized|analyzing|❯|⏵|CLAUDE\.md/i;
|
||||
|
||||
constructor(session: Session, config: Partial<RespawnConfig> = {}) {
|
||||
super();
|
||||
this.session = session;
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
get state(): RespawnState {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
get currentCycle(): number {
|
||||
return this.cycleCount;
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this._state !== 'stopped';
|
||||
}
|
||||
|
||||
private setState(newState: RespawnState): void {
|
||||
if (newState === this._state) return;
|
||||
|
||||
const prevState = this._state;
|
||||
this._state = newState;
|
||||
this.log(`State: ${prevState} → ${newState}`);
|
||||
this.emit('stateChanged', newState, prevState);
|
||||
}
|
||||
|
||||
private log(message: string): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
this.emit('log', `[${timestamp}] [Respawn] ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching the session for idle state
|
||||
*/
|
||||
start(): void {
|
||||
if (!this.config.enabled) {
|
||||
this.log('Respawn is disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._state !== 'stopped') {
|
||||
this.log('Already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.log('Starting respawn controller');
|
||||
this.setState('watching');
|
||||
this.setupTerminalListener();
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the respawn controller
|
||||
*/
|
||||
stop(): void {
|
||||
this.log('Stopping respawn controller');
|
||||
this.clearTimers();
|
||||
this.setState('stopped');
|
||||
this.session.removeAllListeners('terminal');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause respawn (keeps listening but won't trigger)
|
||||
*/
|
||||
pause(): void {
|
||||
this.log('Pausing respawn');
|
||||
this.clearTimers();
|
||||
// Stay in current state but clear timers
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume respawn
|
||||
*/
|
||||
resume(): void {
|
||||
this.log('Resuming respawn');
|
||||
if (this._state === 'watching') {
|
||||
this.checkIdleAndMaybeStart();
|
||||
}
|
||||
}
|
||||
|
||||
private setupTerminalListener(): void {
|
||||
// Clear any existing listener
|
||||
this.session.removeAllListeners('terminal');
|
||||
|
||||
this.session.on('terminal', (data: string) => {
|
||||
this.handleTerminalData(data);
|
||||
});
|
||||
}
|
||||
|
||||
private handleTerminalData(data: string): void {
|
||||
this.terminalBuffer += data;
|
||||
|
||||
// Keep buffer manageable (last 10KB)
|
||||
if (this.terminalBuffer.length > 10240) {
|
||||
this.terminalBuffer = this.terminalBuffer.slice(-5120);
|
||||
}
|
||||
|
||||
// Filter out noise - only count meaningful data as activity
|
||||
// Ignore: cursor movements, color codes alone, small whitespace-only data
|
||||
const meaningfulData = data
|
||||
.replace(/\x1b\[[0-9;]*[HJKmsu?lh]/g, '') // Remove ANSI escape sequences
|
||||
.replace(/\s+/g, '') // Remove whitespace
|
||||
.trim();
|
||||
|
||||
const isMeaningfulActivity = meaningfulData.length > 0;
|
||||
|
||||
// Detect working state
|
||||
const isWorking = this.WORKING_PATTERNS.some(pattern => data.includes(pattern));
|
||||
if (isWorking) {
|
||||
this.workingDetected = true;
|
||||
this.promptDetected = false;
|
||||
this.lastActivityTime = Date.now();
|
||||
this.clearIdleTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Detect prompt (idle) state
|
||||
const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern));
|
||||
if (hasPrompt) {
|
||||
const wasPromptDetected = this.promptDetected;
|
||||
this.promptDetected = true;
|
||||
this.workingDetected = false;
|
||||
|
||||
if (!wasPromptDetected) {
|
||||
// First time seeing prompt (or after being cleared) - log it
|
||||
this.lastActivityTime = Date.now();
|
||||
this.log('Prompt detected');
|
||||
|
||||
// Only start idle timer in watching state
|
||||
if (this._state === 'watching') {
|
||||
this.startIdleTimer();
|
||||
}
|
||||
}
|
||||
// In waiting_* states, checkXxxComplete will handle the state transition
|
||||
} else if (isMeaningfulActivity) {
|
||||
// Meaningful activity that's not a prompt or working indicator
|
||||
this.lastActivityTime = Date.now();
|
||||
if (this.promptDetected && this._state === 'watching') {
|
||||
// Still at prompt but got some other data - restart timer
|
||||
this.startIdleTimer();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle state-specific terminal data
|
||||
switch (this._state) {
|
||||
case 'waiting_update':
|
||||
this.checkUpdateComplete(data);
|
||||
break;
|
||||
case 'waiting_clear':
|
||||
this.checkClearComplete(data);
|
||||
break;
|
||||
case 'waiting_init':
|
||||
this.checkInitComplete(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private startIdleTimer(): void {
|
||||
this.clearIdleTimer();
|
||||
|
||||
this.idleTimer = setTimeout(() => {
|
||||
// Double-check we're still idle and in watching state
|
||||
if (this._state === 'watching' && this.promptDetected && !this.workingDetected) {
|
||||
const timeSinceActivity = Date.now() - this.lastActivityTime;
|
||||
this.log(`Idle timeout fired (${timeSinceActivity}ms since last activity)`);
|
||||
this.onIdleDetected();
|
||||
}
|
||||
}, this.config.idleTimeoutMs);
|
||||
}
|
||||
|
||||
private clearIdleTimer(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
this.clearIdleTimer();
|
||||
if (this.stepTimer) {
|
||||
clearTimeout(this.stepTimer);
|
||||
this.stepTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private onIdleDetected(): void {
|
||||
if (this._state !== 'watching') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the respawn cycle
|
||||
this.cycleCount++;
|
||||
this.log(`Starting respawn cycle #${this.cycleCount}`);
|
||||
this.emit('respawnCycleStarted', this.cycleCount);
|
||||
|
||||
this.sendUpdateDocs();
|
||||
}
|
||||
|
||||
private sendUpdateDocs(): void {
|
||||
this.setState('sending_update');
|
||||
this.terminalBuffer = ''; // Clear buffer for fresh detection
|
||||
|
||||
this.stepTimer = setTimeout(() => {
|
||||
const input = this.config.updatePrompt + '\n';
|
||||
this.log(`Sending update prompt: "${this.config.updatePrompt}"`);
|
||||
this.session.write(input);
|
||||
this.emit('stepSent', 'update', this.config.updatePrompt);
|
||||
this.setState('waiting_update');
|
||||
this.promptDetected = false;
|
||||
this.workingDetected = false;
|
||||
}, this.config.interStepDelayMs);
|
||||
}
|
||||
|
||||
private checkUpdateComplete(data: string): void {
|
||||
// Update is complete when we see the prompt again after working
|
||||
if (this.promptDetected && !this.workingDetected) {
|
||||
// Wait a bit more to make sure it's truly done
|
||||
this.clearIdleTimer();
|
||||
this.idleTimer = setTimeout(() => {
|
||||
if (this.promptDetected && !this.workingDetected) {
|
||||
this.log('Update docs completed');
|
||||
this.emit('stepCompleted', 'update');
|
||||
this.sendClear();
|
||||
}
|
||||
}, 3000); // 3 second verification
|
||||
}
|
||||
}
|
||||
|
||||
private sendClear(): void {
|
||||
this.setState('sending_clear');
|
||||
this.terminalBuffer = '';
|
||||
|
||||
this.stepTimer = setTimeout(() => {
|
||||
this.log('Sending /clear');
|
||||
this.session.write('/clear\n');
|
||||
this.emit('stepSent', 'clear', '/clear');
|
||||
this.setState('waiting_clear');
|
||||
this.promptDetected = false;
|
||||
}, this.config.interStepDelayMs);
|
||||
}
|
||||
|
||||
private checkClearComplete(data: string): void {
|
||||
// Clear is fast, just wait for prompt
|
||||
if (this.CLEAR_COMPLETE_PATTERN.test(data) || this.promptDetected) {
|
||||
this.clearIdleTimer();
|
||||
this.idleTimer = setTimeout(() => {
|
||||
this.log('/clear completed');
|
||||
this.emit('stepCompleted', 'clear');
|
||||
this.sendInit();
|
||||
}, 1000); // 1 second for clear
|
||||
}
|
||||
}
|
||||
|
||||
private sendInit(): void {
|
||||
this.setState('sending_init');
|
||||
this.terminalBuffer = '';
|
||||
|
||||
this.stepTimer = setTimeout(() => {
|
||||
this.log('Sending /init');
|
||||
this.session.write('/init\n');
|
||||
this.emit('stepSent', 'init', '/init');
|
||||
this.setState('waiting_init');
|
||||
this.promptDetected = false;
|
||||
this.workingDetected = false;
|
||||
}, this.config.interStepDelayMs);
|
||||
}
|
||||
|
||||
private checkInitComplete(data: string): void {
|
||||
// Init completes when we see the prompt after it finishes
|
||||
if (this.promptDetected && !this.workingDetected) {
|
||||
this.clearIdleTimer();
|
||||
this.idleTimer = setTimeout(() => {
|
||||
if (this.promptDetected && !this.workingDetected) {
|
||||
this.log('/init completed');
|
||||
this.emit('stepCompleted', 'init');
|
||||
this.completeCycle();
|
||||
}
|
||||
}, 3000); // 3 second verification for init
|
||||
}
|
||||
}
|
||||
|
||||
private completeCycle(): void {
|
||||
this.log(`Respawn cycle #${this.cycleCount} completed`);
|
||||
this.emit('respawnCycleCompleted', this.cycleCount);
|
||||
|
||||
// Go back to watching state for next cycle
|
||||
this.setState('watching');
|
||||
this.terminalBuffer = '';
|
||||
this.promptDetected = false;
|
||||
this.workingDetected = false;
|
||||
}
|
||||
|
||||
private checkIdleAndMaybeStart(): void {
|
||||
// Check if already idle
|
||||
const timeSinceActivity = Date.now() - this.lastActivityTime;
|
||||
if (timeSinceActivity > this.config.idleTimeoutMs && this.promptDetected) {
|
||||
this.onIdleDetected();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration
|
||||
*/
|
||||
updateConfig(config: Partial<RespawnConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
this.log(`Config updated: ${JSON.stringify(config)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): RespawnConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status information
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
state: this._state,
|
||||
cycleCount: this.cycleCount,
|
||||
lastActivityTime: this.lastActivityTime,
|
||||
timeSinceActivity: Date.now() - this.lastActivityTime,
|
||||
promptDetected: this.promptDetected,
|
||||
workingDetected: this.workingDetected,
|
||||
config: this.config,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
export function generateClaudeMd(caseName: string, description: string = ''): string {
|
||||
const date = new Date().toISOString().split('T')[0];
|
||||
|
||||
return `# CLAUDE.md - Project Configuration
|
||||
|
||||
## Project Overview
|
||||
|
||||
- **Project Name**: ${caseName}
|
||||
- **Description**: ${description || 'A new project'}
|
||||
- **Tech Stack**: [TECHNOLOGIES_USED]
|
||||
- **Last Updated**: ${date}
|
||||
|
||||
---
|
||||
|
||||
## Work Principles
|
||||
|
||||
### Autonomy
|
||||
Full permissions granted. Act decisively without asking - read, write, edit, execute freely.
|
||||
|
||||
### Git Discipline
|
||||
- **Commit after every meaningful change** - never batch unrelated work
|
||||
- Use conventional commits: \`feat:\`, \`fix:\`, \`docs:\`, \`refactor:\`, \`test:\`, \`chore:\`
|
||||
- Commit message = what changed + why (not how)
|
||||
|
||||
### Documentation
|
||||
- Update README.md when adding features or changing setup
|
||||
- Update this file's session log after work sessions
|
||||
- Keep docs in sync with code changes
|
||||
|
||||
### Thinking
|
||||
Extended thinking is enabled. Use deep reasoning for complex architectural decisions, difficult bugs, and multi-file changes.
|
||||
|
||||
---
|
||||
|
||||
## When to Use Agents
|
||||
|
||||
**Explore agent**: Codebase investigation, finding files, understanding architecture
|
||||
\`\`\`
|
||||
"Use explore agent to find all authentication-related code"
|
||||
\`\`\`
|
||||
|
||||
**Parallel agents**: Independent tasks that don't conflict
|
||||
\`\`\`
|
||||
"Research auth, database, and API modules in parallel using separate agents"
|
||||
\`\`\`
|
||||
|
||||
**Background execution**: Long-running operations (tests, builds)
|
||||
\`\`\`
|
||||
"Run the test suite in the background while I continue"
|
||||
\`\`\`
|
||||
|
||||
**Sequential chaining**: When second task depends on first
|
||||
\`\`\`
|
||||
"Use code-reviewer to find issues, then use fixer to resolve them"
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Planning Mode (Automatic)
|
||||
|
||||
**Automatically enter planning mode** when ANY of these conditions apply:
|
||||
- Multi-file changes (3+ files affected)
|
||||
- Architectural decisions
|
||||
- Unclear or evolving requirements
|
||||
- Risk mitigation on core systems
|
||||
- New feature implementation
|
||||
- Refactoring existing functionality
|
||||
|
||||
**Do NOT ask** whether to enter planning mode - just enter it when conditions are met.
|
||||
|
||||
Planning mode flow: read-only exploration → create plan → get approval → execute.
|
||||
|
||||
**Skip planning mode** only for:
|
||||
- Single-file bug fixes
|
||||
- Typo corrections
|
||||
- Simple config changes
|
||||
- Tasks with explicit step-by-step instructions from user
|
||||
|
||||
---
|
||||
|
||||
## Ralph Wiggum Loop (Autonomous Work Mode)
|
||||
|
||||
Ralph loops enable persistent, autonomous work on large tasks. When active, you continue iterating until completion criteria are met or the loop is cancelled.
|
||||
|
||||
### Starting a Ralph Loop
|
||||
- Start: \`/ralph-loop\`
|
||||
- Cancel: \`/cancel-ralph\`
|
||||
- Help: \`/ralph-loop:help\`
|
||||
|
||||
### Time-Aware Loops
|
||||
|
||||
When the user specifies a **minimum duration** (e.g., "optimize for 8 hours", "work on this for 2 hours"), the loop becomes time-aware:
|
||||
|
||||
**At loop start:**
|
||||
\`\`\`bash
|
||||
# Record start time
|
||||
date +%s > /tmp/ralph_start_time
|
||||
echo "Loop started at $(date)"
|
||||
\`\`\`
|
||||
|
||||
**Check elapsed time periodically:**
|
||||
\`\`\`bash
|
||||
START=$(cat /tmp/ralph_start_time)
|
||||
NOW=$(date +%s)
|
||||
ELAPSED_HOURS=$(echo "scale=2; ($NOW - $START) / 3600" | bc)
|
||||
echo "Elapsed: $ELAPSED_HOURS hours"
|
||||
\`\`\`
|
||||
|
||||
**Time-aware behavior:**
|
||||
1. Complete all primary tasks from the user's prompt
|
||||
2. After primary tasks done, check elapsed time
|
||||
3. If minimum duration NOT reached:
|
||||
- **Do NOT output completion phrase**
|
||||
- Self-generate additional related tasks
|
||||
- Continue working until minimum time elapsed
|
||||
4. Only output completion phrase when:
|
||||
- ALL primary tasks complete AND
|
||||
- Minimum duration reached (or exceeded)
|
||||
|
||||
**Self-generating additional tasks when time remains:**
|
||||
- Code optimization (performance, readability, DRY)
|
||||
- Test coverage improvements
|
||||
- Edge case handling
|
||||
- Error message improvements
|
||||
- Documentation gaps
|
||||
- Security hardening
|
||||
- Accessibility improvements
|
||||
- Code cleanup and dead code removal
|
||||
- Dependency updates
|
||||
- Type safety improvements
|
||||
|
||||
### How You Know You're in a Ralph Loop
|
||||
|
||||
The user started the loop with a prompt containing:
|
||||
- Clear task requirements
|
||||
- A **completion phrase** (e.g., \`<promise>COMPLETE</promise>\`)
|
||||
- **Optional: minimum duration** (e.g., "for the next 4 hours")
|
||||
- Iteration limits (handled by the system)
|
||||
|
||||
Your job: Keep working until ALL requirements are verifiably done AND minimum time reached (if specified), then output the exact completion phrase.
|
||||
|
||||
### Core Behaviors During Ralph Loop
|
||||
|
||||
**1. Work Incrementally**
|
||||
- Complete one sub-task at a time
|
||||
- Verify it works before moving to the next
|
||||
- Don't try to do everything in one pass
|
||||
|
||||
**2. Commit Frequently**
|
||||
- Commit after each meaningful completion
|
||||
- Creates recovery points if something breaks
|
||||
- Shows progress in git history
|
||||
\`\`\`
|
||||
git add . && git commit -m "feat(auth): add token refresh endpoint"
|
||||
\`\`\`
|
||||
|
||||
**3. Self-Correct Relentlessly**
|
||||
\`\`\`
|
||||
Loop:
|
||||
1. Implement/fix
|
||||
2. Run tests
|
||||
3. If tests fail → read error, fix, go to 1
|
||||
4. Run linter
|
||||
5. If lint errors → fix, go to 1
|
||||
6. Commit
|
||||
7. Continue to next task
|
||||
\`\`\`
|
||||
|
||||
**4. Track Progress**
|
||||
Update the session log in this file as you complete tasks:
|
||||
\`\`\`markdown
|
||||
| Date | Tasks Completed | Files Changed | Notes |
|
||||
|------|-----------------|---------------|-------|
|
||||
| YYYY-MM-DD | Add auth endpoint | auth.ts, routes.ts | Tests passing |
|
||||
\`\`\`
|
||||
|
||||
**5. Use Git History When Stuck**
|
||||
If something isn't working:
|
||||
\`\`\`bash
|
||||
git log --oneline -10
|
||||
git diff HEAD~1
|
||||
\`\`\`
|
||||
See what you already tried. Don't repeat failed approaches.
|
||||
|
||||
**6. Completion Phrase = Contract**
|
||||
Only output the completion phrase (e.g., \`<promise>COMPLETE</promise>\`) when:
|
||||
- ALL requirements from the original prompt are done
|
||||
- ALL tests pass
|
||||
- ALL linting passes
|
||||
- Changes are committed
|
||||
|
||||
**Never output the completion phrase early.** The loop only ends when you say it's done.
|
||||
|
||||
### What Makes Good Completion Criteria
|
||||
|
||||
The user should provide criteria that are:
|
||||
- **Verifiable**: Tests pass, lint clean, build succeeds
|
||||
- **Measurable**: "5 endpoints", "all files in src/", "zero errors"
|
||||
- **Binary**: Done or not done, no ambiguity
|
||||
|
||||
If the original prompt has vague criteria, ask clarifying questions before starting heavy work.
|
||||
|
||||
### Self-Correction Pattern (Include in Your Work)
|
||||
|
||||
\`\`\`
|
||||
FOR EACH TASK:
|
||||
1. Implement the change
|
||||
2. Run tests (npm test, pytest, go test, cargo test, etc.)
|
||||
- If fail → read error, fix, retry
|
||||
3. Run linter (npm run lint, ruff, golangci-lint, etc.)
|
||||
- If fail → fix, go to step 2
|
||||
4. Verify manually if needed
|
||||
5. Commit with descriptive message
|
||||
6. Update session log
|
||||
7. Move to next task
|
||||
|
||||
WHEN ALL TASKS DONE:
|
||||
1. Run full test suite
|
||||
2. Run full lint
|
||||
3. Verify build succeeds
|
||||
4. Review all changes: git diff main
|
||||
5. Only then output completion phrase
|
||||
\`\`\`
|
||||
|
||||
### When to NOT Output Completion Phrase
|
||||
|
||||
- Tests are failing (even one)
|
||||
- Lint errors exist
|
||||
- Build is broken
|
||||
- You skipped a requirement
|
||||
- You're unsure if something works
|
||||
- **Minimum duration not reached** (for time-aware loops)
|
||||
|
||||
Instead: Fix the issue, verify, then complete. For time-aware loops: generate more tasks and keep improving until minimum time elapsed.
|
||||
|
||||
---
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Before Writing
|
||||
- Read existing code in the area you're modifying
|
||||
- Follow existing patterns and conventions
|
||||
- Check for similar implementations to reference
|
||||
|
||||
### During Implementation
|
||||
- Keep changes focused and minimal
|
||||
- Don't over-engineer
|
||||
- Write tests for new functionality
|
||||
|
||||
### After Implementation
|
||||
- Run tests
|
||||
- Update docs if needed
|
||||
- Commit with descriptive message
|
||||
|
||||
---
|
||||
|
||||
## Hooks Awareness
|
||||
|
||||
This project may have hooks that auto-format code after writes or validate operations. If a tool call behaves unexpectedly, hooks are likely the cause. Continue working - they're intentional.
|
||||
|
||||
---
|
||||
|
||||
## Session Log
|
||||
|
||||
| Date | Tasks Completed | Files Changed | Notes |
|
||||
|------|-----------------|---------------|-------|
|
||||
| ${date} | Project created | CLAUDE.md | Initial setup |
|
||||
|
||||
---
|
||||
|
||||
## Current Task Queue
|
||||
|
||||
### Active Ralph Loop
|
||||
**Status**: Not Active
|
||||
**Completion Phrase**: -
|
||||
|
||||
### Pending Tasks
|
||||
- [ ] <!-- Add tasks here -->
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plans
|
||||
|
||||
<!-- Document plans before major implementations -->
|
||||
|
||||
---
|
||||
|
||||
## Notes & Decisions
|
||||
|
||||
<!-- Track important decisions and context -->
|
||||
`;
|
||||
}
|
||||
@@ -61,11 +61,23 @@ export interface AppState {
|
||||
config: AppConfig;
|
||||
}
|
||||
|
||||
export interface RespawnConfig {
|
||||
/** How long to wait after seeing prompt before considering truly idle (ms) */
|
||||
idleTimeoutMs: number;
|
||||
/** The prompt to send for updating docs */
|
||||
updatePrompt: string;
|
||||
/** Delay between sending steps (ms) */
|
||||
interStepDelayMs: number;
|
||||
/** Whether to enable respawn loop */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
pollIntervalMs: number;
|
||||
defaultTimeoutMs: number;
|
||||
maxConcurrentSessions: number;
|
||||
stateFilePath: string;
|
||||
respawn: RespawnConfig;
|
||||
}
|
||||
|
||||
export interface SessionOutput {
|
||||
@@ -85,6 +97,12 @@ export const DEFAULT_CONFIG: AppConfig = {
|
||||
defaultTimeoutMs: 300000, // 5 minutes
|
||||
maxConcurrentSessions: 5,
|
||||
stateFilePath: '',
|
||||
respawn: {
|
||||
idleTimeoutMs: 5000, // 5 seconds of no activity after prompt
|
||||
updatePrompt: 'update all the docs and CLAUDE.md',
|
||||
interStepDelayMs: 1000, // 1 second between steps
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function createInitialState(): AppState {
|
||||
|
||||
+581
-68
@@ -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) {
|
||||
|
||||
+165
-25
@@ -5,6 +5,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claudeman</title>
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -45,52 +48,173 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Respawn Banner (shown when respawn loop is active) -->
|
||||
<div class="respawn-banner" id="respawnBanner" style="display: none;">
|
||||
<div class="respawn-status-info">
|
||||
<span class="respawn-indicator">🔄</span>
|
||||
<span class="respawn-state" id="respawnState">Watching</span>
|
||||
<span class="respawn-cycles">Cycle: <span id="respawnCycleCount">0</span></span>
|
||||
</div>
|
||||
<div class="respawn-step" id="respawnStep"></div>
|
||||
<div class="respawn-actions">
|
||||
<button class="btn btn-sm" onclick="app.toggleRespawn()">
|
||||
<span id="respawnToggleText">Pause</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger" onclick="app.stopRespawn()">Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main">
|
||||
<!-- Control Panel -->
|
||||
<section class="control-panel">
|
||||
<h2>Run Claude</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Prompt</label>
|
||||
<textarea id="promptInput" placeholder="What should Claude do?"></textarea>
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-tab="run" onclick="app.switchTab('run')">Run</button>
|
||||
<button class="tab" data-tab="cases" onclick="app.switchTab('cases')">Cases</button>
|
||||
<button class="tab" data-tab="settings" onclick="app.switchTab('settings')">Settings</button>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<!-- Run Tab -->
|
||||
<div class="tab-content active" id="tab-run">
|
||||
<div class="form-group">
|
||||
<label>Directory</label>
|
||||
<input type="text" id="dirInput" placeholder="Current directory">
|
||||
<label>Prompt</label>
|
||||
<textarea id="promptInput" placeholder="What should Claude do?" rows="6"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Duration (minutes)</label>
|
||||
<input type="number" id="durationInput" value="0" min="0" max="720">
|
||||
<span class="form-hint">0 = single run</span>
|
||||
<label>Working Directory</label>
|
||||
<div class="input-with-button">
|
||||
<input type="text" id="dirInput" placeholder="Current directory">
|
||||
<button class="btn btn-sm" onclick="app.selectCase()">Select Case</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Duration</label>
|
||||
<select id="durationSelect">
|
||||
<option value="0">Single run</option>
|
||||
<option value="5">5 minutes</option>
|
||||
<option value="15">15 minutes</option>
|
||||
<option value="30">30 minutes</option>
|
||||
<option value="60">1 hour</option>
|
||||
<option value="120">2 hours</option>
|
||||
<option value="240">4 hours</option>
|
||||
<option value="480">8 hours</option>
|
||||
<option value="720">12 hours</option>
|
||||
<option value="custom">Custom...</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="customDurationGroup" style="display: none;">
|
||||
<label>Minutes</label>
|
||||
<input type="number" id="customDuration" value="60" min="1" max="1440">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Respawn Settings (for Interactive Sessions) -->
|
||||
<div class="respawn-settings" id="respawnSettings">
|
||||
<div class="respawn-header">
|
||||
<label>
|
||||
<input type="checkbox" id="respawnEnabled" checked>
|
||||
Enable Auto-Respawn Loop
|
||||
</label>
|
||||
<button class="btn btn-sm" onclick="app.toggleRespawnDetails()">⚙️</button>
|
||||
</div>
|
||||
<div class="respawn-details" id="respawnDetails" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label>Update Prompt</label>
|
||||
<input type="text" id="respawnPrompt" value="update all the docs and CLAUDE.md">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Idle Timeout (sec)</label>
|
||||
<input type="number" id="respawnIdleTimeout" value="5" min="1" max="60">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Step Delay (sec)</label>
|
||||
<input type="number" id="respawnStepDelay" value="1" min="0" max="10">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary btn-large" id="runBtn" onclick="app.startRun()">
|
||||
<span class="btn-icon">▶</span>
|
||||
Run Prompt
|
||||
</button>
|
||||
<button class="btn btn-large" id="interactiveBtn" onclick="app.startInteractive()" style="background: #22c55e;">
|
||||
<span class="btn-icon">💻</span>
|
||||
Interactive Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary btn-large" id="runBtn" onclick="app.startRun()">
|
||||
<span class="btn-icon">▶</span>
|
||||
Run
|
||||
</button>
|
||||
<!-- Cases Tab -->
|
||||
<div class="tab-content" id="tab-cases">
|
||||
<div class="section-header">
|
||||
<h3>Your Cases</h3>
|
||||
<button class="btn btn-sm btn-primary" onclick="app.showCreateCase()">+ New Case</button>
|
||||
</div>
|
||||
|
||||
<div class="cases-list" id="casesList">
|
||||
<p class="empty-state">Loading cases...</p>
|
||||
</div>
|
||||
|
||||
<!-- Create Case Form (hidden by default) -->
|
||||
<div class="create-case-form" id="createCaseForm" style="display: none;">
|
||||
<h4>Create New Case</h4>
|
||||
<div class="form-group">
|
||||
<label>Case Name</label>
|
||||
<input type="text" id="caseNameInput" placeholder="my-project">
|
||||
<span class="form-hint">Letters, numbers, hyphens, underscores only</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<input type="text" id="caseDescInput" placeholder="Project description (optional)">
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-sm" onclick="app.hideCreateCase()">Cancel</button>
|
||||
<button class="btn btn-sm btn-primary" onclick="app.createCase()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-content" id="tab-settings">
|
||||
<div class="form-group">
|
||||
<label>Default Directory</label>
|
||||
<input type="text" id="defaultDirInput" placeholder="~/projects">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="autoScrollOutput" checked>
|
||||
Auto-scroll output
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="soundOnComplete">
|
||||
Sound on completion
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-sm btn-primary" onclick="app.saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Output Panel -->
|
||||
<!-- Terminal Panel -->
|
||||
<section class="output-panel">
|
||||
<div class="output-header">
|
||||
<h2>Output</h2>
|
||||
<h2>Terminal</h2>
|
||||
<div class="output-actions">
|
||||
<button class="btn btn-sm" onclick="app.clearOutput()">Clear</button>
|
||||
<button class="btn btn-sm" onclick="app.clearTerminal()">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="output-container" id="outputContainer">
|
||||
<div class="output-placeholder">
|
||||
<div class="placeholder-icon">💬</div>
|
||||
<div class="placeholder-text">Run a prompt to see Claude's output here</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="terminal-container" id="terminalContainer"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -102,6 +226,22 @@
|
||||
</div>
|
||||
<div class="sessions-list" id="sessionsList"></div>
|
||||
</aside>
|
||||
|
||||
<!-- Case Selector Modal -->
|
||||
<div class="modal" id="caseSelectorModal">
|
||||
<div class="modal-backdrop" onclick="app.closeCaseSelector()"></div>
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Select Case</h3>
|
||||
<button class="modal-close" onclick="app.closeCaseSelector()">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="cases-grid" id="caseSelectorGrid">
|
||||
<p class="empty-state">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
|
||||
@@ -312,6 +312,25 @@ body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Terminal container for xterm.js */
|
||||
.terminal-container {
|
||||
flex: 1;
|
||||
background: #1a1a2e;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.terminal-container .xterm {
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.terminal-container .xterm-viewport {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.output-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -520,3 +539,349 @@ body {
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--accent);
|
||||
background: var(--bg-input);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Input with button */
|
||||
.input-with-button {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-with-button input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Select */
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Cases List */
|
||||
.cases-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.case-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.case-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.case-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.case-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.case-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.case-path {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Create Case Form */
|
||||
.create-case-form {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.create-case-form h4 {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Checkbox */
|
||||
input[type="checkbox"] {
|
||||
margin-right: 0.5rem;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Cases Grid (in modal) */
|
||||
.cases-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.cases-grid .case-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Respawn Settings */
|
||||
.respawn-settings {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.respawn-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.respawn-header label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.respawn-details {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Respawn Banner */
|
||||
.respawn-banner {
|
||||
background: linear-gradient(135deg, #1e3a1e 0%, #0f1f0f 100%);
|
||||
border-bottom: 1px solid var(--green);
|
||||
padding: 0.75rem 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.respawn-status-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.respawn-indicator {
|
||||
font-size: 1.25rem;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
.respawn-state {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--green);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.respawn-cycles {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.respawn-step {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.respawn-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Session Card Respawn Status */
|
||||
.session-respawn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-dark);
|
||||
border-top: 1px solid var(--border);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.session-respawn-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.session-respawn-dot.paused {
|
||||
background: var(--yellow);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.session-respawn-dot.stopped {
|
||||
background: var(--text-muted);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.session-respawn-state {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.session-respawn-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.session-respawn-actions button {
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,13 @@ import Fastify, { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { Session, ClaudeMessage } from '../session.js';
|
||||
import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js';
|
||||
import { getStore } from '../state-store.js';
|
||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -26,6 +30,7 @@ interface ScheduledRun {
|
||||
export class WebServer extends EventEmitter {
|
||||
private app: FastifyInstance;
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private respawnControllers: Map<string, RespawnController> = new Map();
|
||||
private scheduledRuns: Map<string, ScheduledRun> = new Map();
|
||||
private sseClients: Set<FastifyReply> = new Set();
|
||||
private store = getStore();
|
||||
@@ -144,6 +149,182 @@ export class WebServer extends EventEmitter {
|
||||
return { success: true, message: 'Prompt started' };
|
||||
});
|
||||
|
||||
// Start interactive Claude session (persists even if browser disconnects)
|
||||
this.app.post('/api/sessions/:id/interactive', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
}
|
||||
|
||||
if (session.isBusy()) {
|
||||
return { error: 'Session is busy' };
|
||||
}
|
||||
|
||||
try {
|
||||
await session.startInteractive();
|
||||
this.broadcast('session:interactive', { id });
|
||||
return { success: true, message: 'Interactive session started' };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Send input to interactive session
|
||||
this.app.post('/api/sessions/:id/input', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { input } = req.body as { input: string };
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
}
|
||||
|
||||
session.write(input);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Resize session terminal
|
||||
this.app.post('/api/sessions/:id/resize', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { cols, rows } = req.body as { cols: number; rows: number };
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
}
|
||||
|
||||
session.resize(cols, rows);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Get session terminal buffer (for reconnecting)
|
||||
this.app.get('/api/sessions/:id/terminal', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
terminalBuffer: session.terminalBuffer,
|
||||
status: session.status,
|
||||
};
|
||||
});
|
||||
|
||||
// ============ Respawn Controller Endpoints ============
|
||||
|
||||
// Get respawn status for a session
|
||||
this.app.get('/api/sessions/:id/respawn', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = this.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
return { enabled: false, status: null };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
...controller.getStatus(),
|
||||
};
|
||||
});
|
||||
|
||||
// Start respawn controller for a session
|
||||
this.app.post('/api/sessions/:id/respawn/start', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as Partial<RespawnConfig> | undefined;
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
}
|
||||
|
||||
// Create or get existing controller
|
||||
let controller = this.respawnControllers.get(id);
|
||||
if (!controller) {
|
||||
controller = new RespawnController(session, body);
|
||||
this.respawnControllers.set(id, controller);
|
||||
this.setupRespawnListeners(id, controller);
|
||||
} else if (body) {
|
||||
controller.updateConfig(body);
|
||||
}
|
||||
|
||||
controller.start();
|
||||
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return { success: true, status: controller.getStatus() };
|
||||
});
|
||||
|
||||
// Stop respawn controller for a session
|
||||
this.app.post('/api/sessions/:id/respawn/stop', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const controller = this.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
return { error: 'Respawn controller not found' };
|
||||
}
|
||||
|
||||
controller.stop();
|
||||
this.broadcast('respawn:stopped', { sessionId: id });
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Update respawn configuration
|
||||
this.app.put('/api/sessions/:id/respawn/config', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const config = req.body as Partial<RespawnConfig>;
|
||||
const controller = this.respawnControllers.get(id);
|
||||
|
||||
if (!controller) {
|
||||
return { error: 'Respawn controller not found' };
|
||||
}
|
||||
|
||||
controller.updateConfig(config);
|
||||
this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() });
|
||||
|
||||
return { success: true, config: controller.getConfig() };
|
||||
});
|
||||
|
||||
// Start interactive session WITH respawn enabled
|
||||
this.app.post('/api/sessions/:id/interactive-respawn', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = req.body as { respawnConfig?: Partial<RespawnConfig> } | undefined;
|
||||
const session = this.sessions.get(id);
|
||||
|
||||
if (!session) {
|
||||
return { error: 'Session not found' };
|
||||
}
|
||||
|
||||
if (session.isBusy()) {
|
||||
return { error: 'Session is busy' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Start interactive session
|
||||
await session.startInteractive();
|
||||
this.broadcast('session:interactive', { id });
|
||||
|
||||
// Create and start respawn controller
|
||||
const controller = new RespawnController(session, body?.respawnConfig);
|
||||
this.respawnControllers.set(id, controller);
|
||||
this.setupRespawnListeners(id, controller);
|
||||
controller.start();
|
||||
|
||||
this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Interactive session with respawn started',
|
||||
respawnStatus: controller.getStatus(),
|
||||
};
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// Quick run (create session, run prompt, return result)
|
||||
this.app.post('/api/run', async (req) => {
|
||||
const { prompt, workingDir } = req.body as { prompt: string; workingDir?: string };
|
||||
@@ -201,6 +382,66 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
return run;
|
||||
});
|
||||
|
||||
// Case management
|
||||
const casesDir = join(homedir(), 'claudeman-cases');
|
||||
|
||||
this.app.get('/api/cases', async () => {
|
||||
if (!existsSync(casesDir)) {
|
||||
return [];
|
||||
}
|
||||
const entries = readdirSync(casesDir, { withFileTypes: true });
|
||||
return entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => ({
|
||||
name: e.name,
|
||||
path: join(casesDir, e.name),
|
||||
hasClaudeMd: existsSync(join(casesDir, e.name, 'CLAUDE.md')),
|
||||
}));
|
||||
});
|
||||
|
||||
this.app.post('/api/cases', async (req) => {
|
||||
const { name, description } = req.body as { name: string; description?: string };
|
||||
|
||||
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) {
|
||||
return { success: false, error: 'Invalid case name. Use only letters, numbers, hyphens, underscores.' };
|
||||
}
|
||||
|
||||
const casePath = join(casesDir, name);
|
||||
|
||||
if (existsSync(casePath)) {
|
||||
return { success: false, error: 'Case already exists' };
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(casePath, { recursive: true });
|
||||
mkdirSync(join(casePath, 'src'), { recursive: true });
|
||||
|
||||
const claudeMd = generateClaudeMd(name, description || '');
|
||||
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
|
||||
|
||||
this.broadcast('case:created', { name, path: casePath });
|
||||
|
||||
return { success: true, case: { name, path: casePath } };
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
this.app.get('/api/cases/:name', async (req) => {
|
||||
const { name } = req.params as { name: string };
|
||||
const casePath = join(casesDir, name);
|
||||
|
||||
if (!existsSync(casePath)) {
|
||||
return { error: 'Case not found' };
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
path: casePath,
|
||||
hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private setupSessionListeners(session: Session): void {
|
||||
@@ -208,6 +449,10 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcast('session:output', { id: session.id, data });
|
||||
});
|
||||
|
||||
session.on('terminal', (data) => {
|
||||
this.broadcast('session:terminal', { id: session.id, data });
|
||||
});
|
||||
|
||||
session.on('message', (msg: ClaudeMessage) => {
|
||||
this.broadcast('session:message', { id: session.id, message: msg });
|
||||
});
|
||||
@@ -224,6 +469,52 @@ export class WebServer extends EventEmitter {
|
||||
session.on('exit', (code) => {
|
||||
this.broadcast('session:exit', { id: session.id, code });
|
||||
this.broadcast('session:updated', session.toDetailedState());
|
||||
|
||||
// Clean up respawn controller when session exits
|
||||
const controller = this.respawnControllers.get(session.id);
|
||||
if (controller) {
|
||||
controller.stop();
|
||||
this.respawnControllers.delete(session.id);
|
||||
}
|
||||
});
|
||||
|
||||
session.on('working', () => {
|
||||
this.broadcast('session:working', { id: session.id });
|
||||
});
|
||||
|
||||
session.on('idle', () => {
|
||||
this.broadcast('session:idle', { id: session.id });
|
||||
this.broadcast('session:updated', session.toDetailedState());
|
||||
});
|
||||
}
|
||||
|
||||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||
controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => {
|
||||
this.broadcast('respawn:stateChanged', { sessionId, state, prevState });
|
||||
});
|
||||
|
||||
controller.on('respawnCycleStarted', (cycleNumber: number) => {
|
||||
this.broadcast('respawn:cycleStarted', { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
controller.on('respawnCycleCompleted', (cycleNumber: number) => {
|
||||
this.broadcast('respawn:cycleCompleted', { sessionId, cycleNumber });
|
||||
});
|
||||
|
||||
controller.on('stepSent', (step: string, input: string) => {
|
||||
this.broadcast('respawn:stepSent', { sessionId, step, input });
|
||||
});
|
||||
|
||||
controller.on('stepCompleted', (step: string) => {
|
||||
this.broadcast('respawn:stepCompleted', { sessionId, step });
|
||||
});
|
||||
|
||||
controller.on('log', (message: string) => {
|
||||
this.broadcast('respawn:log', { sessionId, message });
|
||||
});
|
||||
|
||||
controller.on('error', (error: Error) => {
|
||||
this.broadcast('respawn:error', { sessionId, error: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -325,9 +616,16 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
private getFullState() {
|
||||
// Build respawn status map
|
||||
const respawnStatus: Record<string, ReturnType<RespawnController['getStatus']>> = {};
|
||||
for (const [sessionId, controller] of this.respawnControllers) {
|
||||
respawnStatus[sessionId] = controller.getStatus();
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: this.getSessionsState(),
|
||||
scheduledRuns: Array.from(this.scheduledRuns.values()),
|
||||
respawnStatus,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -353,6 +651,12 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
// Stop all respawn controllers
|
||||
for (const controller of this.respawnControllers.values()) {
|
||||
controller.stop();
|
||||
}
|
||||
this.respawnControllers.clear();
|
||||
|
||||
// Stop all sessions
|
||||
for (const session of this.sessions.values()) {
|
||||
await session.stop();
|
||||
|
||||
Reference in New Issue
Block a user