diff --git a/CLAUDE.md b/CLAUDE.md index 01c6d95e..847ba5ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 "" --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 `PHRASE` 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 | diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts new file mode 100644 index 00000000..b7acd0c2 --- /dev/null +++ b/src/respawn-controller.ts @@ -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 = {}) { + 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): 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, + }; + } +} diff --git a/src/templates/claude-md.ts b/src/templates/claude-md.ts new file mode 100644 index 00000000..532ed60a --- /dev/null +++ b/src/templates/claude-md.ts @@ -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., \`COMPLETE\`) +- **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., \`COMPLETE\`) 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 +- [ ] + +--- + +## Implementation Plans + + + +--- + +## Notes & Decisions + + +`; +} diff --git a/src/types.ts b/src/types.ts index a5cafa59..e94c7c65 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 { diff --git a/src/web/public/app.js b/src/web/public/app.js index dc79e017..7ab13426 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 = '

No cases yet. Create one to get started.

'; + return; + } + + list.innerHTML = this.cases.map(c => ` +
+ 📁 +
+
${this.escapeHtml(c.name)}
+
${this.escapeHtml(c.path)}
+
+
+ `).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 = '

No cases. Create one in the Cases tab.

'; + } else { + grid.innerHTML = this.cases.map(c => ` +
+ 📁 +
+
${this.escapeHtml(c.name)}
+
+
+ `).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 = ` -
-
💬
-
Run a prompt to see Claude's output here
-
- `; + // 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 => ` -
-
-
- - ${s.id.slice(0, 8)} -
- $${(s.totalCost || 0).toFixed(4)} + list.innerHTML = Array.from(this.sessions.values()).map(s => { + const respawn = this.respawnStatus[s.id]; + const respawnHtml = respawn ? ` +
+ + ${respawn.state.replace(/_/g, ' ')} + + +
-
${this.escapeHtml(s.textOutput || '')}
-
- `).join(''); + ` : ` +
+ + No respawn + + + +
+ `; + + return ` +
+
+
+ + ${s.id.slice(0, 8)} +
+ $${(s.totalCost || 0).toFixed(4)} +
+ ${respawnHtml} +
+ `; + }).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) { diff --git a/src/web/public/index.html b/src/web/public/index.html index 71ad6672..415a22e7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -5,6 +5,9 @@ Claudeman + + +
@@ -45,52 +48,173 @@
+ + +
-

Run Claude

- -
- - + +
+ + +
-
+ +
- - + +
+
- - - 0 = single run + +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+ +
-
- + +
+
+

Your Cases

+ +
+ +
+

Loading cases...

+
+ + + +
+ + +
+
+ + +
+
+ +
+
+ +
+
+ +
- +
-

Output

+

Terminal

- +
-
-
-
💬
-
Run a prompt to see Claude's output here
-
-
+
@@ -102,6 +226,22 @@
+ + + diff --git a/src/web/public/styles.css b/src/web/public/styles.css index fbc1ed19..e887c93a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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; +} diff --git a/src/web/server.ts b/src/web/server.ts index 19cfc613..8af159d3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 = new Map(); + private respawnControllers: Map = new Map(); private scheduledRuns: Map = new Map(); private sseClients: Set = 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 | 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; + 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 } | 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> = {}; + 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 { + // 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();