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`)
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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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",
"type": "module",
"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)
* screen: `stuff "text"` + 100ms delay + `stuff CR` (with retries)
*/
sendInput(sessionId: string, input: string): boolean;
sendInput(sessionId: string, input: string): Promise<boolean>;
// ========== Metadata ==========
+9 -9
View File
@@ -1686,11 +1686,11 @@ export class RespawnController extends EventEmitter {
this.stepTimer = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
() => {
async () => {
this.stepTimer = null;
const prompt = this.config.kickstartPrompt!;
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.setState('waiting_kickstart');
this.promptDetected = false;
@@ -2451,7 +2451,7 @@ export class RespawnController extends EventEmitter {
this.logAction('command', 'Auto-accept: ↵ Enter (plan approved)');
this.emit('stepSent', 'auto-accept', '↵');
this.session.writeViaScreen('\r');
void this.session.writeViaScreen('\r');
this.emit('autoAcceptSent');
// Reset so we don't keep spamming Enter if Claude doesn't respond
this.hasReceivedOutput = false;
@@ -2850,7 +2850,7 @@ export class RespawnController extends EventEmitter {
this.stepTimer = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
() => {
async () => {
this.stepTimer = null;
// 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
this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`);
this.session.writeViaScreen(input);
await this.session.writeViaScreen(input);
this.emit('stepSent', 'update', updatePrompt);
this.setState('waiting_update');
this.promptDetected = false;
@@ -2889,10 +2889,10 @@ export class RespawnController extends EventEmitter {
this.stepTimer = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
() => {
async () => {
this.stepTimer = null;
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.setState('waiting_clear');
this.promptDetected = false;
@@ -2932,10 +2932,10 @@ export class RespawnController extends EventEmitter {
this.stepTimer = this.startTrackedTimer(
'step-delay',
this.config.interStepDelayMs,
() => {
async () => {
this.stepTimer = null;
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.setState('waiting_init');
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
// 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;
const screen = this.screens.get(sessionId);
if (!screen) {
+52 -24
View File
@@ -382,6 +382,11 @@ export class Session extends EventEmitter {
this.mode = config.mode || 'claude';
this._name = config.name || '';
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
this._mux = config.mux || config.screenManager || null;
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
if (!data) return; // Skip if only filtered sequences
// Strip ANSI once for all downstream pattern-matching operations
// This avoids redundant regex operations in parseTokensFromStatusLine,
// parseClaudeCodeInfo, parseTaskDescriptionsFromTerminalData, and working detection
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_FULL, '');
// Lazy ANSI strip: only compute cleanData when a consumer actually needs it.
// During active streaming, many consumers early-exit (ralph disabled, cli info parsed,
// no 'token' in data, etc.), so we skip the expensive O(n) regex on most chunks.
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
this._terminalBuffer.append(data);
@@ -962,21 +973,35 @@ export class Session extends EventEmitter {
this.emit('terminal', data);
this.emit('output', data);
// Forward to Ralph tracker to detect Ralph loops and todos (pre-stripped)
this._ralphTracker.processCleanData(cleanData);
// Forward to Ralph tracker to detect Ralph loops and todos
// 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)
this._bashToolParser.processCleanData(cleanData);
// Forward to Bash tool parser to detect file-viewing commands
// 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")
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
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)")
// This enables correlating subagent windows with their short descriptions
this.parseTaskDescriptionsFromTerminalData(cleanData);
// Pre-check on raw data: parentheses are safe to check without ANSI strip
if (data.includes('(') && data.includes(')')) {
this.parseTaskDescriptionsFromTerminalData(getCleanData());
}
// Detect if Claude is working or at prompt
// 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)
// Using pre-cleaned data avoids false positives from window titles like "3 File Reading Task"
if (cleanData.includes('Thinking') || cleanData.includes('Writing') ||
cleanData.includes('Reading') || cleanData.includes('Running') ||
cleanData.includes('⠋') || cleanData.includes('⠙') ||
cleanData.includes('⠹') || cleanData.includes('⠸') ||
cleanData.includes('⠼') || cleanData.includes('⠴') ||
cleanData.includes('⠦') || cleanData.includes('⠧')) {
// Fast path: check spinner characters on raw data (Unicode, never in ANSI sequences)
const hasSpinner = data.includes('⠋') || data.includes('⠙') ||
data.includes('⠹') || data.includes('⠸') ||
data.includes('⠼') || data.includes('⠴') ||
data.includes('⠦') || data.includes('⠧');
// Slow path: check text keywords on clean data (avoids false positives from window titles)
const hasWorkKeyword = hasSpinner ||
getCleanData().includes('Thinking') || getCleanData().includes('Writing') ||
getCleanData().includes('Reading') || getCleanData().includes('Running');
if (hasWorkKeyword) {
if (!this._isWorking) {
this._isWorking = true;
this._status = 'busy';
@@ -1712,7 +1740,7 @@ export class Session extends EventEmitter {
console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`);
// 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
if (this._isStopped) return;
if (!this._isCompacting) return;
@@ -1725,7 +1753,7 @@ export class Session extends EventEmitter {
const compactCmd = this._autoCompactPrompt
? `/compact ${this._autoCompactPrompt}\r`
: '/compact\r';
this.writeViaScreen(compactCmd);
await this.writeViaScreen(compactCmd);
this.emit('autoCompact', {
tokens: totalTokens,
threshold: this._autoCompactThreshold,
@@ -1766,7 +1794,7 @@ export class Session extends EventEmitter {
console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`);
// 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
if (this._isStopped) return;
if (!this._isClearing) return;
@@ -1776,7 +1804,7 @@ export class Session extends EventEmitter {
if (this._isStopped) return;
// Send /clear command
this.writeViaScreen('/clear\r');
await this.writeViaScreen('/clear\r');
// Reset token counts
this._totalInputTokens = 0;
this._totalOutputTokens = 0;
@@ -1846,7 +1874,7 @@ export class Session extends EventEmitter {
* session.writeViaScreen('/init\r'); // Send /init command
* ```
*/
writeViaScreen(data: string): boolean {
async writeViaScreen(data: string): Promise<boolean> {
if (this._mux && this._muxSession) {
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 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 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
@@ -168,6 +169,8 @@ export class SubagentWatcher extends EventEmitter {
private pendingToolCalls = new Map<string, Map<string, { toolName: string; timestamp: number }>>();
// Guard to prevent concurrent liveness checks (prevents duplicate completed events)
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)
private dirWatcherErrorHandlers = 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;
this._isRunning = true;
// Initial scan
// Initial scan (always runs immediately)
this._pollCount = 0;
this.scanForSubagents().catch((err) => this.emit('subagent:error', err as Error));
// 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.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);
// Periodic liveness check for active subagents
@@ -338,6 +347,7 @@ export class SubagentWatcher extends EventEmitter {
this.agentInfo.clear();
this.knownSubagentDirs.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)
* - 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);
if (!session) {
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
// -l flag = literal text (no special key interpretation)
// 'Enter' after -l text = Enter key
execSync(
await execAsync(
`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`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
{ timeout: EXEC_TIMEOUT_MS }
);
} else if (textPart) {
// Text only, no Enter
execSync(
await execAsync(
`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`,
{ encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }
{ timeout: EXEC_TIMEOUT_MS }
);
} else if (hasCarriageReturn) {
// Enter only
execSync(
await execAsync(
`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 AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
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 SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync
const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling
@@ -1129,6 +1129,8 @@ class ClaudemanApp {
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null;
this._initGeneration = 0; // dedup concurrent handleInit calls
this._selectGeneration = 0; // cancel stale selectSession loads
this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers
this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } }
@@ -1158,6 +1160,15 @@ class ClaudemanApp {
// Once set, never recalculated. Persisted to localStorage and server.
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.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.
* Uses requestAnimationFrame to spread work across frames.
* @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
*/
chunkedTerminalWrite(buffer, chunkSize = 64 * 1024) {
chunkedTerminalWrite(buffer, chunkSize = TERMINAL_CHUNK_SIZE) {
return new Promise((resolve) => {
if (!buffer || buffer.length === 0) {
resolve();
@@ -3050,6 +3061,8 @@ class ClaudemanApp {
}
handleInit(data) {
const gen = ++this._initGeneration;
// Update version displays (header and toolbar)
if (data.version) {
const versionEl = this.$('versionDisplay');
@@ -3214,6 +3227,8 @@ class ClaudemanApp {
// Restore previously active session (survives page reload + SSE reconnect)
// Must always re-select because handleInit clears terminal state above.
// 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;
this.activeSessionId = null;
if (this.sessionOrder.length > 0) {
@@ -3738,17 +3753,39 @@ class ClaudemanApp {
async selectSession(sessionId) {
if (this.activeSessionId === sessionId) return;
// Instant visual feedback — works even with no network.
// Appended to .main (not terminalContainer) so xterm DOM resets can't remove it.
const selectGen = ++this._selectGeneration;
// 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');
if (main) {
const prev = main.querySelector('.tab-switch-glow');
if (prev) prev.remove();
const glow = document.createElement('div');
glow.className = 'tab-switch-glow';
glow.addEventListener('animationend', () => glow.remove(), { once: true });
main.appendChild(glow);
}
let fade = null;
const fadeOutPromise = new Promise(resolve => {
if (main) {
const prev = main.querySelector('.tab-switch-fade');
if (prev) prev.remove();
fade = document.createElement('div');
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
if (this.flickerFilterTimeout) {
@@ -3798,9 +3835,14 @@ class ClaudemanApp {
// Load terminal buffer for this session
// 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 {
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();
this.terminal.clear();
@@ -3812,6 +3854,7 @@ class ClaudemanApp {
}
// Use chunked write for large buffers to avoid UI jank
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
this.terminal.scrollToBottom();
}
@@ -3872,8 +3915,18 @@ class ClaudemanApp {
this.terminal.focus();
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) {
console.error('Failed to load session terminal:', err);
if (fade) fade.remove();
}
}
+5 -11
View File
@@ -1673,22 +1673,16 @@ body {
contain: strict;
will-change: contents;
}
/* Tab switch feedback: green glow emerging from black */
.tab-switch-glow {
/* Tab switch: green-tinted overlay fades in over old content, fades out to reveal new.
Animated via Web Animations API (not CSS keyframes) for reliable GPU compositing.
Green glow is visible against the dark terminal — black-on-black was imperceptible. */
.tab-switch-fade {
position: absolute;
inset: 0;
pointer-events: none;
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);
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 {
+1 -1
View File
@@ -1524,7 +1524,7 @@ export class WebServer extends EventEmitter {
// Use writeViaScreen for programmatic input (more reliable for mux sessions)
let success = false;
if (useScreen) {
success = session.writeViaScreen(inputStr);
success = await session.writeViaScreen(inputStr);
if (!success) {
console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`);
// Fallback to direct write if screen write fails
+1 -1
View File
@@ -23,7 +23,7 @@ class MockSession extends EventEmitter {
this.writeBuffer.push(data);
}
writeViaScreen(data: string): boolean {
async writeViaScreen(data: string): Promise<boolean> {
this.writeBuffer.push(data);
return true;
}
+1 -1
View File
@@ -107,7 +107,7 @@ export class MockSession extends EventEmitter {
}
/** Write via screen (used by respawn controller) */
writeViaScreen(data: string): boolean {
async writeViaScreen(data: string): Promise<boolean> {
this.writeBuffer.push(data);
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)', () => {
expect(manager.sendInput('test-id', '/clear\r')).toBe(true);
it('should return true for registered session (no-op in test mode)', async () => {
expect(await manager.sendInput('test-id', '/clear\r')).toBe(true);
});
it('should return false for unknown session', () => {
expect(manager.sendInput('nonexistent', 'hello\r')).toBe(false);
it('should return false for unknown session', async () => {
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();
manager.sendInput('test-id', 'hello\r');
await manager.sendInput('test-id', 'hello\r');
const sendKeyCalls = mockedExecSync.mock.calls.filter(
([cmd]) => typeof cmd === 'string' && cmd.includes('send-keys')
);