diff --git a/CLAUDE.md b/CLAUDE.md index 68db2b94..9a08b3df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ When user says "COM": 1. Increment version in BOTH `package.json` AND `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.1411 (must match `package.json`) +**Version**: 0.1412 (must match `package.json`) ## Project Overview @@ -69,11 +69,23 @@ journalctl --user -u claudeman-web -f | `src/session-manager.ts` | Session lifecycle, cleanup | | `src/respawn-controller.ts` | State machine for autonomous cycling | | `src/ralph-tracker.ts` | Detects `PHRASE`, todos | +| `src/subagent-watcher.ts` | Monitors Claude Code's Task tool (background agents) | +| `src/run-summary.ts` | Timeline events for "what happened while away" | +| `src/ai-idle-checker.ts` | AI-powered idle detection with `ai-checker-base.ts` | | `src/plan-orchestrator.ts` | Multi-agent plan generation | | `src/web/server.ts` | Fastify REST API + SSE at `/api/events` | | `src/web/public/app.js` | Frontend: xterm.js, tab management, subagent windows | | `src/types.ts` | All TypeScript interfaces | +### Utility Files (`src/utils/`) + +| File | Purpose | +|------|---------| +| `lru-map.ts` | LRU eviction Map for bounded caches | +| `stale-expiration-map.ts` | TTL-based Map with lazy expiration | +| `cleanup-manager.ts` | Centralized resource disposal | +| `buffer-accumulator.ts` | Chunk accumulator with size limits | + ### Data Flow 1. Session spawns `claude --dangerously-skip-permissions` via node-pty @@ -140,14 +152,27 @@ The app must stay fast with 20 sessions and 50 agent windows: - Debounced state persistence (500ms) - SSE batching (16ms) -## Buffer Limits +## Resource Limits +Limits are centralized in `src/config/buffer-limits.ts` and `src/config/map-limits.ts`. + +**Buffer limits** (per session): | Buffer | Max | Trim To | |--------|-----|---------| | Terminal | 2MB | 1.5MB | | Text output | 1MB | 768KB | | Messages | 1000 | 800 | +**Map limits** (global): +| Resource | Max | +|----------|-----| +| Tracked agents | 500 | +| Concurrent sessions | 50 | +| SSE clients total | 100 | +| File watchers | 500 | + +Use `LRUMap` for bounded caches with eviction, `StaleExpirationMap` for TTL-based cleanup. + ## Where to Find More Information | Topic | Location | diff --git a/package.json b/package.json index 0a981208..a8a24aca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1411", + "version": "0.1412", "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/session.ts b/src/session.ts index 33bebf7a..0e605119 100644 --- a/src/session.ts +++ b/src/session.ts @@ -315,6 +315,7 @@ export class Session extends EventEmitter { private _isWorking: boolean = false; private _lastPromptTime: number = 0; private activityTimeout: NodeJS.Timeout | null = null; + private _awaitingIdleConfirmation: boolean = false; // Prevents timeout reset during idle detection private _taskTracker: TaskTracker; // Token tracking for auto-clear @@ -922,34 +923,44 @@ export class Session extends EventEmitter { // Detect if Claude is working or at prompt // The prompt line contains "❯" when waiting for input if (data.includes('❯') || data.includes('\u276f')) { - // Reset activity timeout - if no activity for 2 seconds after prompt, Claude is idle - if (this.activityTimeout) clearTimeout(this.activityTimeout); - this.activityTimeout = setTimeout(() => { - // Emit idle if either: - // 1. Claude was working and is now at prompt (normal case) - // 2. Session just started and is ready (status is 'busy' but _isWorking is false) - const wasWorking = this._isWorking; - const isInitialReady = this._status === 'busy' && !this._isWorking; - if (wasWorking || isInitialReady) { - this._isWorking = false; - this._status = 'idle'; - this._lastPromptTime = Date.now(); - this.emit('idle'); - } - }, IDLE_DETECTION_DELAY_MS); + // Only start a new timeout if we're not already awaiting idle confirmation + // This prevents status bar redraws (which include ❯) from resetting the timer + if (!this._awaitingIdleConfirmation) { + if (this.activityTimeout) clearTimeout(this.activityTimeout); + this._awaitingIdleConfirmation = true; + this.activityTimeout = setTimeout(() => { + this._awaitingIdleConfirmation = false; + // Emit idle if either: + // 1. Claude was working and is now at prompt (normal case) + // 2. Session just started and is ready (status is 'busy' but _isWorking is false) + const wasWorking = this._isWorking; + const isInitialReady = this._status === 'busy' && !this._isWorking; + if (wasWorking || isInitialReady) { + this._isWorking = false; + this._status = 'idle'; + this._lastPromptTime = Date.now(); + this.emit('idle'); + } + }, IDLE_DETECTION_DELAY_MS); + } } // Detect when Claude starts working (thinking, writing, etc) - if (data.includes('Thinking') || data.includes('Writing') || data.includes('Reading') || - data.includes('Running') || data.includes('⠋') || data.includes('⠙') || - data.includes('⠹') || data.includes('⠸') || data.includes('⠼') || - data.includes('⠴') || data.includes('⠦') || data.includes('⠧')) { + // Strip ANSI/OSC sequences to avoid false positives from window titles like "3 File Reading Task" + const cleanDataForWorkingCheck = data.replace(ANSI_ESCAPE_PATTERN, ''); + if (cleanDataForWorkingCheck.includes('Thinking') || cleanDataForWorkingCheck.includes('Writing') || + cleanDataForWorkingCheck.includes('Reading') || cleanDataForWorkingCheck.includes('Running') || + cleanDataForWorkingCheck.includes('⠋') || cleanDataForWorkingCheck.includes('⠙') || + cleanDataForWorkingCheck.includes('⠹') || cleanDataForWorkingCheck.includes('⠸') || + cleanDataForWorkingCheck.includes('⠼') || cleanDataForWorkingCheck.includes('⠴') || + cleanDataForWorkingCheck.includes('⠦') || cleanDataForWorkingCheck.includes('⠧')) { if (!this._isWorking) { this._isWorking = true; this._status = 'busy'; this.emit('working'); } - // Reset timeout since Claude is active + // Reset timeout and idle confirmation flag since Claude is active + this._awaitingIdleConfirmation = false; if (this.activityTimeout) clearTimeout(this.activityTimeout); } }); @@ -959,6 +970,7 @@ export class Session extends EventEmitter { this.ptyProcess = null; this._pid = null; this._status = 'idle'; + this._awaitingIdleConfirmation = false; // Clear all timers to prevent memory leaks if (this.activityTimeout) { clearTimeout(this.activityTimeout);