diff --git a/CLAUDE.md b/CLAUDE.md index ecc4f1d0..cc819e1d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md 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.1381 (must match `package.json`) +**Version**: 0.1382 (must match `package.json`) **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty diff --git a/package.json b/package.json index 8a0775ed..b773e894 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1381", + "version": "0.1382", "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/spawn-orchestrator.ts b/src/spawn-orchestrator.ts index a23741e9..36be93b4 100644 --- a/src/spawn-orchestrator.ts +++ b/src/spawn-orchestrator.ts @@ -545,6 +545,7 @@ export class SpawnOrchestrator extends EventEmitter { parentSessionId: task.parentSessionId, depth: task.depth, timeoutTimer: null, + warningTimer: null, progressTimer: null, status: 'initializing', startedAt: null, @@ -688,9 +689,9 @@ export class SpawnOrchestrator extends EventEmitter { private setupTimeout(agent: AgentContext): void { const timeoutMs = agent.task.spec.timeoutMinutes * 60 * 1000; - // Warning at 90% + // Warning at 90% - store timer for cleanup const warningMs = timeoutMs * 0.9; - setTimeout(() => { + agent.warningTimer = setTimeout(() => { if (agent.status === 'running' && this._sessionCreator && agent.sessionId) { this._sessionCreator.writeToSession( agent.sessionId, @@ -818,6 +819,10 @@ export class SpawnOrchestrator extends EventEmitter { clearTimeout(agent.timeoutTimer); agent.timeoutTimer = null; } + if (agent.warningTimer) { + clearTimeout(agent.warningTimer); + agent.warningTimer = null; + } if (agent.progressTimer) { clearInterval(agent.progressTimer); agent.progressTimer = null; diff --git a/src/spawn-types.ts b/src/spawn-types.ts index 5043712b..4188f618 100644 --- a/src/spawn-types.ts +++ b/src/spawn-types.ts @@ -259,6 +259,8 @@ export interface AgentContext { depth: number; /** Timeout timer handle */ timeoutTimer: NodeJS.Timeout | null; + /** Warning timer handle (fires at 90% of timeout) */ + warningTimer: NodeJS.Timeout | null; /** Progress poll timer handle */ progressTimer: NodeJS.Timeout | null; /** Current status */ diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 332189e4..60ce54f7 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -272,6 +272,39 @@ export class SubagentWatcher extends EventEmitter { this.pendingToolCalls.clear(); } + /** + * Clean up stale completed agents to prevent unbounded memory growth + * Removes agents that have been completed for longer than STALE_AGENT_MAX_AGE_MS + */ + 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); + } + } + } + + 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); + } + } + } + } + /** * Get all known subagents */ @@ -744,6 +777,13 @@ export class SubagentWatcher extends EventEmitter { } }); + // Handle watcher errors to prevent unhandled exceptions + watcher.on('error', (error) => { + this.emit('subagent:error', error instanceof Error ? error : new Error(String(error))); + this.dirWatchers.delete(dir); + this.knownSubagentDirs.delete(dir); + }); + this.dirWatchers.set(dir, watcher); } catch { // Watch failed @@ -836,6 +876,12 @@ export class SubagentWatcher extends EventEmitter { } }); + // Handle watcher errors to prevent unhandled exceptions + watcher.on('error', (error) => { + this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId); + this.fileWatchers.delete(filePath); + }); + this.fileWatchers.set(filePath, watcher); this.resetIdleTimer(agentId); } catch { @@ -997,7 +1043,10 @@ export class SubagentWatcher extends EventEmitter { } else if (content.type === 'tool_result' && content.tool_use_id) { // Extract tool result const resultContent = this.extractToolResultContent(content.content); - const toolName = this.pendingToolCalls.get(agentId)?.get(content.tool_use_id); + const agentPendingCalls = this.pendingToolCalls.get(agentId); + const toolName = agentPendingCalls?.get(content.tool_use_id); + // Delete after lookup to prevent memory leak + agentPendingCalls?.delete(content.tool_use_id); const toolResult: SubagentToolResult = { agentId, @@ -1044,7 +1093,10 @@ export class SubagentWatcher extends EventEmitter { for (const content of entry.message.content) { if (content.type === 'tool_result' && content.tool_use_id) { const resultContent = this.extractToolResultContent(content.content); - const toolName = this.pendingToolCalls.get(agentId)?.get(content.tool_use_id); + const agentPendingCalls = this.pendingToolCalls.get(agentId); + const toolName = agentPendingCalls?.get(content.tool_use_id); + // Delete after lookup to prevent memory leak + agentPendingCalls?.delete(content.tool_use_id); const toolResult: SubagentToolResult = { agentId,