feat: add RespawnController for automatic Claude Code session respawning

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

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

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