diff --git a/CLAUDE.md b/CLAUDE.md index f1776a85..71456be0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,10 +14,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## CRITICAL: Session Safety -**You may be running inside a Claudeman-managed tmux/screen session.** Before killing ANY tmux, screen, or Claude process: +**You may be running inside a Claudeman-managed tmux session.** Before killing ANY tmux or Claude process: -1. Check: `echo $CLAUDEMAN_SCREEN` - if `1`, you're in a managed session -2. **NEVER** run `tmux kill-session`, `screen -X quit`, `pkill tmux`, `pkill screen`, or `pkill claude` without confirming +1. Check: `echo $CLAUDEMAN_TMUX` - if `1`, you're in a managed session +2. **NEVER** run `tmux kill-session`, `pkill tmux`, or `pkill claude` without confirming 3. Use the web UI or `./scripts/tmux-manager.sh` instead of direct kill commands ## COM Shorthand (Deployment) @@ -26,7 +26,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`) 2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web` -**Version**: 0.1487 (must match `package.json` for npm publish) +**Version**: 0.1488 (must match `package.json` for npm publish) ## Project Overview @@ -36,7 +36,7 @@ Claudeman is a Claude Code session manager with web interface and autonomous Ral **TypeScript Strictness** (see `tsconfig.json`): `noUnusedLocals`, `noUnusedParameters`, `noImplicitReturns`, `noImplicitOverride`, `noFallthroughCasesInSwitch`, `allowUnreachableCode: false`, `allowUnusedLabels: false`. Note: `src/tui` is excluded from compilation (legacy/deprecated code path). -**Requirements**: Node.js 18+, Claude CLI, tmux (preferred) or GNU Screen (deprecated fallback via `CLAUDEMAN_MUX=screen`) +**Requirements**: Node.js 18+, Claude CLI, tmux ## Commands @@ -72,8 +72,7 @@ journalctl --user -u claudeman-web -f - **`npm run dev` is NOT the web server** — it shows CLI help. Use `npx tsx src/index.ts web` - **Single-line prompts only** — `writeViaScreen()` sends text and Enter separately; multi-line breaks Ink -- **Test screens need 'test' in name** — The cleanup system only kills screens containing 'test' -- **Don't kill screens blindly** — Check `$CLAUDEMAN_SCREEN` first; you might be inside one +- **Don't kill tmux sessions blindly** — Check `$CLAUDEMAN_TMUX` first; you might be inside one - **Port 3000 during E2E** — Tests use ports 3183-3193; don't run dev server on 3000 while testing ## Import Conventions @@ -90,9 +89,9 @@ journalctl --user -u claudeman-web -f |------|---------| | `src/session.ts` | PTY wrapper: `runPrompt()`, `startInteractive()`, `startShell()` | | `src/mux-interface.ts` | `TerminalMultiplexer` interface + `MuxSession` type | -| `src/mux-factory.ts` | Auto-detect tmux/screen, create multiplexer (`CLAUDEMAN_MUX` override) | -| `src/tmux-manager.ts` | tmux session management (preferred backend) | -| `src/screen-manager.ts` | GNU screen persistence, ghost discovery (deprecated fallback) | +| `src/mux-factory.ts` | Create tmux multiplexer (`CLAUDEMAN_MUX` override for legacy screen) | +| `src/tmux-manager.ts` | tmux session management | +| `src/screen-manager.ts` | GNU screen fallback (deprecated) | | `src/session-manager.ts` | Session lifecycle, cleanup | | `src/state-store.ts` | State persistence to `~/.claudeman/state.json` | | `src/respawn-controller.ts` | State machine for autonomous cycling | @@ -155,9 +154,9 @@ journalctl --user -u claudeman-web -f ### Key Patterns -**Input to sessions**: Use `session.writeViaScreen()` for programmatic input (respawn, auto-compact). With tmux, uses `send-keys -l` (literal text) + `send-keys Enter` — single command, no delay. With screen (deprecated), text and Enter sent as separate `screen -X stuff` commands with 100ms delay. All prompts must be single-line. +**Input to sessions**: Use `session.writeViaScreen()` for programmatic input (respawn, auto-compact). Uses tmux `send-keys -l` (literal text) + `send-keys Enter`. All prompts must be single-line. -**Terminal multiplexer abstraction**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts tmux vs screen. `createMultiplexer()` from `src/mux-factory.ts` auto-detects tmux (preferred) or falls back to screen. Set `CLAUDEMAN_MUX=screen` env var to force screen backend. +**Terminal multiplexer**: `TerminalMultiplexer` interface (`src/mux-interface.ts`) abstracts the backend. `createMultiplexer()` from `src/mux-factory.ts` creates the tmux backend. Legacy screen fallback exists via `CLAUDEMAN_MUX=screen` but is deprecated. **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. @@ -182,7 +181,6 @@ journalctl --user -u claudeman-web -f |------|---------| | `~/.claudeman/state.json` | Sessions, settings, tokens, respawn config | | `~/.claudeman/mux-sessions.json` | Tmux session metadata for recovery | -| `~/.claudeman/screens.json` | Legacy screen metadata (auto-migrated to mux-sessions.json) | | `~/.claudeman/settings.json` | User preferences | ## Default Settings @@ -193,20 +191,13 @@ UI defaults are set in `src/web/public/app.js` using `??` fallbacks. To change d ## Testing -**Port allocation**: E2E tests use centralized ports in `test/e2e/e2e.config.ts` (3183-3193). Unit/integration tests pick unique ports manually (team tests: 3150-3151). Search `const PORT =` or `TEST_PORT` in test files to find used ports before adding new tests. +**Ports**: E2E uses 3183-3193 (see `test/e2e/e2e.config.ts`). Unit tests pick unique ports manually. Search `const PORT =` before adding new tests. -**E2E tests**: Use Playwright. Run `npx playwright install chromium` first. See `test/e2e/fixtures/` for helpers. E2E config (`test/e2e/e2e.config.ts`) provides ports (3183-3193), timeouts, and helpers. +**Config**: Vitest with `globals: true`, `fileParallelism: false`. Unit timeout 30s, E2E timeout 90s. E2E requires `npx playwright install chromium`. -**Test config**: Vitest runs with `globals: true` (no imports needed for `describe`/`it`/`expect`/`vi`) and `fileParallelism: false` (files run sequentially to respect screen limits). Unit test timeout is 30s, teardown timeout is 60s. E2E tests have longer timeouts defined in `test/e2e/e2e.config.ts` (90s test, 30s session creation). Mock helpers in `test/setup.ts` auto-run before all tests. +**Safety**: `test/setup.ts` snapshots pre-existing tmux sessions at load time and never kills them. Only sessions registered via `registerTestTmuxSession()` get cleaned up. -**Test safety**: `test/setup.ts` provides: -- Screen concurrency limiter (max 10) -- Pre-existing screen protection (never kills screens present before tests) -- Tracked resource cleanup (only kills screens/processes tests register) -- Safe to run from within Claudeman-managed sessions -- Exported helpers: `acquireScreenSlot()`, `releaseScreenSlot()`, `registerTestScreen()`, `unregisterTestScreen()` - -Respawn tests use MockSession to avoid spawning real Claude processes. See `test/respawn-test-utils.ts` for MockSession, MockAiIdleChecker, MockAiPlanChecker, state trackers, and terminal output generators. +**Respawn tests**: Use MockSession from `test/respawn-test-utils.ts` to avoid spawning real Claude processes. ## Debugging @@ -231,7 +222,7 @@ curl localhost:3000/api/sessions/:id/run-summary | jq # Session timeline | SSE not connecting | Browser console for errors | Check CORS, ensure server running | | Respawn not triggering | Session settings → Respawn enabled? | Enable respawn, check idle timeout config | | Terminal blank on tab switch | Network tab for `/api/sessions/:id/buffer` | Check session exists, restart server | -| Tests failing on session limits | `tmux list-sessions \| wc -l` | Clean up test sessions: `tmux list-sessions \| grep test \| awk -F: '{print $1}' \| xargs -I{} tmux kill-session -t {}` | +| Tests failing on session limits | `tmux list-sessions \| wc -l` | Clean up: `tmux list-sessions \| grep test \| awk -F: '{print $1}' \| xargs -I{} tmux kill-session -t {}` | | State not persisting | `cat ~/.claudeman/state.json` | Check file permissions, disk space | ## Performance Constraints @@ -296,7 +287,7 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base | **Test utilities** | `test/respawn-test-utils.ts` | | **Memory leak patterns** | `test/memory-leak-prevention.test.ts` | | **Keyboard shortcuts** | README.md or App Settings in web UI | -| **Mobile/SSH access** | README.md (Claudeman Screens / `sc` command) | +| **Mobile/SSH access** | README.md (Claudeman Sessions / `sc` command) | | **Plan orchestrator** | `src/plan-orchestrator.ts` file header | | **Agent prompts** | `src/prompts/` directory | | **Agent Teams (experimental)** | `agent-teams/README.md`, `agent-teams/design.md` | @@ -307,8 +298,6 @@ Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-base |--------|---------| | `scripts/tmux-manager.sh` | Safe tmux session management (use instead of direct kill commands) | | `scripts/tmux-chooser.sh` | Mobile-friendly tmux session picker (`sc` alias) | -| `scripts/screen-manager.sh` | Legacy screen management (deprecated, use tmux-manager.sh) | -| `scripts/screen-chooser.sh` | Legacy screen session picker (deprecated, use tmux-chooser.sh) | | `scripts/monitor-respawn.sh` | Monitor respawn state machine in real-time | | `scripts/postinstall.js` | npm postinstall hook for setup | | `scripts/data-generator.sh` | Generate test data for development | diff --git a/package.json b/package.json index b5476b76..25d0d28f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1487", + "version": "0.1488", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "type": "module", "main": "dist/index.js", diff --git a/src/ai-idle-checker.ts b/src/ai-idle-checker.ts index 89b401cb..629050b1 100644 --- a/src/ai-idle-checker.ts +++ b/src/ai-idle-checker.ts @@ -149,25 +149,12 @@ export class AiIdleChecker extends AiCheckerBase< protected readonly logPrefix = '[AiIdleChecker]'; protected readonly checkDescription = 'AI idle check'; - /** Number of active teammates (injected by respawn controller) */ - private teammateCount: number = 0; - constructor(sessionId: string, config: Partial = {}) { super(sessionId, DEFAULT_AI_CHECK_CONFIG, config); } - /** Set active teammate count for prompt context injection */ - setTeammateCount(count: number): void { - this.teammateCount = count; - } - protected buildPrompt(terminalBuffer: string): string { - let prompt = AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer); - if (this.teammateCount > 0) { - const teamContext = `\n\n## Agent Teams Context\n${this.teammateCount} agent teammate(s) are currently working on tasks for this session.\nIf teammates are active, answer WORKING — the lead is waiting for their results.\n`; - prompt = prompt.replace('## Terminal Output to Analyze', teamContext + '## Terminal Output to Analyze'); - } - return prompt; + return AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer); } protected parseVerdict(output: string): { verdict: AiCheckVerdict; reasoning: string } | null { diff --git a/src/bash-tool-parser.ts b/src/bash-tool-parser.ts index cb37f8d4..6e6e92c5 100644 --- a/src/bash-tool-parser.ts +++ b/src/bash-tool-parser.ts @@ -412,7 +412,7 @@ export class BashToolParser extends EventEmitter { processTerminalData(data: string): void { if (!this._enabled || this._destroyed) return; - // Append to line buffer + // Append to line buffer (raw data — lines will be stripped individually in processLine) this._lineBuffer += data; // Prevent unbounded growth @@ -434,14 +434,44 @@ export class BashToolParser extends EventEmitter { } } + /** + * Process pre-stripped terminal data (ANSI codes already removed). + * Use this when the caller has already stripped ANSI to avoid redundant regex work. + */ + processCleanData(data: string): void { + if (!this._enabled || this._destroyed) return; + + this._lineBuffer += data; + + if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) { + const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2); + this._lineBuffer = trimPoint > 0 + ? this._lineBuffer.slice(trimPoint + 1) + : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2); + } + + const lines = this._lineBuffer.split('\n'); + this._lineBuffer = lines.pop() || ''; + + for (const line of lines) { + this.processCleanLine(line); + } + } + // ========== Private Methods ========== /** - * Process a single line of terminal output. + * Process a single line of terminal output (raw — will strip ANSI). */ private processLine(line: string): void { - // Strip ANSI codes for cleaner pattern matching const cleanLine = this.stripAnsi(line); + this.processCleanLine(cleanLine); + } + + /** + * Process a single pre-stripped line of terminal output. + */ + private processCleanLine(cleanLine: string): void { // Check for tool start const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN); diff --git a/src/index.ts b/src/index.ts index afcb6528..ba0a4003 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,15 +10,28 @@ import { program } from './cli.js'; +// Detect if we're running the web server (long-lived process) +// In web mode, we should NOT exit on transient errors — log and continue +const isWebMode = process.argv.includes('web'); + // Handle uncaught errors process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err.message); - process.exit(1); + if (isWebMode) { + // Log full stack trace for debugging but keep the server running + console.error('[RECOVERED] Server continuing after uncaught exception:', err.stack); + } else { + process.exit(1); + } }); process.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason); - process.exit(1); + if (isWebMode) { + console.error('[RECOVERED] Server continuing after unhandled rejection'); + } else { + process.exit(1); + } }); // Run CLI diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 9adb99dd..7b066441 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -1317,6 +1317,14 @@ export class RalphTracker extends EventEmitter { processTerminalData(data: string): void { // Remove ANSI escape codes for cleaner parsing const cleanData = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, ''); + this.processCleanData(cleanData); + } + + /** + * Process pre-stripped terminal data (ANSI codes already removed). + * Use this when the caller has already stripped ANSI to avoid redundant regex work. + */ + processCleanData(cleanData: string): void { // If tracker is disabled, only check for patterns that should auto-enable it if (!this._loopState.enabled) { diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index b0cc181e..20bfbebe 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -57,7 +57,6 @@ import type { CycleOutcome, HealthStatus, } from './types.js'; -import type { TeamWatcher } from './team-watcher.js'; // ========== Constants ========== @@ -639,9 +638,6 @@ export class RespawnController extends EventEmitter { /** The session being controlled */ private session: Session; - /** Optional team watcher for team-aware idle detection */ - private teamWatcher: TeamWatcher | null = null; - /** Current configuration */ private config: RespawnConfig; @@ -1235,10 +1231,6 @@ export class RespawnController extends EventEmitter { * * @fires stateChanged - Transitions to 'watching' */ - setTeamWatcher(watcher: TeamWatcher | null): void { - this.teamWatcher = watcher; - } - start(): void { if (!this.config.enabled) { this.log('Respawn is disabled'); @@ -2191,12 +2183,6 @@ export class RespawnController extends EventEmitter { // Get the terminal buffer for analysis const buffer = this.terminalBuffer.value; - // Inject teammate context for team-aware AI check - if (this.teamWatcher) { - const count = this.teamWatcher.getActiveTeammateCount(this.session.id); - this.aiChecker.setTeammateCount(count); - } - this.aiChecker.check(buffer).then((result) => { // If state changed while checking (e.g., cancelled), ignore result if (this._state !== 'ai_checking') { @@ -2750,17 +2736,6 @@ export class RespawnController extends EventEmitter { return; } - // Team-awareness: don't respawn if teammates are active - if (this.teamWatcher?.hasActiveTeammates(this.session.id)) { - this.log(`Idle confirmation rejected - active teammates working (reason was: ${reason})`); - this.logAction('detection', 'Rejected: Active teammates'); - this.emit('respawnBlocked', { reason: 'active_teammates', details: 'Team has active teammates' }); - this.setState('watching'); - this.startNoOutputTimer(); - this.startPreFilterTimer(); - return; - } - this.log(`Idle confirmed via: ${reason}`); const status = this.getDetectionStatus(); this.log(`Detection status: confidence=${status.confidenceLevel}%, ` + diff --git a/src/session.ts b/src/session.ts index 3647ed70..0fa1ef65 100644 --- a/src/session.ts +++ b/src/session.ts @@ -430,6 +430,13 @@ export class Session extends EventEmitter { niceConfig?: NiceConfig; // Nice prioritying configuration }) { super(); + + // Default error handler prevents unhandled 'error' events from crashing the process. + // Server attaches its own handler after construction — this is a safety net for the gap. + this.on('error', (err) => { + console.error(`[Session] Unhandled error event:`, err); + }); + this.id = config.id || uuidv4(); this.workingDir = config.workingDir; this.createdAt = config.createdAt || Date.now(); @@ -1016,11 +1023,11 @@ export class Session extends EventEmitter { this.emit('terminal', data); this.emit('output', data); - // Forward to Ralph tracker to detect Ralph loops and todos (handles own stripping) - this._ralphTracker.processTerminalData(data); + // Forward to Ralph tracker to detect Ralph loops and todos (pre-stripped) + this._ralphTracker.processCleanData(cleanData); - // Forward to Bash tool parser to detect file-viewing commands (handles own stripping) - this._bashToolParser.processTerminalData(data); + // Forward to Bash tool parser to detect file-viewing commands (pre-stripped) + this._bashToolParser.processCleanData(cleanData); // Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens") this.parseTokensFromStatusLine(cleanData); diff --git a/src/state-store.ts b/src/state-store.ts index a3e8e0c0..9f95c033 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -15,6 +15,7 @@ */ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs'; +import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js'; @@ -105,6 +106,7 @@ export class StateStore { /** * Schedules a debounced save. * Multiple calls within 500ms are batched into a single disk write. + * Uses async I/O to avoid blocking the event loop. */ save(): void { this.dirty = true; @@ -112,17 +114,18 @@ export class StateStore { return; // Already scheduled } this.saveTimeout = setTimeout(() => { - this.saveNow(); + this.saveNowAsync().catch((err) => { + console.error('[StateStore] Async save failed:', err); + }); }, SAVE_DEBOUNCE_MS); } /** - * Immediately writes state to disk using atomic write pattern. - * Writes to temp file first, then renames to prevent corruption on crash. - * Includes backup mechanism and circuit breaker for reliability. - * Use when guaranteed persistence is required (e.g., before shutdown). + * Async version of saveNow — used by the debounced save() path. + * Uses non-blocking fs.promises to avoid blocking the event loop during + * the debounced write cycle. For synchronous shutdown flush, use saveNow(). */ - saveNow(): void { + async saveNowAsync(): Promise { if (this.saveTimeout) { clearTimeout(this.saveTimeout); this.saveTimeout = null; @@ -146,7 +149,7 @@ export class StateStore { // Step 1: Serialize state (validates it's JSON-safe) try { - json = JSON.stringify(this.state, null, 2); + json = JSON.stringify(this.state); } catch (err) { console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err); this.consecutiveSaveFailures++; @@ -154,29 +157,22 @@ export class StateStore { console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); this.circuitBreakerOpen = true; } - // Don't throw - this prevents crashing the app - // Mark dirty again so we can retry later this.dirty = true; return; } - // Step 2: Create backup of current state file (if exists) + // Step 2: Create backup via file copy (async, no read+parse+write) try { - if (existsSync(this.filePath)) { - // Read current file and verify it's valid JSON before backing up - const currentContent = readFileSync(this.filePath, 'utf-8'); - JSON.parse(currentContent); // Validate - writeFileSync(backupPath, currentContent, 'utf-8'); - } - } catch (err) { - // Backup failed - current file may be corrupt, continue with write - console.warn('[StateStore] Could not create backup (current file may be corrupt):', err); + await access(this.filePath); + await copyFile(this.filePath, backupPath); + } catch { + // Backup failed or file doesn't exist yet - continue with write } - // Step 3: Atomic write: write to temp file, then rename + // Step 3: Atomic write: write to temp file, then rename (async) try { - writeFileSync(tempPath, json, 'utf-8'); - renameSync(tempPath, this.filePath); + await writeFile(tempPath, json, 'utf-8'); + await rename(tempPath, this.filePath); // Success! Reset failure counter this.consecutiveSaveFailures = 0; @@ -190,11 +186,9 @@ export class StateStore { // Try to clean up temp file on error try { - if (existsSync(tempPath)) { - unlinkSync(tempPath); - } - } catch (cleanupErr) { - console.warn('[StateStore] Failed to cleanup temp file during save error:', cleanupErr); + await unlink(tempPath); + } catch { + // Temp file may not exist } // Check circuit breaker threshold @@ -203,7 +197,75 @@ export class StateStore { this.circuitBreakerOpen = true; } - // Mark dirty so we retry later (don't throw to avoid crashing app) + this.dirty = true; + } + } + + /** + * Synchronous immediate write to disk using atomic write pattern. + * Used by flushAll() during shutdown when async is not appropriate. + * Prefer saveNowAsync() for normal operation. + */ + saveNow(): void { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + this.saveTimeout = null; + } + if (!this.dirty) { + return; + } + + if (this.circuitBreakerOpen) { + console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); + return; + } + + this.dirty = false; + this.ensureDir(); + + const tempPath = this.filePath + '.tmp'; + const backupPath = this.filePath + '.bak'; + let json: string; + + try { + json = JSON.stringify(this.state); + } catch (err) { + console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err); + this.consecutiveSaveFailures++; + if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); + this.circuitBreakerOpen = true; + } + this.dirty = true; + return; + } + + // Backup via copy (skip read+parse validation — just copy the file) + try { + if (existsSync(this.filePath)) { + const currentContent = readFileSync(this.filePath, 'utf-8'); + writeFileSync(backupPath, currentContent, 'utf-8'); + } + } catch { + // Backup failed - continue with write + } + + try { + writeFileSync(tempPath, json, 'utf-8'); + renameSync(tempPath, this.filePath); + this.consecutiveSaveFailures = 0; + if (this.circuitBreakerOpen) { + console.log('[StateStore] Circuit breaker CLOSED - save succeeded'); + this.circuitBreakerOpen = false; + } + } catch (err) { + console.error('[StateStore] Failed to write state file:', err); + this.consecutiveSaveFailures++; + try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* ignore */ } + if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { + console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); + this.circuitBreakerOpen = true; + } this.dirty = true; } } diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 7a4870e8..4bf1fa6d 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -11,7 +11,8 @@ import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; import { homedir } from 'node:os'; import { join, basename } from 'node:path'; -import { execSync } from 'node:child_process'; +import { execFile } from 'node:child_process'; +import { readFile, readdir, stat as statAsync } from 'node:fs/promises'; import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS } from './config/map-limits.js'; // ========== Types ========== @@ -208,11 +209,11 @@ export class SubagentWatcher extends EventEmitter { this._isRunning = true; // Initial scan - this.scanForSubagents(); + this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error)); // Periodic scan for new subagent directories this.pollInterval = setInterval(() => { - this.scanForSubagents(); + this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error)); }, POLL_INTERVAL_MS); // Periodic liveness check for active subagents @@ -583,8 +584,13 @@ export class SubagentWatcher extends EventEmitter { */ private async findSubagentProcess(sessionId: string): Promise { try { - // Find all claude processes - const pgrepOutput = execSync('pgrep -f "claude"', { encoding: 'utf8' }); + // Find all claude processes (async to avoid blocking event loop) + const pgrepOutput = await new Promise((resolve, reject) => { + execFile('pgrep', ['-f', 'claude'], { encoding: 'utf8' }, (err, stdout) => { + if (err) return reject(err); + resolve(stdout); + }); + }); const pids = pgrepOutput.trim().split('\n').filter(Boolean); for (const pidStr of pids) { @@ -592,8 +598,8 @@ export class SubagentWatcher extends EventEmitter { if (Number.isNaN(pid)) continue; try { - // Check /proc/{pid}/environ for session ID - const environ = readFileSync(`/proc/${pid}/environ`, 'utf8'); + // Check /proc/{pid}/environ for session ID (async read) + const environ = await readFile(`/proc/${pid}/environ`, 'utf8'); if (environ.includes(sessionId)) { return pid; } @@ -602,8 +608,8 @@ export class SubagentWatcher extends EventEmitter { } try { - // Also check /proc/{pid}/cmdline for session ID - const cmdline = readFileSync(`/proc/${pid}/cmdline`, 'utf8'); + // Also check /proc/{pid}/cmdline for session ID (async read) + const cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); if (cmdline.includes(sessionId)) { return pid; } @@ -850,28 +856,28 @@ export class SubagentWatcher extends EventEmitter { } /** - * Scan for all subagent directories + * Scan for all subagent directories (async to avoid blocking event loop) */ - private scanForSubagents(): void { + private async scanForSubagents(): Promise { if (!existsSync(CLAUDE_PROJECTS_DIR)) return; try { - const projects = readdirSync(CLAUDE_PROJECTS_DIR); + const projects = await readdir(CLAUDE_PROJECTS_DIR); for (const project of projects) { const projectPath = join(CLAUDE_PROJECTS_DIR, project); try { - const stat = statSync(projectPath); - if (!stat.isDirectory()) continue; + const st = await statAsync(projectPath); + if (!st.isDirectory()) continue; - const sessions = readdirSync(projectPath); + const sessions = await readdir(projectPath); for (const session of sessions) { const sessionPath = join(projectPath, session); try { - const sessionStat = statSync(sessionPath); + const sessionStat = await statAsync(sessionPath); if (!sessionStat.isDirectory()) continue; const subagentDir = join(sessionPath, 'subagents'); diff --git a/src/types.ts b/src/types.ts index 871f8f4f..bcd9f554 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1533,72 +1533,6 @@ export interface PaneInfo { height: number; } -// ========== Agent Teams Types ========== - -/** - * Team configuration matching ~/.claude/teams/{name}/config.json - */ -export interface TeamConfig { - name: string; - description?: string; - createdAt: number; - leadAgentId: string; - leadSessionId: string; // Maps to Claudeman session ID - members: TeamMember[]; -} - -/** - * A member of an agent team (lead or teammate) - */ -export interface TeamMember { - agentId: string; // Format: {name}@{teamName} - name: string; - agentType: 'team-lead' | 'general-purpose'; - model?: string; - prompt?: string; // Only present for teammates - color?: string; // blue, green, yellow - backendType?: string; // "in-process" - joinedAt: number; - tmuxPaneId?: string; - cwd?: string; -} - -/** - * Team task matching ~/.claude/tasks/{team}/{N}.json - */ -export interface TeamTask { - id: string; - subject: string; - description: string; - activeForm?: string; - status: 'pending' | 'in_progress' | 'completed'; - owner?: string; - blocks: string[]; - blockedBy: string[]; - metadata?: { _internal?: boolean }; -} - -/** - * Inbox message from ~/.claude/teams/{name}/inboxes/{member}.json - */ -export interface InboxMessage { - from: string; - text: string; // Double-encoded JSON string - timestamp: string; - read: boolean; -} - -/** - * Parsed content from an inbox message's text field - */ -export interface InboxMessageContent { - type: 'task_assignment' | 'shutdown_request' | 'shutdown_response' | 'message' | 'broadcast'; - taskId?: string; - subject?: string; - assignedBy?: string; - timestamp?: string; -} - // ========== Plan Orchestrator Re-exports ========== export type { PlanItem } from './plan-orchestrator.js'; diff --git a/src/web/public/app.js b/src/web/public/app.js index 155fe71e..f87e97e2 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1145,14 +1145,6 @@ class ClaudemanApp { // Once set, never recalculated. Persisted to localStorage and server. this.subagentParentMap = new Map(); - // Agent Teams tracking - this.teams = new Map(); // Map - this.teamTasks = new Map(); // Map - this.teammateMap = new Map(); // Map for quick lookup - - // Teammate tmux pane terminals - this.teammatePanesByName = new Map(); // Map - this.teammateTerminals = new Map(); // Map this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel @@ -2108,22 +2100,6 @@ class ClaudemanApp { this.closeSessionImagePopups(data.id); // Close image popup windows for this session this.closeSessionSubagentWindows(data.id, true); // Close subagent windows and cleanup activity data - // Clean up any agent teams associated with this session - for (const [teamName, team] of this.teams) { - if (team.leadSessionId === data.id) { - this.teams.delete(teamName); - this.teamTasks.delete(teamName); - } - } - // Clean up teammate pane tracking for this session - for (const [name, paneInfo] of this.teammatePanesByName) { - if (paneInfo.sessionId === data.id) { - this.teammatePanesByName.delete(name); - } - } - this.rebuildTeammateMap(); - this.renderTeamTasksPanel(); - // Clean up idle timer for this session const idleTimer = this.idleTimers.get(data.id); if (idleTimer) { @@ -2892,81 +2868,6 @@ class ClaudemanApp { }, 5 * 60 * 1000); // 5 minutes }); - // ========== Agent Teams Events ========== - - addListener('team:created', (e) => { - const team = JSON.parse(e.data); - this.teams.set(team.name, team); - this.rebuildTeammateMap(); - this.renderTeamTasksPanel(); - this.updateSubagentWindows(); - }); - - addListener('team:updated', (e) => { - const team = JSON.parse(e.data); - this.teams.set(team.name, team); - this.rebuildTeammateMap(); - this.renderTeamTasksPanel(); - this.updateSubagentWindows(); - }); - - addListener('team:removed', (e) => { - const team = JSON.parse(e.data); - this.teams.delete(team.name); - this.teamTasks.delete(team.name); - this.rebuildTeammateMap(); - this.renderTeamTasksPanel(); - this.updateSubagentWindows(); - }); - - addListener('team:task_updated', (e) => { - const data = JSON.parse(e.data); - this.teamTasks.set(data.teamName, data.tasks); - this.renderTeamTasksPanel(); - }); - - addListener('team:inbox_message', (e) => { - // Inbox messages currently just trigger panel updates - this.renderTeamTasksPanel(); - }); - - // ========== Teammate Pane Events ========== - - addListener('teammate:pane_available', (e) => { - const data = JSON.parse(e.data); - - // Only show teammate pane windows if the session has a tab in Claudeman - if (!this.sessions.has(data.sessionId)) return; - - this.teammatePanesByName.set(data.teammateName, { - paneTarget: data.paneTarget, - sessionId: data.sessionId, - color: data.color, - }); - // Teammate pane windows are disabled — users interact with teammates - // directly through the main tmux split-pane terminal instead. - }); - - addListener('teammate:terminal', (e) => { - const data = JSON.parse(e.data); - // Find the terminal instance for this pane - for (const [, termData] of this.teammateTerminals) { - if (termData.paneTarget === data.paneTarget && termData.sessionId === data.sessionId) { - // Decode base64 to Uint8Array to preserve UTF-8 multi-byte sequences - const binary = atob(data.data); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - if (termData.terminal) { - try { termData.terminal.write(bytes); } catch {} - } else if (termData.pendingData) { - // Terminal not ready yet — buffer the data - termData.pendingData.push(bytes); - } - break; - } - } - }); - // ========== Image Detection Events (Screenshots & Generated Images) ========== addListener('image:detected', (e) => { @@ -11285,6 +11186,16 @@ class ClaudemanApp { } renderSubagentPanel() { + // Debounce renders at 150ms to prevent excessive DOM updates from rapid subagent events + if (this._subagentPanelRenderTimeout) { + clearTimeout(this._subagentPanelRenderTimeout); + } + this._subagentPanelRenderTimeout = setTimeout(() => { + this._renderSubagentPanelImmediate(); + }, 150); + } + + _renderSubagentPanelImmediate() { const list = this.$('subagentList'); if (!list) return; @@ -11777,6 +11688,17 @@ class ClaudemanApp { // This map stores agentId -> sessionId, where sessionId is the tab's data-id. updateConnectionLines() { + // Coalesce multiple calls within the same frame into a single rAF + if (!this._connectionLinesScheduled) { + this._connectionLinesScheduled = true; + requestAnimationFrame(() => { + this._connectionLinesScheduled = false; + this._updateConnectionLinesImmediate(); + }); + } + } + + _updateConnectionLinesImmediate() { const svg = document.getElementById('connectionLines'); if (!svg) return; @@ -12007,13 +11929,6 @@ class ClaudemanApp { // ========== Subagent Floating Windows ========== openSubagentWindow(agentId) { - // Skip teammate agents — they use tmux split panes in the main terminal, - // not floating popup windows. Prevents false-info activity log popups. - const agentForTeammateCheck = this.subagents.get(agentId); - if (agentForTeammateCheck && this.getTeammateInfo(agentForTeammateCheck)) { - return; - } - // If window already exists, focus it if (this.subagentWindows.has(agentId)) { const existing = this.subagentWindows.get(agentId); @@ -12581,49 +12496,74 @@ class ClaudemanApp { return; } - const html = activity.slice(-100).map(a => { - const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false }); - if (a.type === 'tool') { - return `
- ${time} - ${this.getToolIcon(a.tool)} - ${a.tool} - ${this.escapeHtml(this.getToolDetail(a.tool, a.input))} -
`; - } else if (a.type === 'tool_result') { - const icon = a.isError ? '❌' : '📄'; - const statusClass = a.isError ? ' error' : ''; - const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; - const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview; - return `
- ${time} - ${icon} - ${a.tool || '→'} - ${this.escapeHtml(preview)}${sizeInfo} -
`; - } else if (a.type === 'progress') { - // Check for hook events - const isHook = a.hookEvent || a.hookName; - const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); - const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); - return `
- ${time} - ${icon} - ${this.escapeHtml(displayText)} -
`; - } else if (a.type === 'message') { - const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text; - return `
- ${time} 💬 ${this.escapeHtml(preview)} -
`; + // Incremental rendering: track how many items are already rendered + const renderedCount = body.dataset.renderedCount ? parseInt(body.dataset.renderedCount, 10) : 0; + const maxItems = 100; + const visibleActivity = activity.slice(-maxItems); + + // If activity was trimmed or this is a fresh render, do full rebuild + if (renderedCount === 0 || renderedCount > visibleActivity.length || body.children.length === 0 || + (body.children.length === 1 && body.querySelector('.subagent-empty'))) { + // Full rebuild + const html = visibleActivity.map(a => this._renderActivityItem(a)).join(''); + body.innerHTML = html; + body.dataset.renderedCount = String(visibleActivity.length); + } else { + // Incremental: only append new items + const newItems = visibleActivity.slice(renderedCount); + if (newItems.length > 0) { + const newHtml = newItems.map(a => this._renderActivityItem(a)).join(''); + body.insertAdjacentHTML('beforeend', newHtml); + body.dataset.renderedCount = String(visibleActivity.length); + + // Trim excess children from the front if over maxItems + while (body.children.length > maxItems) { + body.removeChild(body.firstChild); + } } - return ''; - }).join(''); + } - body.innerHTML = html; body.scrollTop = body.scrollHeight; } + _renderActivityItem(a) { + const time = new Date(a.timestamp).toLocaleTimeString('en-US', { hour12: false }); + if (a.type === 'tool') { + return `
+ ${time} + ${this.getToolIcon(a.tool)} + ${a.tool} + ${this.escapeHtml(this.getToolDetail(a.tool, a.input))} +
`; + } else if (a.type === 'tool_result') { + const icon = a.isError ? '❌' : '📄'; + const statusClass = a.isError ? ' error' : ''; + const sizeInfo = a.contentLength > 500 ? ` (${this.formatBytes(a.contentLength)})` : ''; + const preview = a.preview.length > 60 ? a.preview.substring(0, 60) + '...' : a.preview; + return `
+ ${time} + ${icon} + ${a.tool || '→'} + ${this.escapeHtml(preview)}${sizeInfo} +
`; + } else if (a.type === 'progress') { + const isHook = a.hookEvent || a.hookName; + const icon = isHook ? '🪝' : (a.progressType === 'query_update' ? '⟳' : '✓'); + const displayText = isHook ? (a.hookName || a.hookEvent) : (a.query || a.progressType); + return `
+ ${time} + ${icon} + ${this.escapeHtml(displayText)} +
`; + } else if (a.type === 'message') { + const preview = a.text.length > 150 ? a.text.substring(0, 150) + '...' : a.text; + return `
+ ${time} 💬 ${this.escapeHtml(preview)} +
`; + } + return ''; + } + // Update all open subagent windows updateSubagentWindows() { for (const agentId of this.subagentWindows.keys()) { diff --git a/src/web/public/index.html b/src/web/public/index.html index 8913c275..85a4db45 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -425,21 +425,6 @@ - - -