From 59b22337a5908e31e7d61fed4b2a1b39fb036b19 Mon Sep 17 00:00:00 2001 From: arkon Date: Tue, 27 Jan 2026 00:41:00 +0100 Subject: [PATCH] chore: bump version to 0.1390 --- CLAUDE.md | 17 +++++- package.json | 2 +- src/subagent-watcher.ts | 116 ++++++++++++++++++++++++++++++++-------- src/web/server.ts | 12 +++++ 4 files changed, 121 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 937d517a..ef40703f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,20 @@ Always bump version on every COM, even for small changes. 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. -**Version**: 0.1389 (must match `package.json`) +### Mission: Rock-Solid Performance + +**The app MUST remain fast, responsive, and never hang** — even with many tabs open and many subagent windows active. This is a core design principle, not a nice-to-have. Every feature must be built with this constraint in mind: + +- **60fps terminal rendering** with batched writes and `requestAnimationFrame` +- **Auto-trimming buffers** prevent memory bloat in long-running sessions +- **Debounced state persistence** (500ms) avoids disk thrashing +- **SSE batching** (16ms) reduces client-side event storms +- **Lazy loading** of agent transcripts and historical data +- **No blocking operations** in the main event loop + +When adding new features, always ask: "Will this maintain responsiveness with 20 sessions and 50 agent windows?" + +**Version**: 0.1390 (must match `package.json`) **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty @@ -528,7 +541,7 @@ TUI uses React JSX (`jsx: react-jsx`, `jsxImportSource: react`) for Ink componen - **Session event**: Add to `SessionEvents` interface in `session.ts`, emit via `this.emit()`, subscribe in server.ts, handle in frontend - **Session setting**: Add field to `SessionState` in `types.ts`, include in `session.toState()`, call `this.persistSessionState(session)` in server.ts after the change - **MCP tool**: Add tool definition in `mcp-server.ts` using `server.tool()`, use `apiRequest()` to call Claudeman REST API -- **New test file**: Create `test/.test.ts`, pick unique port (next available: 3155), add to port allocation table above +- **New test file**: Create `test/.test.ts`, pick unique port (next available: 3183), add to port allocation table above ### API Error Codes diff --git a/package.json b/package.json index bf83f1cf..342b84ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1389", + "version": "0.1390", "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/subagent-watcher.ts b/src/subagent-watcher.ts index ceeae169..bd0deef6 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -123,7 +123,10 @@ const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects'); const IDLE_TIMEOUT_MS = 30000; // Consider agent idle after 30s of no activity const POLL_INTERVAL_MS = 1000; // Check for new files every second const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s -const STALE_AGENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // Remove completed agents older than 24 hours +const STALE_COMPLETED_MAX_AGE_MS = 60 * 60 * 1000; // Remove completed agents older than 1 hour +const STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older than 4 hours +const STARTUP_MAX_FILE_AGE_MS = 4 * 60 * 60 * 1000; // Only load files modified in last 4 hours on startup +const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when exceeded) // Display/preview length constants const TEXT_PREVIEW_LENGTH = 200; // Length for text previews in tool results @@ -282,36 +285,91 @@ export class SubagentWatcher extends EventEmitter { } /** - * Clean up stale completed agents to prevent unbounded memory growth - * Removes agents that have been completed for longer than STALE_AGENT_MAX_AGE_MS + * Clean up stale agents to prevent unbounded memory growth. + * - Completed agents: removed after STALE_COMPLETED_MAX_AGE_MS (1 hour) + * - Idle agents: removed after STALE_IDLE_MAX_AGE_MS (4 hours) + * Also enforces MAX_TRACKED_AGENTS limit with LRU eviction. */ private cleanupStaleAgents(): void { const now = Date.now(); const agentsToDelete: string[] = []; for (const [agentId, info] of this.agentInfo) { - if (info.status === 'completed') { - const lastActivity = new Date(info.lastActivityAt).getTime(); - if (now - lastActivity > STALE_AGENT_MAX_AGE_MS) { - agentsToDelete.push(agentId); - } + const lastActivity = new Date(info.lastActivityAt).getTime(); + const age = now - lastActivity; + + // Clean up based on status and age + if (info.status === 'completed' && age > STALE_COMPLETED_MAX_AGE_MS) { + agentsToDelete.push(agentId); + } else if (info.status === 'idle' && age > STALE_IDLE_MAX_AGE_MS) { + agentsToDelete.push(agentId); } } - for (const agentId of agentsToDelete) { - const info = this.agentInfo.get(agentId); - if (info) { - // Clean up all associated resources - this.agentInfo.delete(agentId); - this.pendingToolCalls.delete(agentId); - this.filePositions.delete(info.filePath); - const watcher = this.fileWatchers.get(info.filePath); - if (watcher) { - watcher.close(); - this.fileWatchers.delete(info.filePath); - } + // Enforce max tracked agents limit (LRU eviction) + const currentCount = this.agentInfo.size - agentsToDelete.length; + if (currentCount > MAX_TRACKED_AGENTS) { + // Sort by lastActivityAt (oldest first) and evict oldest completed/idle agents + const sortedAgents = Array.from(this.agentInfo.entries()) + .filter(([id]) => !agentsToDelete.includes(id)) + .filter(([, info]) => info.status !== 'active') // Keep active agents + .sort((a, b) => new Date(a[1].lastActivityAt).getTime() - new Date(b[1].lastActivityAt).getTime()); + + const toEvict = currentCount - MAX_TRACKED_AGENTS; + for (let i = 0; i < toEvict && i < sortedAgents.length; i++) { + agentsToDelete.push(sortedAgents[i][0]); } } + + // Perform cleanup + for (const agentId of agentsToDelete) { + this.removeAgent(agentId); + } + } + + /** + * Remove an agent and all its associated resources. + */ + private removeAgent(agentId: string): void { + const info = this.agentInfo.get(agentId); + if (info) { + this.agentInfo.delete(agentId); + this.pendingToolCalls.delete(agentId); + this.filePositions.delete(info.filePath); + const watcher = this.fileWatchers.get(info.filePath); + if (watcher) { + watcher.close(); + this.fileWatchers.delete(info.filePath); + } + const timer = this.idleTimers.get(agentId); + if (timer) { + clearTimeout(timer); + this.idleTimers.delete(agentId); + } + } + } + + /** + * Manually trigger cleanup of stale agents. + * Returns number of agents removed. + */ + cleanupNow(): number { + const beforeCount = this.agentInfo.size; + this.cleanupStaleAgents(); + return beforeCount - this.agentInfo.size; + } + + /** + * Clear all tracked agents (for manual reset). + * Returns number of agents cleared. + */ + clearAll(): number { + const count = this.agentInfo.size; + const agentIds = Array.from(this.agentInfo.keys()); + for (const agentId of agentIds) { + this.removeAgent(agentId); + } + return count; } /** @@ -757,12 +815,12 @@ export class SubagentWatcher extends EventEmitter { if (this.knownSubagentDirs.has(dir)) return; this.knownSubagentDirs.add(dir); - // Watch existing files + // Watch existing files (initial scan - skip old files) try { const files = readdirSync(dir); for (const file of files) { if (file.endsWith('.jsonl')) { - this.watchAgentFile(join(dir, file), projectHash, sessionId); + this.watchAgentFile(join(dir, file), projectHash, sessionId, true); } } } catch { @@ -801,8 +859,12 @@ export class SubagentWatcher extends EventEmitter { /** * Watch a specific agent transcript file + * @param filePath Path to the agent transcript file + * @param projectHash Claude project hash + * @param sessionId Claude session ID + * @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS */ - private watchAgentFile(filePath: string, projectHash: string, sessionId: string): void { + private watchAgentFile(filePath: string, projectHash: string, sessionId: string, isInitialScan: boolean = false): void { if (this.fileWatchers.has(filePath)) return; const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', ''); @@ -816,6 +878,14 @@ export class SubagentWatcher extends EventEmitter { return; } + // On initial scan, skip old files to avoid loading stale historical data + if (isInitialScan) { + const fileAge = Date.now() - stat.mtime.getTime(); + if (fileAge > STARTUP_MAX_FILE_AGE_MS) { + return; // Skip old files on startup + } + } + // Extract description - prefer reading from parent transcript (most reliable) // The parent transcript has the exact Task tool call with description parameter let description = this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId); diff --git a/src/web/server.ts b/src/web/server.ts index f01eef4d..73273d87 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1972,6 +1972,18 @@ export class WebServer extends EventEmitter { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed'); }); + // Trigger cleanup of stale subagents + this.app.post('/api/subagents/cleanup', async () => { + const removed = subagentWatcher.cleanupNow(); + return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } }; + }); + + // Clear all tracked subagents (memory only - does not delete files) + this.app.delete('/api/subagents', async () => { + const cleared = subagentWatcher.clearAll(); + return { success: true, data: { cleared } }; + }); + // ========== Hook Events ==========