chore: version packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-28 14:45:39 +01:00
co-authored by Claude Opus 4.6
parent 88098adc1c
commit db8ee8458c
12 changed files with 680 additions and 184 deletions
+12 -1
View File
@@ -653,6 +653,9 @@ export class RespawnController extends EventEmitter {
/** Timer for periodic detection status updates */
private detectionUpdateTimer: NodeJS.Timeout | null = null;
/** Cached key fields from last emitted detection status (for dedup) */
private lastEmittedDetectionKey: string = '';
/** Timer for auto-accepting plan mode prompts */
private autoAcceptTimer: NodeJS.Timeout | null = null;
@@ -1196,10 +1199,18 @@ export class RespawnController extends EventEmitter {
private startDetectionUpdates(): void {
this.stopDetectionUpdates();
if (this._state === 'stopped') return;
this.lastEmittedDetectionKey = '';
this.detectionUpdateTimer = setInterval(() => {
try {
if (this._state !== 'stopped') {
this.emit('detectionUpdate', this.getDetectionStatus());
const status = this.getDetectionStatus();
// Only emit when status meaningfully changed (confidence, state text, or timer values)
// to avoid broadcasting identical data every 2s for stable/idle sessions.
const key = `${status.confidenceLevel}|${status.statusText}|${this._state}`;
if (key !== this.lastEmittedDetectionKey) {
this.lastEmittedDetectionKey = key;
this.emit('detectionUpdate', status);
}
}
} catch (err) {
console.error(`[RespawnController] Error in detectionUpdateTimer:`, err);
+104 -16
View File
@@ -62,6 +62,8 @@ export class StateStore {
private filePath: string;
private saveTimeout: NodeJS.Timeout | null = null;
private dirty: boolean = false;
private dirtySessions = new Set<string>();
private cachedSessionJsons = new Map<string, string>();
// Inner state storage (separate from main state to reduce write frequency)
private ralphStates: Map<string, RalphSessionState> = new Map();
@@ -97,6 +99,10 @@ export class StateStore {
this.ralphStatePath = this.filePath.replace('.json', '-inner.json');
this.state = this.load();
this.state.config.stateFilePath = this.filePath;
// Pre-populate session cache for loaded state
for (const [id, session] of Object.entries(this.state.sessions)) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
}
this.loadRalphStates();
}
@@ -176,6 +182,65 @@ export class StateStore {
}
}
/**
* Assemble JSON string with incremental per-session caching.
* Only dirty sessions are re-serialized; clean sessions use cached JSON fragments.
*/
private assembleStateJson(): string {
// Re-serialize dirty sessions and update cache
for (const id of this.dirtySessions) {
const session = this.state.sessions[id];
if (session) {
this.cachedSessionJsons.set(id, JSON.stringify(session));
} else {
this.cachedSessionJsons.delete(id);
}
}
this.dirtySessions.clear();
// Build sessions object from cached fragments
const sessionParts: string[] = [];
for (const [id, session] of Object.entries(this.state.sessions)) {
let json = this.cachedSessionJsons.get(id);
if (!json) {
// Session not in cache (loaded from disk or set via direct state mutation)
json = JSON.stringify(session);
this.cachedSessionJsons.set(id, json);
}
sessionParts.push(`${JSON.stringify(id)}:${json}`);
}
// Prune stale cache entries (sessions removed via direct state mutation)
if (this.cachedSessionJsons.size > Object.keys(this.state.sessions).length) {
for (const cachedId of this.cachedSessionJsons.keys()) {
if (!(cachedId in this.state.sessions)) {
this.cachedSessionJsons.delete(cachedId);
}
}
}
// Build final JSON: sessions from cache, everything else re-serialized (tiny)
const sessionsJson = `{${sessionParts.join(',')}}`;
// Serialize non-session fields individually (they're small)
const parts: string[] = [
`"sessions":${sessionsJson}`,
`"tasks":${JSON.stringify(this.state.tasks)}`,
`"ralphLoop":${JSON.stringify(this.state.ralphLoop)}`,
`"config":${JSON.stringify(this.state.config)}`,
];
// Optional fields
if (this.state.globalStats) {
parts.push(`"globalStats":${JSON.stringify(this.state.globalStats)}`);
}
if (this.state.tokenStats) {
parts.push(`"tokenStats":${JSON.stringify(this.state.tokenStats)}`);
}
return `{${parts.join(',')}}`;
}
private async _doSaveAsync(): Promise<void> {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
@@ -199,15 +264,23 @@ export class StateStore {
// Step 1: Serialize state (validates it's JSON-safe)
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;
json = this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
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;
}
return;
}
return;
}
// Clear dirty flag BEFORE async I/O so mutations during write re-set it.
@@ -279,15 +352,23 @@ export class StateStore {
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;
json = this.assembleStateJson();
} catch (assembleErr) {
// Fallback to full serialization if incremental assembly fails
console.warn('[StateStore] assembleStateJson failed, falling back to full serialize:', assembleErr);
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
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;
}
return;
}
return;
}
// Backup via atomic copy (avoids reading entire file into memory)
@@ -387,12 +468,15 @@ export class StateStore {
/** Sets a session state and triggers a debounced save. */
setSession(id: string, session: AppState['sessions'][string]) {
this.state.sessions[id] = session;
this.dirtySessions.add(id);
this.save();
}
/** Removes a session state and triggers a debounced save. */
removeSession(id: string) {
delete this.state.sessions[id];
this.cachedSessionJsons.delete(id);
this.dirtySessions.delete(id);
this.save();
}
@@ -413,6 +497,8 @@ export class StateStore {
const name = this.state.sessions[sessionId]?.name;
cleaned.push({ id: sessionId, name });
delete this.state.sessions[sessionId];
this.cachedSessionJsons.delete(sessionId);
this.dirtySessions.delete(sessionId);
// Also clean up Ralph state for this session
this.ralphStates.delete(sessionId);
}
@@ -475,6 +561,8 @@ export class StateStore {
this.state = createInitialState();
this.state.config.stateFilePath = this.filePath;
this.ralphStates.clear();
this.cachedSessionJsons.clear();
this.dirtySessions.clear();
this.saveNow(); // Immediate save for reset operations
this.saveRalphStatesNow();
}
+128 -112
View File
@@ -160,8 +160,9 @@ const FILE_CONTENT_DEBOUNCE_MS = 100; // Debounce delay for file content updates
export class SubagentWatcher extends EventEmitter {
private filePositions = new Map<string, number>();
private fileWatchers = new Map<string, FSWatcher>();
private dirWatchers = new Map<string, FSWatcher>();
// Per-file debounce timers for directory watcher (replaces per-file FSWatchers)
private fileDebouncers = new Map<string, NodeJS.Timeout>();
private agentInfo = new Map<string, SubagentInfo>();
private idleTimers = new Map<string, NodeJS.Timeout>();
private pollInterval: NodeJS.Timeout | null = null;
@@ -180,7 +181,8 @@ export class SubagentWatcher extends EventEmitter {
private parentDescriptionCache = new Map<string, { descriptions: Map<string, string>; timestamp: number }>();
// Store error handlers for FSWatchers to enable proper cleanup (prevent memory leaks)
private dirWatcherErrorHandlers = new Map<string, (error: Error) => void>();
private fileWatcherErrorHandlers = new Map<string, (error: Error) => void>();
// Map filePath → { projectHash, sessionId } for directory watcher file-change handling
private fileAgentContext = new Map<string, { projectHash: string; sessionId: string }>();
constructor() {
super();
@@ -426,17 +428,12 @@ export class SubagentWatcher extends EventEmitter {
this.livenessInterval = null;
}
// Remove error handlers before closing watchers to prevent memory leak
for (const [filePath, handler] of this.fileWatcherErrorHandlers) {
const watcher = this.fileWatchers.get(filePath);
if (watcher) watcher.off('error', handler);
// Clear file debouncers
for (const timer of this.fileDebouncers.values()) {
clearTimeout(timer);
}
this.fileWatcherErrorHandlers.clear();
for (const watcher of this.fileWatchers.values()) {
watcher.close();
}
this.fileWatchers.clear();
this.fileDebouncers.clear();
this.fileAgentContext.clear();
// Remove error handlers before closing watchers to prevent memory leak
for (const [dir, handler] of this.dirWatcherErrorHandlers) {
@@ -534,11 +531,11 @@ export class SubagentWatcher extends EventEmitter {
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);
this.fileWatcherErrorHandlers.delete(info.filePath);
this.fileAgentContext.delete(info.filePath);
const debounceTimer = this.fileDebouncers.get(info.filePath);
if (debounceTimer) {
clearTimeout(debounceTimer);
this.fileDebouncers.delete(info.filePath);
}
const timer = this.idleTimers.get(agentId);
if (timer) {
@@ -622,7 +619,7 @@ export class SubagentWatcher extends EventEmitter {
*/
getStats(): {
agentCount: number;
fileWatcherCount: number;
fileDebouncerCount: number;
dirWatcherCount: number;
idleTimerCount: number;
pendingToolCallsCount: number;
@@ -637,7 +634,7 @@ export class SubagentWatcher extends EventEmitter {
return {
agentCount: this.agentInfo.size,
fileWatcherCount: this.fileWatchers.size,
fileDebouncerCount: this.fileDebouncers.size,
dirWatcherCount: this.dirWatchers.size,
idleTimerCount: this.idleTimers.size,
pendingToolCallsCount,
@@ -955,14 +952,29 @@ export class SubagentWatcher extends EventEmitter {
try {
// The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl
const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`);
let fileSize: number;
try {
await statAsync(transcriptPath);
const fileStat = await statAsync(transcriptPath);
fileSize = fileStat.size;
} catch {
return undefined;
}
const content = await readFile(transcriptPath, 'utf8');
const lines = content.split('\n').filter((l) => l.trim());
// Only read last 16KB — toolUseResult entries are near the end of the transcript
const TAIL_BYTES = 16384;
const startOffset = Math.max(0, fileSize - TAIL_BYTES);
const content = await new Promise<string>((resolve, reject) => {
const chunks: string[] = [];
const stream = createReadStream(transcriptPath, { start: startOffset, encoding: 'utf8' });
stream.on('data', (chunk) => chunks.push(String(chunk)));
stream.on('end', () => resolve(chunks.join('')));
stream.on('error', reject);
});
let lines = content.split('\n').filter((l) => l.trim());
// If we started mid-file, drop the first partial line
if (startOffset > 0 && lines.length > 0) {
lines = lines.slice(1);
}
// Parse ALL toolUseResult entries into a Map and cache them
const descriptions = new Map<string, string>();
@@ -1091,39 +1103,51 @@ export class SubagentWatcher extends EventEmitter {
}
/**
* Watch a subagent directory for new/updated files
* Watch a subagent directory for new and changed files.
* Uses a single directory-level fs.watch() instead of per-file watchers.
* On Linux, inotify IN_MODIFY fires for content changes within the directory.
*/
private async watchSubagentDir(dir: string, projectHash: string, sessionId: string): Promise<void> {
if (this.knownSubagentDirs.has(dir)) return;
this.knownSubagentDirs.add(dir);
// Watch existing files (initial scan - skip old files)
// Register existing files (initial scan - skip old files)
try {
const files = await readdir(dir);
for (const file of files) {
if (file.endsWith('.jsonl')) {
await this.watchAgentFile(join(dir, file), projectHash, sessionId, true);
await this.registerAgentFile(join(dir, file), projectHash, sessionId, true);
}
}
} catch {
return;
}
// Watch for new files with debounce to allow content to be written
// Single directory watcher handles both new files and file content changes
try {
const watcher = watch(dir, (_eventType, filename) => {
if (filename?.endsWith('.jsonl')) {
const filePath = join(dir, filename);
// Wait 100ms for file content to be written before processing
// Even if file is empty after debounce, we still watch it - the
// description retry mechanisms in processEntry and the file change
// handler will extract description when content arrives
setTimeout(() => {
if (existsSync(filePath)) {
this.watchAgentFile(filePath, projectHash, sessionId);
}
}, FILE_CONTENT_DEBOUNCE_MS);
}
if (!filename?.endsWith('.jsonl')) return;
const filePath = join(dir, filename);
// Clear existing debounce for this file
const existing = this.fileDebouncers.get(filePath);
if (existing) clearTimeout(existing);
// Debounce 100ms to batch rapid writes
const timer = setTimeout(() => {
this.fileDebouncers.delete(filePath);
if (!existsSync(filePath)) return;
if (this.fileAgentContext.has(filePath)) {
// Known file — handle content change
this.handleFileChange(filePath).catch(() => {});
} else {
// New file — register it
this.registerAgentFile(filePath, projectHash, sessionId).catch(() => {});
}
}, FILE_CONTENT_DEBOUNCE_MS);
this.fileDebouncers.set(filePath, timer);
});
// Handle watcher errors to prevent unhandled exceptions
@@ -1145,26 +1169,80 @@ export class SubagentWatcher extends EventEmitter {
}
/**
* Watch a specific agent transcript file
* Handle a file content change for an already-registered agent file.
* Tails from last known position, updates info, retries description if missing.
*/
private async handleFileChange(filePath: string): Promise<void> {
const context = this.fileAgentContext.get(filePath);
if (!context) return;
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
const currentPos = this.filePositions.get(filePath) || 0;
const newPos = await this.tailFile(filePath, agentId, context.sessionId, currentPos);
this.filePositions.set(filePath, newPos);
// Update info
const existingInfo = this.agentInfo.get(agentId);
if (existingInfo) {
try {
const newStat = await statAsync(filePath);
existingInfo.lastActivityAt = Date.now();
existingInfo.fileSize = newStat.size;
existingInfo.status = 'active';
} catch {
// Stat failed
}
// Retry description extraction if missing (race condition fix)
if (!existingInfo.description) {
// First try parent transcript (most reliable)
let extractedDescription = await this.extractDescriptionFromParentTranscript(
existingInfo.projectHash,
existingInfo.sessionId,
agentId
);
// Fallback to subagent file
if (!extractedDescription) {
extractedDescription = await this.extractDescriptionFromFile(filePath);
}
if (extractedDescription) {
// Check if this is an internal agent - if so, remove it
if (this.isInternalAgent(extractedDescription)) {
this.removeAgent(agentId);
return;
}
existingInfo.description = extractedDescription;
this.emit('subagent:updated', existingInfo);
}
}
// Reset idle timer
this.resetIdleTimer(agentId);
}
}
/**
* Register a specific agent transcript file (discovery + initial read).
* Does NOT create a per-file watcher — the directory watcher handles changes.
* @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 async watchAgentFile(
private async registerAgentFile(
filePath: string,
projectHash: string,
sessionId: string,
isInitialScan: boolean = false
): Promise<void> {
if (this.fileWatchers.has(filePath)) return;
if (this.fileAgentContext.has(filePath)) return;
const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', '');
// Initial info - handle race condition where file may be deleted between discovery and stat
let stat;
let fileStat;
try {
stat = await statAsync(filePath);
fileStat = await statAsync(filePath);
} catch {
// File was deleted between discovery and stat - skip this agent
return;
@@ -1172,7 +1250,7 @@ export class SubagentWatcher extends EventEmitter {
// On initial scan, skip old files to avoid loading stale historical data
if (isInitialScan) {
const fileAge = Date.now() - stat.mtime.getTime();
const fileAge = Date.now() - fileStat.mtime.getTime();
if (fileAge > STARTUP_MAX_FILE_AGE_MS) {
return; // Skip old files on startup
}
@@ -1197,12 +1275,12 @@ export class SubagentWatcher extends EventEmitter {
sessionId,
projectHash,
filePath,
startedAt: stat.birthtime.toISOString(),
lastActivityAt: stat.mtime.getTime(),
startedAt: fileStat.birthtime.toISOString(),
lastActivityAt: fileStat.mtime.getTime(),
status: 'active',
toolCallCount: 0,
entryCount: 0,
fileSize: stat.size,
fileSize: fileStat.size,
description,
};
@@ -1221,6 +1299,8 @@ export class SubagentWatcher extends EventEmitter {
}
}
// Track file context for directory watcher change handling
this.fileAgentContext.set(filePath, { projectHash, sessionId });
this.agentInfo.set(agentId, info);
this.emit('subagent:discovered', info);
@@ -1234,71 +1314,7 @@ export class SubagentWatcher extends EventEmitter {
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
});
// Watch for changes
try {
const watcher = watch(filePath, async (eventType) => {
if (eventType === 'change') {
const currentPos = this.filePositions.get(filePath) || 0;
const newPos = await this.tailFile(filePath, agentId, sessionId, currentPos);
this.filePositions.set(filePath, newPos);
// Update info
const existingInfo = this.agentInfo.get(agentId);
if (existingInfo) {
try {
const newStat = await statAsync(filePath);
existingInfo.lastActivityAt = Date.now();
existingInfo.fileSize = newStat.size;
existingInfo.status = 'active';
} catch {
// Stat failed
}
// Retry description extraction if missing (race condition fix)
if (!existingInfo.description) {
// First try parent transcript (most reliable)
let extractedDescription = await this.extractDescriptionFromParentTranscript(
existingInfo.projectHash,
existingInfo.sessionId,
agentId
);
// Fallback to subagent file
if (!extractedDescription) {
extractedDescription = await this.extractDescriptionFromFile(filePath);
}
if (extractedDescription) {
// Check if this is an internal agent - if so, remove it
if (this.isInternalAgent(extractedDescription)) {
this.removeAgent(agentId);
return;
}
existingInfo.description = extractedDescription;
this.emit('subagent:updated', existingInfo);
}
}
// Reset idle timer
this.resetIdleTimer(agentId);
}
}
});
// Handle watcher errors to prevent unhandled exceptions
// Store handler reference for proper cleanup
const errorHandler = (error: Error) => {
this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId);
watcher.close();
this.fileWatcherErrorHandlers.delete(filePath);
this.fileWatchers.delete(filePath);
};
watcher.on('error', errorHandler);
this.fileWatcherErrorHandlers.set(filePath, errorHandler);
this.fileWatchers.set(filePath, watcher);
this.resetIdleTimer(agentId);
} catch {
// Watch failed
}
this.resetIdleTimer(agentId);
}
/**
+56 -1
View File
@@ -13,12 +13,14 @@ import { readdir, readFile, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { watch as chokidarWatch, type FSWatcher as ChokidarWatcher } from 'chokidar';
import type { TeamConfig, TeamMember, TeamTask, InboxMessage } from './types.js';
import { LRUMap } from './utils/lru-map.js';
// ========== Constants ==========
const POLL_INTERVAL_MS = 5000;
const POLL_INTERVAL_MS = 30000;
const MAX_CACHED_TEAMS = 50;
const MAX_CACHED_TASKS = 200;
@@ -37,6 +39,8 @@ export class TeamWatcher extends EventEmitter {
private inboxMtimes: Map<string, number> = new Map();
// Reverse index: sessionId → teamName for O(1) lookup
private sessionToTeam: Map<string, string> = new Map();
private teamsWatcher: ChokidarWatcher | null = null;
private tasksWatcher: ChokidarWatcher | null = null;
constructor(teamsDir?: string, tasksDir?: string) {
super();
@@ -49,9 +53,60 @@ export class TeamWatcher extends EventEmitter {
if (this.pollTimer) return;
this.poll();
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
this.setupFsWatchers();
}
private setupFsWatchers(): void {
try {
this.teamsWatcher = chokidarWatch(this.teamsDir, {
depth: 2,
awaitWriteFinish: { stabilityThreshold: 200 },
ignored: /\.lock/,
ignoreInitial: true,
persistent: false,
});
const teamsHandler = () => this.pollAsync().catch(() => {});
this.teamsWatcher.on('add', teamsHandler);
this.teamsWatcher.on('change', teamsHandler);
this.teamsWatcher.on('unlink', teamsHandler);
this.teamsWatcher.on('unlinkDir', teamsHandler);
this.teamsWatcher.on('error', (err) => {
console.warn('[TeamWatcher] chokidar teams watcher error:', err);
});
} catch (err) {
console.warn('[TeamWatcher] Failed to set up teams chokidar watcher, relying on polling:', err);
}
try {
this.tasksWatcher = chokidarWatch(this.tasksDir, {
depth: 1,
awaitWriteFinish: { stabilityThreshold: 200 },
ignored: /\.lock/,
ignoreInitial: true,
persistent: false,
});
this.tasksWatcher.on('add', () => this.pollTasks().catch(() => {}));
this.tasksWatcher.on('change', () => this.pollTasks().catch(() => {}));
this.tasksWatcher.on('error', (err) => {
console.warn('[TeamWatcher] chokidar tasks watcher error:', err);
});
} catch (err) {
console.warn('[TeamWatcher] Failed to set up tasks chokidar watcher, relying on polling:', err);
}
}
stop(): void {
// Close chokidar watchers
if (this.teamsWatcher) {
this.teamsWatcher.close().catch(() => {});
this.teamsWatcher = null;
}
if (this.tasksWatcher) {
this.tasksWatcher.close().catch(() => {});
this.tasksWatcher = null;
}
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
+51 -19
View File
@@ -12011,8 +12011,6 @@ class CodemanApp {
const svg = document.getElementById('connectionLines');
if (!svg) return;
svg.innerHTML = '';
// Check if Ralph wizard modal is open
const wizardModal = document.getElementById('ralphWizardModal');
const wizardOpen = wizardModal?.classList.contains('active');
@@ -12032,8 +12030,51 @@ class CodemanApp {
.filter(([, data]) => data.element)
.map(([id, data]) => ({ id, ...data }));
// === PHASE 1: Batch all layout reads (getBoundingClientRect) ===
// Reading layout properties forces the browser to calculate layout.
// By batching all reads before any writes, we avoid repeated forced reflows.
const rects = new Map();
// Read all subagent window rects
for (const { agentId, win } of visibleSubagentWindows) {
const winRect = win.getBoundingClientRect();
rects.set('sub:' + agentId, win.getBoundingClientRect());
}
// Read all plan subagent rects
for (const planAgent of planSubagentArray) {
rects.set('plan:' + planAgent.id, planAgent.element.getBoundingClientRect());
}
// Read wizard rect (if open)
let wizardRect = null;
if (wizardOpen && wizardContent) {
wizardRect = wizardContent.getBoundingClientRect();
}
// Read tab rects for normal mode (only tabs that are actually needed)
if (!wizardOpen) {
for (const { agentId } of visibleSubagentWindows) {
const parentSessionId = this.subagentParentMap.get(agentId);
if (!parentSessionId || rects.has('tab:' + parentSessionId)) continue;
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
if (tab) rects.set('tab:' + parentSessionId, tab.getBoundingClientRect());
}
}
// Read plan window rects for wizard-to-plan lines
if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) {
for (const [agentId, windowData] of this.planSubagents) {
if (!windowData.element) continue;
const key = 'planwin:' + agentId;
if (!rects.has(key)) rects.set(key, windowData.element.getBoundingClientRect());
}
}
// === PHASE 2: DOM writes using cached rects (no more layout reads) ===
svg.innerHTML = '';
for (const { agentId } of visibleSubagentWindows) {
const winRect = rects.get('sub:' + agentId);
// If wizard is open with plan subagents, connect regular subagents to plan subagent windows
if (wizardOpen && wizardContent && planSubagentArray.length > 0) {
@@ -12042,7 +12083,7 @@ class CodemanApp {
let nearestDistance = Infinity;
for (const planAgent of planSubagentArray) {
const planRect = planAgent.element.getBoundingClientRect();
const planRect = rects.get('plan:' + planAgent.id);
const planCenterX = planRect.left + planRect.width / 2;
const planCenterY = planRect.top + planRect.height / 2;
const winCenterX = winRect.left + winRect.width / 2;
@@ -12056,7 +12097,7 @@ class CodemanApp {
}
if (nearestPlanAgent) {
const planRect = nearestPlanAgent.element.getBoundingClientRect();
const planRect = rects.get('plan:' + nearestPlanAgent.id);
// Draw line from plan subagent window to regular subagent window
let x1, y1, x2, y2;
@@ -12087,8 +12128,6 @@ class CodemanApp {
}
} else if (wizardOpen && wizardContent) {
// Wizard open but no plan subagents - connect directly to wizard
const wizardRect = wizardContent.getBoundingClientRect();
const winCenterX = winRect.left + winRect.width / 2;
const wizardCenterX = wizardRect.left + wizardRect.width / 2;
@@ -12124,15 +12163,12 @@ class CodemanApp {
continue;
}
// Find the TAB element by its data-id
const tab = document.querySelector(`.session-tab[data-id="${parentSessionId}"]`);
if (!tab) {
const tabRect = rects.get('tab:' + parentSessionId);
if (!tabRect) {
// Tab not in DOM (might be scrolled out or session closed)
continue;
}
const tabRect = tab.getBoundingClientRect();
// Draw curved line from TAB bottom-center to window top-center
const x1 = tabRect.left + tabRect.width / 2;
const y1 = tabRect.bottom;
@@ -12155,13 +12191,9 @@ class CodemanApp {
// Draw lines from wizard to plan subagent windows (Opus agents during plan generation)
// Skip if agents are minimized to tab
if (wizardOpen && wizardContent && this.planSubagents.size > 0 && !this.planAgentsMinimized) {
const wizardRect = wizardContent.getBoundingClientRect();
for (const [agentId, windowData] of this.planSubagents) {
const win = windowData.element;
if (!win) continue;
const winRect = win.getBoundingClientRect();
for (const [agentId] of this.planSubagents) {
const winRect = rects.get('planwin:' + agentId);
if (!winRect) continue;
// Determine which side of wizard the window is on
const winCenterX = winRect.left + winRect.width / 2;
+12 -10
View File
@@ -995,10 +995,10 @@ export class WebServer extends EventEmitter {
},
},
watchers: {
fileWatchers: subagentStats.fileWatcherCount,
fileDebouncers: subagentStats.fileDebouncerCount,
dirWatchers: subagentStats.dirWatcherCount,
transcriptWatchers: this.transcriptWatchers.size,
total: subagentStats.fileWatcherCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size,
total: subagentStats.fileDebouncerCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size,
},
timers: {
respawnTimers: this.respawnTimers.size,
@@ -5878,15 +5878,17 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
private broadcast(event: string, data: unknown): void {
// Invalidate caches on state-changing broadcasts, but NOT on high-frequency
// streaming events that don't change session metadata (terminal data,
// detection updates). These fire every 16ms-2s and would make the 1s TTL
// caches permanently empty — defeating their purpose.
// Invalidate caches only on structurally significant events — ones that
// change session list content (creation, deletion, or full state refresh).
// High-frequency non-structural events (working/idle transitions, completion,
// error, respawn state changes) are NOT worth invalidating for because:
// 1. The debounced session:updated follows within 500ms with the new state
// 2. These caches serve /api/sessions and SSE init — neither is polled rapidly
// 3. Invalidating on every working/idle transition makes the 1s TTL useless
if (
(event.startsWith('session:') || event.startsWith('respawn:')) &&
event !== 'session:terminal' &&
event !== 'session:needsRefresh' &&
event !== 'respawn:detectionUpdate'
event === 'session:created' ||
event === 'session:deleted' ||
event === 'session:updated'
) {
this.cachedLightState = null;
this.cachedSessionsList = null;