fix: subagent windows not opening — two cascading bugs

1. handleInit crash: commit cdc822d removed Map initializations for
   teammateTerminals, teammatePanesByName, teams, teamTasks, teammateMap
   from the constructor but left cleanup code that iterates them.
   cleanupAllFloatingWindows() crashed on "not iterable", preventing
   ALL frontend data (sessions, subagents) from loading.

2. claudeSessionId null on recovered sessions: only set inside
   startInteractive(), never in constructor or persisted. After server
   restart, recovered sessions had null claudeSessionId, so the
   hasMatchingTab check always failed → no subagent windows.

Fixes:
- Re-add all 5 missing Map initializations in app.js constructor
- Set _claudeSessionId = this.id in Session constructor (Claudeman
  always passes --session-id to Claude, so they always match)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-13 05:07:51 +01:00
co-authored by Claude Opus 4.6
parent 82a0da035c
commit 7d2519cacb
14 changed files with 168 additions and 83 deletions
+1 -1
View File
@@ -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`) 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` 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.1492 (must match `package.json` for npm publish) **Version**: 0.1493 (must match `package.json` for npm publish)
## Project Overview ## Project Overview
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "claudeman", "name": "claudeman",
"version": "0.1492", "version": "0.1493",
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence", "description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",
+1 -1
View File
@@ -108,7 +108,7 @@ export interface TerminalMultiplexer extends EventEmitter {
* tmux: `send-keys -l 'text' Enter` (single command, no delay) * tmux: `send-keys -l 'text' Enter` (single command, no delay)
* screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries) * screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries)
*/ */
sendInput(sessionId: string, input: string): boolean; sendInput(sessionId: string, input: string): Promise<boolean>;
// ========== Metadata ========== // ========== Metadata ==========
+9 -9
View File
@@ -1686,11 +1686,11 @@ export class RespawnController extends EventEmitter {
this.stepTimer = this.startTrackedTimer( this.stepTimer = this.startTrackedTimer(
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
() => { async () => {
this.stepTimer = null; this.stepTimer = null;
const prompt = this.config.kickstartPrompt!; const prompt = this.config.kickstartPrompt!;
this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`);
this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI await this.session.writeViaScreen(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI
this.emit('stepSent', 'kickstart', prompt); this.emit('stepSent', 'kickstart', prompt);
this.setState('waiting_kickstart'); this.setState('waiting_kickstart');
this.promptDetected = false; this.promptDetected = false;
@@ -2451,7 +2451,7 @@ export class RespawnController extends EventEmitter {
this.logAction('command', 'Auto-accept: ↵ Enter (plan approved)'); this.logAction('command', 'Auto-accept: ↵ Enter (plan approved)');
this.emit('stepSent', 'auto-accept', '↵'); this.emit('stepSent', 'auto-accept', '↵');
this.session.writeViaScreen('\r'); void this.session.writeViaScreen('\r');
this.emit('autoAcceptSent'); this.emit('autoAcceptSent');
// Reset so we don't keep spamming Enter if Claude doesn't respond // Reset so we don't keep spamming Enter if Claude doesn't respond
this.hasReceivedOutput = false; this.hasReceivedOutput = false;
@@ -2850,7 +2850,7 @@ export class RespawnController extends EventEmitter {
this.stepTimer = this.startTrackedTimer( this.stepTimer = this.startTrackedTimer(
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
() => { async () => {
this.stepTimer = null; this.stepTimer = null;
// Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config
@@ -2865,7 +2865,7 @@ export class RespawnController extends EventEmitter {
const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`); this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
this.session.writeViaScreen(input); await this.session.writeViaScreen(input);
this.emit('stepSent', 'update', updatePrompt); this.emit('stepSent', 'update', updatePrompt);
this.setState('waiting_update'); this.setState('waiting_update');
this.promptDetected = false; this.promptDetected = false;
@@ -2889,10 +2889,10 @@ export class RespawnController extends EventEmitter {
this.stepTimer = this.startTrackedTimer( this.stepTimer = this.startTrackedTimer(
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
() => { async () => {
this.stepTimer = null; this.stepTimer = null;
this.logAction('command', 'Sending: /clear'); this.logAction('command', 'Sending: /clear');
this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaScreen('/clear\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'clear', '/clear'); this.emit('stepSent', 'clear', '/clear');
this.setState('waiting_clear'); this.setState('waiting_clear');
this.promptDetected = false; this.promptDetected = false;
@@ -2932,10 +2932,10 @@ export class RespawnController extends EventEmitter {
this.stepTimer = this.startTrackedTimer( this.stepTimer = this.startTrackedTimer(
'step-delay', 'step-delay',
this.config.interStepDelayMs, this.config.interStepDelayMs,
() => { async () => {
this.stepTimer = null; this.stepTimer = null;
this.logAction('command', 'Sending: /init'); this.logAction('command', 'Sending: /init');
this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI await this.session.writeViaScreen('/init\r'); // \r triggers Enter in Ink/Claude CLI
this.emit('stepSent', 'init', '/init'); this.emit('stepSent', 'init', '/init');
this.setState('waiting_init'); this.setState('waiting_init');
this.promptDetected = false; this.promptDetected = false;
+1 -1
View File
@@ -830,7 +830,7 @@ export class ScreenManager extends EventEmitter implements TerminalMultiplexer {
// Send input directly to screen session using screen -X stuff // Send input directly to screen session using screen -X stuff
// This bypasses the attached PTY and sends input directly to the screen // This bypasses the attached PTY and sends input directly to the screen
sendInput(sessionId: string, input: string): boolean { async sendInput(sessionId: string, input: string): Promise<boolean> {
if (IS_TEST_MODE) return true; if (IS_TEST_MODE) return true;
const screen = this.screens.get(sessionId); const screen = this.screens.get(sessionId);
if (!screen) { if (!screen) {
+52 -24
View File
@@ -382,6 +382,11 @@ export class Session extends EventEmitter {
this.mode = config.mode || 'claude'; this.mode = config.mode || 'claude';
this._name = config.name || ''; this._name = config.name || '';
this._lastActivityAt = this.createdAt; this._lastActivityAt = this.createdAt;
// Set claudeSessionId immediately — Claudeman always passes --session-id ${this.id}
// to Claude CLI, so the Claude session ID always matches the Claudeman session ID.
// This ensures subagent matching works even for recovered sessions (where
// startInteractive() hasn't been called yet).
this._claudeSessionId = this.id;
// Support both new (mux) and deprecated (screenManager) parameter names // Support both new (mux) and deprecated (screenManager) parameter names
this._mux = config.mux || config.screenManager || null; this._mux = config.mux || config.screenManager || null;
this._useMux = config.useMux ?? config.useScreen ?? (this._mux !== null && this._mux.isAvailable()); this._useMux = config.useMux ?? config.useScreen ?? (this._mux !== null && this._mux.isAvailable());
@@ -950,10 +955,16 @@ export class Session extends EventEmitter {
.replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L .replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
if (!data) return; // Skip if only filtered sequences if (!data) return; // Skip if only filtered sequences
// Strip ANSI once for all downstream pattern-matching operations // Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
// This avoids redundant regex operations in parseTokensFromStatusLine, // During active streaming, many consumers early-exit (ralph disabled, cli info parsed,
// parseClaudeCodeInfo, parseTaskDescriptionsFromTerminalData, and working detection // no 'token' in data, etc.), so we skip the expensive O(n) regex on most chunks.
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_FULL, ''); let _cleanData: string | null = null;
const getCleanData = (): string => {
if (_cleanData === null) {
_cleanData = data.replace(ANSI_ESCAPE_PATTERN_FULL, '');
}
return _cleanData;
};
// BufferAccumulator handles auto-trimming when max size exceeded // BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data); this._terminalBuffer.append(data);
@@ -962,21 +973,35 @@ export class Session extends EventEmitter {
this.emit('terminal', data); this.emit('terminal', data);
this.emit('output', data); this.emit('output', data);
// Forward to Ralph tracker to detect Ralph loops and todos (pre-stripped) // Forward to Ralph tracker to detect Ralph loops and todos
this._ralphTracker.processCleanData(cleanData); // Ralph tracker early-exits when disabled + autoEnableDisabled, skipping ANSI strip
if (this._ralphTracker.enabled || !this._ralphTracker.autoEnableDisabled) {
this._ralphTracker.processCleanData(getCleanData());
}
// Forward to Bash tool parser to detect file-viewing commands (pre-stripped) // Forward to Bash tool parser to detect file-viewing commands
this._bashToolParser.processCleanData(cleanData); // Parser early-exits when disabled, skipping ANSI strip
if (this._bashToolParser.enabled) {
this._bashToolParser.processCleanData(getCleanData());
}
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens") // Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
this.parseTokensFromStatusLine(cleanData); // Pre-check on raw data: 'token' won't appear in ANSI sequences
if (data.includes('token')) {
this.parseTokensFromStatusLine(getCleanData());
}
// Parse Claude Code CLI info (version, model, account type) from startup // Parse Claude Code CLI info (version, model, account type) from startup
this.parseClaudeCodeInfo(cleanData); // Gated by _cliInfoParsed — only runs during first few chunks
if (!this._cliInfoParsed) {
this.parseClaudeCodeInfo(getCleanData());
}
// Parse task descriptions from terminal output (e.g., "Explore(Check files)") // Parse task descriptions from terminal output (e.g., "Explore(Check files)")
// This enables correlating subagent windows with their short descriptions // Pre-check on raw data: parentheses are safe to check without ANSI strip
this.parseTaskDescriptionsFromTerminalData(cleanData); if (data.includes('(') && data.includes(')')) {
this.parseTaskDescriptionsFromTerminalData(getCleanData());
}
// Detect if Claude is working or at prompt // Detect if Claude is working or at prompt
// The prompt line contains "❯" when waiting for input // The prompt line contains "❯" when waiting for input
@@ -1004,13 +1029,16 @@ export class Session extends EventEmitter {
} }
// Detect when Claude starts working (thinking, writing, etc) // Detect when Claude starts working (thinking, writing, etc)
// Using pre-cleaned data avoids false positives from window titles like "3 File Reading Task" // Fast path: check spinner characters on raw data (Unicode, never in ANSI sequences)
if (cleanData.includes('Thinking') || cleanData.includes('Writing') || const hasSpinner = data.includes('⠋') || data.includes('⠙') ||
cleanData.includes('Reading') || cleanData.includes('Running') || data.includes('⠹') || data.includes('⠸') ||
cleanData.includes('⠋') || cleanData.includes('⠙') || data.includes('⠼') || data.includes('⠴') ||
cleanData.includes('⠹') || cleanData.includes('⠸') || data.includes('⠦') || data.includes('⠧');
cleanData.includes('⠼') || cleanData.includes('⠴') || // Slow path: check text keywords on clean data (avoids false positives from window titles)
cleanData.includes('⠦') || cleanData.includes('⠧')) { const hasWorkKeyword = hasSpinner ||
getCleanData().includes('Thinking') || getCleanData().includes('Writing') ||
getCleanData().includes('Reading') || getCleanData().includes('Running');
if (hasWorkKeyword) {
if (!this._isWorking) { if (!this._isWorking) {
this._isWorking = true; this._isWorking = true;
this._status = 'busy'; this._status = 'busy';
@@ -1712,7 +1740,7 @@ export class Session extends EventEmitter {
console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`); console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`);
// Wait for Claude to be idle before compacting // Wait for Claude to be idle before compacting
const checkAndCompact = () => { const checkAndCompact = async () => {
// Check if session is still valid (not stopped) - must be first check // Check if session is still valid (not stopped) - must be first check
if (this._isStopped) return; if (this._isStopped) return;
if (!this._isCompacting) return; if (!this._isCompacting) return;
@@ -1725,7 +1753,7 @@ export class Session extends EventEmitter {
const compactCmd = this._autoCompactPrompt const compactCmd = this._autoCompactPrompt
? `/compact ${this._autoCompactPrompt}\r` ? `/compact ${this._autoCompactPrompt}\r`
: '/compact\r'; : '/compact\r';
this.writeViaScreen(compactCmd); await this.writeViaScreen(compactCmd);
this.emit('autoCompact', { this.emit('autoCompact', {
tokens: totalTokens, tokens: totalTokens,
threshold: this._autoCompactThreshold, threshold: this._autoCompactThreshold,
@@ -1766,7 +1794,7 @@ export class Session extends EventEmitter {
console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`); console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`);
// Wait for Claude to be idle before clearing // Wait for Claude to be idle before clearing
const checkAndClear = () => { const checkAndClear = async () => {
// Check if session is still valid (not stopped) - must be first check // Check if session is still valid (not stopped) - must be first check
if (this._isStopped) return; if (this._isStopped) return;
if (!this._isClearing) return; if (!this._isClearing) return;
@@ -1776,7 +1804,7 @@ export class Session extends EventEmitter {
if (this._isStopped) return; if (this._isStopped) return;
// Send /clear command // Send /clear command
this.writeViaScreen('/clear\r'); await this.writeViaScreen('/clear\r');
// Reset token counts // Reset token counts
this._totalInputTokens = 0; this._totalInputTokens = 0;
this._totalOutputTokens = 0; this._totalOutputTokens = 0;
@@ -1846,7 +1874,7 @@ export class Session extends EventEmitter {
* session.writeViaScreen('/init\r'); // Send /init command * session.writeViaScreen('/init\r'); // Send /init command
* ``` * ```
*/ */
writeViaScreen(data: string): boolean { async writeViaScreen(data: string): Promise<boolean> {
if (this._mux && this._muxSession) { if (this._mux && this._muxSession) {
return this._mux.sendInput(this.id, data); return this._mux.sendInput(this.id, data);
} }
+13 -3
View File
@@ -123,7 +123,8 @@ export interface SubagentEvents {
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects'); const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude/projects');
const IDLE_TIMEOUT_MS = 30000; // Consider agent idle after 30s of no activity 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 POLL_INTERVAL_MS = 1000; // Base poll interval (lightweight checks)
const FULL_SCAN_EVERY_N_POLLS = 5; // Full directory traversal every 5th poll (5s)
const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s
const STALE_COMPLETED_MAX_AGE_MS = 60 * 60 * 1000; // Remove completed agents older than 1 hour 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 STALE_IDLE_MAX_AGE_MS = 4 * 60 * 60 * 1000; // Remove idle agents older than 4 hours
@@ -168,6 +169,8 @@ export class SubagentWatcher extends EventEmitter {
private pendingToolCalls = new Map<string, Map<string, { toolName: string; timestamp: number }>>(); private pendingToolCalls = new Map<string, Map<string, { toolName: string; timestamp: number }>>();
// Guard to prevent concurrent liveness checks (prevents duplicate completed events) // Guard to prevent concurrent liveness checks (prevents duplicate completed events)
private _isCheckingLiveness = false; private _isCheckingLiveness = false;
// Counter for throttling full directory scans (only scan every FULL_SCAN_EVERY_N_POLLS)
private _pollCount = 0;
// Store error handlers for FSWatchers to enable proper cleanup (prevent memory leaks) // Store error handlers for FSWatchers to enable proper cleanup (prevent memory leaks)
private dirWatcherErrorHandlers = new Map<string, (error: Error) => void>(); private dirWatcherErrorHandlers = new Map<string, (error: Error) => void>();
private fileWatcherErrorHandlers = new Map<string, (error: Error) => void>(); private fileWatcherErrorHandlers = new Map<string, (error: Error) => void>();
@@ -208,12 +211,18 @@ export class SubagentWatcher extends EventEmitter {
if (this._isRunning) return; if (this._isRunning) return;
this._isRunning = true; this._isRunning = true;
// Initial scan // Initial scan (always runs immediately)
this._pollCount = 0;
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error)); this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
// Periodic scan for new subagent directories // Periodic scan for new subagent directories
// Full directory traversal only every FULL_SCAN_EVERY_N_POLLS polls (~5s)
// FSWatchers handle known directories between full scans
this.pollInterval = setInterval(() => { this.pollInterval = setInterval(() => {
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error)); this._pollCount++;
if (this._pollCount % FULL_SCAN_EVERY_N_POLLS === 0) {
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
}
}, POLL_INTERVAL_MS); }, POLL_INTERVAL_MS);
// Periodic liveness check for active subagents // Periodic liveness check for active subagents
@@ -338,6 +347,7 @@ export class SubagentWatcher extends EventEmitter {
this.agentInfo.clear(); this.agentInfo.clear();
this.knownSubagentDirs.clear(); this.knownSubagentDirs.clear();
this.pendingToolCalls.clear(); this.pendingToolCalls.clear();
this._pollCount = 0;
} }
/** /**
+9 -9
View File
@@ -859,7 +859,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* - `Enter` key is sent as a separate argument (not a shell escape) * - `Enter` key is sent as a separate argument (not a shell escape)
* - Single command, no delay, no retry loop needed * - Single command, no delay, no retry loop needed
*/ */
sendInput(sessionId: string, input: string): boolean { async sendInput(sessionId: string, input: string): Promise<boolean> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`); console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`);
@@ -886,25 +886,25 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// Send text + Enter in a single command // Send text + Enter in a single command
// -l flag = literal text (no special key interpretation) // -l flag = literal text (no special key interpretation)
// 'Enter' after -l text = Enter key // 'Enter' after -l text = Enter key
execSync( await execAsync(
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, `tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } { timeout: EXEC_TIMEOUT_MS }
); );
execSync( await execAsync(
`tmux send-keys -t "${session.muxName}" Enter`, `tmux send-keys -t "${session.muxName}" Enter`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } { timeout: EXEC_TIMEOUT_MS }
); );
} else if (textPart) { } else if (textPart) {
// Text only, no Enter // Text only, no Enter
execSync( await execAsync(
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, `tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } { timeout: EXEC_TIMEOUT_MS }
); );
} else if (hasCarriageReturn) { } else if (hasCarriageReturn) {
// Enter only // Enter only
execSync( await execAsync(
`tmux send-keys -t "${session.muxName}" Enter`, `tmux send-keys -t "${session.muxName}" Enter`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } { timeout: EXEC_TIMEOUT_MS }
); );
} }
+67 -14
View File
@@ -15,7 +15,7 @@ const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 64 * 1024; // 64KB chunks for terminal data const TERMINAL_CHUNK_SIZE = 128 * 1024; // 128KB chunks for terminal data
const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load
const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
@@ -1129,6 +1129,8 @@ class ClaudemanApp {
this.terminal = null; this.terminal = null;
this.fitAddon = null; this.fitAddon = null;
this.activeSessionId = null; this.activeSessionId = null;
this._initGeneration = 0; // dedup concurrent handleInit calls
this._selectGeneration = 0; // cancel stale selectSession loads
this.respawnStatus = {}; this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers this.respawnTimers = {}; // Track timed respawn timers
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } } this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
@@ -1158,6 +1160,15 @@ class ClaudemanApp {
// Once set, never recalculated. Persisted to localStorage and server. // Once set, never recalculated. Persisted to localStorage and server.
this.subagentParentMap = new Map(); this.subagentParentMap = new Map();
// Agent Teams tracking
this.teams = new Map(); // Map<teamName, TeamConfig>
this.teamTasks = new Map(); // Map<teamName, TeamTask[]>
this.teammateMap = new Map(); // Map<agentId-prefix, {name, color, teamName}> for quick lookup
// Teammate tmux pane terminals (Agent Teams feature)
this.teammatePanesByName = new Map(); // Map<name, { paneTarget, sessionId, color }>
this.teammateTerminals = new Map(); // Map<agentId, { terminal, fitAddon, paneTarget, sessionId, resizeObserver }>
this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphStatePanelCollapsed = true; // Default to collapsed
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
@@ -1855,10 +1866,10 @@ class ClaudemanApp {
* Write large buffer to terminal in chunks to avoid UI jank. * Write large buffer to terminal in chunks to avoid UI jank.
* Uses requestAnimationFrame to spread work across frames. * Uses requestAnimationFrame to spread work across frames.
* @param {string} buffer - The full terminal buffer to write * @param {string} buffer - The full terminal buffer to write
* @param {number} chunkSize - Size of each chunk (default 64KB for smooth 60fps) * @param {number} chunkSize - Size of each chunk (default 128KB for smooth 60fps)
* @returns {Promise<void>} - Resolves when all chunks written * @returns {Promise<void>} - Resolves when all chunks written
*/ */
chunkedTerminalWrite(buffer, chunkSize = 64 * 1024) { chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!buffer || buffer.length === 0) { if (!buffer || buffer.length === 0) {
resolve(); resolve();
@@ -3050,6 +3061,8 @@ class ClaudemanApp {
} }
handleInit(data) { handleInit(data) {
const gen = ++this._initGeneration;
// Update version displays (header and toolbar) // Update version displays (header and toolbar)
if (data.version) { if (data.version) {
const versionEl = this.$('versionDisplay'); const versionEl = this.$('versionDisplay');
@@ -3214,6 +3227,8 @@ class ClaudemanApp {
// Restore previously active session (survives page reload + SSE reconnect) // Restore previously active session (survives page reload + SSE reconnect)
// Must always re-select because handleInit clears terminal state above. // Must always re-select because handleInit clears terminal state above.
// Reset activeSessionId so selectSession doesn't early-return. // Reset activeSessionId so selectSession doesn't early-return.
// Guard: skip if a newer handleInit has already started (race between loadState + SSE init).
if (gen !== this._initGeneration) return;
const previousActiveId = this.activeSessionId; const previousActiveId = this.activeSessionId;
this.activeSessionId = null; this.activeSessionId = null;
if (this.sessionOrder.length > 0) { if (this.sessionOrder.length > 0) {
@@ -3738,17 +3753,39 @@ class ClaudemanApp {
async selectSession(sessionId) { async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return; if (this.activeSessionId === sessionId) return;
// Instant visual feedback — works even with no network. const selectGen = ++this._selectGeneration;
// Appended to .main (not terminalContainer) so xterm DOM resets can't remove it.
// Green fade overlay — Web Animations API for reliable GPU compositing.
// (CSS keyframe animations were invisible: black-on-black had zero contrast.)
const main = document.querySelector('.main'); const main = document.querySelector('.main');
if (main) { let fade = null;
const prev = main.querySelector('.tab-switch-glow'); const fadeOutPromise = new Promise(resolve => {
if (prev) prev.remove(); if (main) {
const glow = document.createElement('div'); const prev = main.querySelector('.tab-switch-fade');
glow.className = 'tab-switch-glow'; if (prev) prev.remove();
glow.addEventListener('animationend', () => glow.remove(), { once: true }); fade = document.createElement('div');
main.appendChild(glow); fade.className = 'tab-switch-fade';
} fade.style.opacity = '0';
fade.style.willChange = 'opacity';
fade.style.transform = 'translateZ(0)'; // force own GPU layer
main.appendChild(fade);
// Web Animations API — runs on compositor thread, bypasses containment issues
const anim = fade.animate(
[{ opacity: 0 }, { opacity: 1 }],
{ duration: 120, easing: 'ease-out', fill: 'forwards' }
);
anim.finished.then(resolve).catch(resolve);
setTimeout(resolve, 350); // safety fallback
} else {
resolve();
}
});
// Yield to browser so it paints the fade overlay at opacity 0 and starts
// animating BEFORE the heavy sync work below (renderSessionTabs etc).
await new Promise(r => requestAnimationFrame(r));
if (selectGen !== this._selectGeneration) return; // newer tab switch won
// Clean up flicker filter state when switching sessions // Clean up flicker filter state when switching sessions
if (this.flickerFilterTimeout) { if (this.flickerFilterTimeout) {
@@ -3798,9 +3835,14 @@ class ClaudemanApp {
// Load terminal buffer for this session // Load terminal buffer for this session
// Use tail mode for faster initial load (256KB is enough for recent visible content) // Use tail mode for faster initial load (256KB is enough for recent visible content)
// Fetch runs in parallel with fade-out; we wait for both before clearing terminal.
try { try {
const tailSize = 256 * 1024; const tailSize = 256 * 1024;
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`); const [res] = await Promise.all([
fetch(`/api/sessions/${sessionId}/terminal?tail=${tailSize}`),
fadeOutPromise,
]);
if (selectGen !== this._selectGeneration) return; // stale — newer selectSession won
const data = await res.json(); const data = await res.json();
this.terminal.clear(); this.terminal.clear();
@@ -3812,6 +3854,7 @@ class ClaudemanApp {
} }
// Use chunked write for large buffers to avoid UI jank // Use chunked write for large buffers to avoid UI jank
await this.chunkedTerminalWrite(data.terminalBuffer); await this.chunkedTerminalWrite(data.terminalBuffer);
if (selectGen !== this._selectGeneration) return; // stale — skip post-write UI updates
// Ensure terminal is scrolled to bottom after buffer load // Ensure terminal is scrolled to bottom after buffer load
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
} }
@@ -3872,8 +3915,18 @@ class ClaudemanApp {
this.terminal.focus(); this.terminal.focus();
this.terminal.scrollToBottom(); this.terminal.scrollToBottom();
// Reveal new content — green fade dissolves out via WAAPI
if (fade) {
const revealAnim = fade.animate(
[{ opacity: 1 }, { opacity: 0 }],
{ duration: 120, easing: 'ease-in', fill: 'forwards' }
);
revealAnim.finished.then(() => fade.remove()).catch(() => fade.remove());
}
} catch (err) { } catch (err) {
console.error('Failed to load session terminal:', err); console.error('Failed to load session terminal:', err);
if (fade) fade.remove();
} }
} }
+5 -11
View File
@@ -1673,22 +1673,16 @@ body {
contain: strict; contain: strict;
will-change: contents; will-change: contents;
} }
/* Tab switch: green-tinted overlay fades in over old content, fades out to reveal new.
/* Tab switch feedback: green glow emerging from black */ Animated via Web Animations API (not CSS keyframes) for reliable GPU compositing.
.tab-switch-glow { Green glow is visible against the dark terminal — black-on-black was imperceptible. */
.tab-switch-fade {
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
z-index: 100; z-index: 100;
background: radial-gradient(ellipse at center, rgba(34, 197, 94, 0.12) 0%, transparent 70%); background: radial-gradient(ellipse at center, rgba(20, 80, 40, 0.85) 0%, rgba(0, 5, 2, 0.97) 75%);
box-shadow: inset 0 0 150px 60px rgba(34, 197, 94, 0.35); box-shadow: inset 0 0 150px 60px rgba(34, 197, 94, 0.35);
animation: tab-switch-glow 0.7s ease-out forwards;
}
@keyframes tab-switch-glow {
0% { opacity: 0; }
15% { opacity: 1; }
100% { opacity: 0; }
} }
.terminal-container .xterm { .terminal-container .xterm {
+1 -1
View File
@@ -1524,7 +1524,7 @@ export class WebServer extends EventEmitter {
// Use writeViaScreen for programmatic input (more reliable for mux sessions) // Use writeViaScreen for programmatic input (more reliable for mux sessions)
let success = false; let success = false;
if (useScreen) { if (useScreen) {
success = session.writeViaScreen(inputStr); success = await session.writeViaScreen(inputStr);
if (!success) { if (!success) {
console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`); console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`);
// Fallback to direct write if screen write fails // Fallback to direct write if screen write fails
+1 -1
View File
@@ -23,7 +23,7 @@ class MockSession extends EventEmitter {
this.writeBuffer.push(data); this.writeBuffer.push(data);
} }
writeViaScreen(data: string): boolean { async writeViaScreen(data: string): Promise<boolean> {
this.writeBuffer.push(data); this.writeBuffer.push(data);
return true; return true;
} }
+1 -1
View File
@@ -107,7 +107,7 @@ export class MockSession extends EventEmitter {
} }
/** Write via screen (used by respawn controller) */ /** Write via screen (used by respawn controller) */
writeViaScreen(data: string): boolean { async writeViaScreen(data: string): Promise<boolean> {
this.writeBuffer.push(data); this.writeBuffer.push(data);
return true; return true;
} }
+6 -6
View File
@@ -120,17 +120,17 @@ describe('TmuxManager (unit)', () => {
}); });
}); });
it('should return true for registered session (no-op in test mode)', () => { it('should return true for registered session (no-op in test mode)', async () => {
expect(manager.sendInput('test-id', '/clear\r')).toBe(true); expect(await manager.sendInput('test-id', '/clear\r')).toBe(true);
}); });
it('should return false for unknown session', () => { it('should return false for unknown session', async () => {
expect(manager.sendInput('nonexistent', 'hello\r')).toBe(false); expect(await manager.sendInput('nonexistent', 'hello\r')).toBe(false);
}); });
it('should not call any tmux commands in test mode', () => { it('should not call any tmux commands in test mode', async () => {
mockedExecSync.mockClear(); mockedExecSync.mockClear();
manager.sendInput('test-id', 'hello\r'); await manager.sendInput('test-id', 'hello\r');
const sendKeyCalls = mockedExecSync.mock.calls.filter( const sendKeyCalls = mockedExecSync.mock.calls.filter(
([cmd]) => typeof cmd === 'string' && cmd.includes('send-keys') ([cmd]) => typeof cmd === 'string' && cmd.includes('send-keys')
); );