diff --git a/CLAUDE.md b/CLAUDE.md index 271a7577..a08a92ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,85 +39,107 @@ npm install **CRITICAL**: `npm run dev` runs CLI help, NOT the web server. Use `npx tsx src/index.ts web` for development. +### Build & Clean + ```bash npm run build # Compile TS + copy static files + templates + make bins executable npm run clean # Remove dist/ +npm run typecheck # Type check without building (or: npx tsc --noEmit) +``` -# Start web server (pick one): +### Web Server + +```bash npx tsx src/index.ts web # Dev mode - no build needed (RECOMMENDED) npx tsx src/index.ts web -p 8080 # Dev mode with custom port npx tsx src/index.ts web --https # Dev mode with self-signed TLS (enables browser notifications) npm run web # After npm run build (shorthand) node dist/index.js web # After npm run build claudeman web # After npm link +``` -# Start TUI (terminal user interface): +### TUI (Terminal User Interface) + +```bash npx tsx src/index.ts tui # Dev mode - prompts to start web if not running claudeman tui # After npm link claudeman tui --with-web # Auto-start web server if not running (no prompt) claudeman tui --no-web # Skip web server check entirely claudeman tui -p 8080 # Specify web server port +``` -# Testing (vitest) -# Note: globals: true configured - no imports needed for describe/it/expect +### Testing + +```bash npm run test # Run all tests once npm run test:watch # Watch mode npm run test:coverage # With coverage report npx vitest run test/session.test.ts # Single file npx vitest run -t "should create session" # By pattern +``` -# Test port allocation (integration tests spawn servers): -# 3099: quick-start.test.ts -# 3102: session.test.ts -# 3105: scheduled-runs.test.ts -# 3107: sse-events.test.ts -# 3110: edge-cases.test.ts -# 3115: integration-flows.test.ts -# 3120: session-cleanup.test.ts -# 3125: ralph-integration.test.ts -# Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator, hooks-config, ai-idle-checker -# Next available: 3127+ +**Test Configuration** (vitest.config.ts): +- `globals: true` - no imports needed for `describe`/`it`/`expect` +- `testTimeout: 30000` - 30s for integration tests +- `teardownTimeout: 60000` - 60s ensures cleanup runs even on failures +- `fileParallelism: false` - sequential file execution to respect screen session limits +- Coverage excludes entry points: `src/index.ts`, `src/cli.ts` -# Tests mock PTY - no real Claude CLI spawned -# Test timeout: 30s (configured in vitest.config.ts) -# Teardown timeout: 60s (ensures cleanup runs even on failures) -# Coverage excludes: src/index.ts, src/cli.ts (entry points) -# Global test utilities (describe/it/expect) available without imports (globals: true) -# Tests run sequentially (fileParallelism: false) to respect screen session limits -# Global setup (test/setup.ts) enforces max 10 concurrent screens + orphan cleanup -# -# ✅ TEST SAFETY: test/setup.ts protects its own process tree during cleanup. -# You can safely run tests from within a Claudeman-managed session - the cleanup -# will not kill your own Claude instance. The respawn-controller tests use -# MockSession (not real screens). +**Test Port Allocation** (integration tests spawn servers): -# TypeScript checking -npm run typecheck # Type check without building (or: npx tsc --noEmit) -# Note: No ESLint/Prettier configured - rely on TypeScript strict mode +| Port | Test File | +|------|-----------| +| 3099 | quick-start.test.ts | +| 3102 | session.test.ts | +| 3105 | scheduled-runs.test.ts | +| 3107 | sse-events.test.ts | +| 3110 | edge-cases.test.ts | +| 3115 | integration-flows.test.ts | +| 3120 | session-cleanup.test.ts | +| 3125 | ralph-integration.test.ts | +| 3127+ | Next available | -# MCP Server (for Claude Code to call spawn tools directly): -# Configure in Claude Code's MCP settings: -# command: "node", args: ["dist/mcp-server.js"] -# env: { CLAUDEMAN_API_URL: "http://localhost:3000", CLAUDEMAN_SESSION_ID: "" } +Unit tests (no port needed): respawn-controller, ralph-tracker, pty-interactive, task-queue, task, ralph-loop, session-manager, state-store, types, templates, ralph-config, spawn-detector, spawn-types, spawn-orchestrator, hooks-config, ai-idle-checker + +**Test Safety**: `test/setup.ts` enforces max 10 concurrent screens, performs orphan cleanup, and protects its own process tree. You can safely run tests from within a Claudeman-managed session - the cleanup will not kill your own Claude instance. The respawn-controller tests use MockSession (not real screens). + +### MCP Server + +```bash npx tsx src/mcp-server.ts # Dev mode (stdio transport) +``` -# Debugging +Configure in Claude Code's MCP settings: +```json +{ "command": "node", "args": ["dist/mcp-server.js"], "env": { "CLAUDEMAN_API_URL": "http://localhost:3000", "CLAUDEMAN_SESSION_ID": "" } } +``` + +### Debugging + +```bash screen -ls # List GNU screen sessions screen -r # Attach to screen session (Ctrl+A D to detach) curl localhost:3000/api/sessions # Check active sessions curl localhost:3000/api/status | jq . # Full app state including respawn cat ~/.claudeman/state.json | jq . # View main state cat ~/.claudeman/state-inner.json | jq . # View Ralph loop state +``` -# Systemd service (respawning, survives logout): +### Systemd Service + +```bash systemctl --user status claudeman-web # Check status systemctl --user restart claudeman-web # Restart systemctl --user stop claudeman-web # Stop journalctl --user -u claudeman-web -f # Stream logs -# Install: ln -sf scripts/claudeman-web.service ~/.config/systemd/user/ -# Enable: systemctl --user enable claudeman-web && loginctl enable-linger $USER +``` -# Kill stuck screen sessions +Install: `ln -sf scripts/claudeman-web.service ~/.config/systemd/user/` +Enable: `systemctl --user enable claudeman-web && loginctl enable-linger $USER` + +### Kill Stuck Screens + +```bash screen -X -S quit # Graceful quit pkill -f "SCREEN.*claudeman" # Force kill all claudeman screens ``` @@ -149,16 +171,6 @@ claudeman status # Overall status claudeman reset # Reset all state ``` -## Keyboard Shortcuts (Web UI) - -| Shortcut | Action | -|----------|--------| -| `Ctrl+Enter` | Quick-start session | -| `Ctrl+W` | Close session | -| `Ctrl+Tab` | Next session | -| `Ctrl+K` | Kill all sessions | -| `Ctrl+L` | Clear terminal | - ## Architecture ### Key Files @@ -405,19 +417,9 @@ CLI commands (`claudeman status/session list`) read from `state.json` to display ### TypeScript Config -Module resolution: NodeNext. Target: ES2022. Strict mode with additional checks: +Module resolution: NodeNext. Target: ES2022. Strict mode enabled with all additional strictness flags (`noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, etc.). No ESLint/Prettier configured - rely on TypeScript strict mode. -| Setting | Effect | -|---------|--------| -| `noUnusedLocals` | Error on unused local variables | -| `noUnusedParameters` | Error on unused function parameters | -| `noImplicitReturns` | All code paths must return a value | -| `noImplicitOverride` | Require `override` keyword for overridden methods | -| `noFallthroughCasesInSwitch` | Require break/return in switch cases | -| `allowUnreachableCode: false` | Error on unreachable code | -| `allowUnusedLabels: false` | Error on unused labels | - -TUI uses React JSX (`jsxImportSource: react`) for Ink components. +TUI uses React JSX (`jsx: react-jsx`, `jsxImportSource: react`) for Ink components. ## Adding New Features diff --git a/src/web/public/app.js b/src/web/public/app.js index 8f34d1a9..e891610b 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -917,18 +917,12 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:stepSent', (e) => { - const data = JSON.parse(e.data); - if (data.sessionId === this.activeSessionId) { - document.getElementById('respawnStep').textContent = data.input; - } + this.eventSource.addEventListener('respawn:stepSent', (_e) => { + // Step info is shown via state label (e.g., "Sending prompt", "Clearing context") }); this.eventSource.addEventListener('respawn:autoAcceptSent', (e) => { const data = JSON.parse(e.data); - if (data.sessionId === this.activeSessionId) { - document.getElementById('respawnStep').textContent = '⏎ Auto-accepted plan'; - } const session = this.sessions.get(data.sessionId); this.notificationManager?.notify({ urgency: 'info', @@ -950,34 +944,20 @@ class ClaudemanApp { } }); - this.eventSource.addEventListener('respawn:aiCheckStarted', (e) => { - const data = JSON.parse(e.data); - if (data.sessionId === this.activeSessionId) { - document.getElementById('respawnStep').textContent = 'AI Check: Analyzing...'; - } + this.eventSource.addEventListener('respawn:aiCheckStarted', (_e) => { + // AI check status shown via updateDetectionDisplay }); - this.eventSource.addEventListener('respawn:aiCheckCompleted', (e) => { - const data = JSON.parse(e.data); - if (data.sessionId === this.activeSessionId) { - const icon = data.verdict === 'IDLE' ? '✓' : '⏳'; - document.getElementById('respawnStep').textContent = `AI Check: ${data.verdict} ${icon}`; - } + this.eventSource.addEventListener('respawn:aiCheckCompleted', (_e) => { + // AI check status shown via updateDetectionDisplay }); - this.eventSource.addEventListener('respawn:aiCheckFailed', (e) => { - const data = JSON.parse(e.data); - if (data.sessionId === this.activeSessionId) { - document.getElementById('respawnStep').textContent = `AI Check: Error - ${data.error?.substring(0, 50)}`; - } + this.eventSource.addEventListener('respawn:aiCheckFailed', (_e) => { + // AI check status shown via updateDetectionDisplay }); - this.eventSource.addEventListener('respawn:aiCheckCooldown', (e) => { - const data = JSON.parse(e.data); - if (data.sessionId === this.activeSessionId && data.active) { - const remaining = Math.ceil((data.endsAt - Date.now()) / 1000); - document.getElementById('respawnStep').textContent = `AI Check: WORKING (cooldown ${remaining}s)`; - } + this.eventSource.addEventListener('respawn:aiCheckCooldown', (_e) => { + // AI check status shown via updateDetectionDisplay }); // Respawn run timer events (timed respawn runs) @@ -1981,8 +1961,28 @@ class ClaudemanApp { this.hideRespawnTimer(); } + // Human-friendly state labels + getStateLabel(state) { + const labels = { + 'stopped': 'Stopped', + 'watching': 'Watching', + 'confirming_idle': 'Confirming idle', + 'ai_checking': 'AI checking', + 'sending_update': 'Sending prompt', + 'waiting_update': 'Running prompt', + 'sending_clear': 'Clearing context', + 'waiting_clear': 'Clearing...', + 'sending_init': 'Initializing', + 'waiting_init': 'Initializing...', + 'monitoring_init': 'Waiting for work', + 'sending_kickstart': 'Kickstarting', + 'waiting_kickstart': 'Kickstarting...', + }; + return labels[state] || state.replace(/_/g, ' '); + } + updateRespawnBanner(state) { - this.$('respawnState').textContent = state.replace(/_/g, ' '); + this.$('respawnState').textContent = this.getStateLabel(state); } updateDetectionDisplay(detection) { @@ -1991,50 +1991,48 @@ class ClaudemanApp { const statusEl = this.$('detectionStatus'); const waitingEl = this.$('detectionWaiting'); const confidenceEl = this.$('detectionConfidence'); + const aiCheckEl = document.getElementById('detectionAiCheck'); - // Update status text - statusEl.textContent = detection.statusText || ''; - - // Update waiting for text - if (detection.waitingFor) { - waitingEl.textContent = `→ ${detection.waitingFor}`; + // Simplified status - only show when meaningful + if (detection.statusText && detection.statusText !== 'Watching...') { + statusEl.textContent = detection.statusText; + statusEl.style.display = ''; } else { - waitingEl.textContent = ''; + statusEl.style.display = 'none'; } - // Update confidence level + // Hide "waiting for" text - it's redundant with the state label + waitingEl.style.display = 'none'; + + // Show confidence only when confirming (>0%) const confidence = detection.confidenceLevel || 0; if (confidence > 0) { confidenceEl.textContent = `${confidence}%`; confidenceEl.style.display = ''; confidenceEl.className = 'detection-confidence'; - if (confidence >= 60) { - confidenceEl.classList.add('high'); - } else if (confidence >= 30) { - confidenceEl.classList.add('medium'); - } + if (confidence >= 60) confidenceEl.classList.add('high'); + else if (confidence >= 30) confidenceEl.classList.add('medium'); } else { confidenceEl.style.display = 'none'; } - // Show AI check info if available - const aiCheckEl = document.getElementById('detectionAiCheck'); + // Simplified AI check display if (aiCheckEl && detection.aiCheck) { const ai = detection.aiCheck; let aiText = ''; if (ai.status === 'checking') { - aiText = 'AI: Analyzing...'; + aiText = 'AI analyzing...'; } else if (ai.status === 'cooldown' && ai.cooldownEndsAt) { const remaining = Math.ceil((ai.cooldownEndsAt - Date.now()) / 1000); - aiText = `AI: WORKING (${remaining}s cooldown)`; + if (remaining > 0) aiText = `Cooldown ${remaining}s`; } else if (ai.status === 'disabled') { - aiText = `AI: Disabled (${ai.disabledReason || 'errors'})`; - } else if (ai.lastVerdict) { - aiText = `AI: Last=${ai.lastVerdict}`; - if (ai.lastCheckDurationMs) aiText += ` (${Math.round(ai.lastCheckDurationMs / 1000)}s)`; + aiText = 'AI disabled'; } + // Don't show "last verdict" - it's noise aiCheckEl.textContent = aiText; aiCheckEl.style.display = aiText ? '' : 'none'; + } else if (aiCheckEl) { + aiCheckEl.style.display = 'none'; } } @@ -2062,6 +2060,12 @@ class ClaudemanApp { } const timer = this.respawnTimers[this.activeSessionId]; + // Guard against invalid timer data + if (!timer.endAt || isNaN(timer.endAt)) { + this.hideRespawnTimer(); + return; + } + const now = Date.now(); const remaining = Math.max(0, timer.endAt - now); diff --git a/src/web/public/index.html b/src/web/public/index.html index 80d99c82..7837a144 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -70,25 +70,19 @@
↻ Watching - - - - - - - Cycle 0 + + + + + + #0 -
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 77fb4c83..5d703949 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -568,20 +568,16 @@ body { .respawn-state { color: var(--green); font-weight: 500; - text-transform: capitalize; +} + +.respawn-spacer { + flex: 1; } .respawn-cycles { - color: var(--text-dim); -} - -.respawn-step { - flex: 1; color: var(--text-muted); - font-family: monospace; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + font-family: 'SF Mono', Monaco, monospace; + font-size: 0.7rem; } .respawn-timer { @@ -599,23 +595,14 @@ body { font-size: 0.7rem; } -.respawn-detection { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0 0.5rem; - border-left: 1px solid rgba(34, 197, 94, 0.2); -} - .detection-status { color: var(--text-muted); font-size: 0.7rem; } -.detection-waiting { +.detection-ai-check { color: var(--accent); font-size: 0.7rem; - font-style: italic; } .detection-confidence {