diff --git a/CLAUDE.md b/CLAUDE.md index 25bb68e8..b9bc3a4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/package.json b/package.json index 5b9c73d8..589176f8 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/mux-interface.ts b/src/mux-interface.ts index ab3fc4bc..bdb32bb6 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -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; // ========== Metadata ========== diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index a35f9401..fef248cd 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -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; diff --git a/src/screen-manager.ts b/src/screen-manager.ts index 849a92e1..249af3b7 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -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 { if (IS_TEST_MODE) return true; const screen = this.screens.get(sessionId); if (!screen) { diff --git a/src/session.ts b/src/session.ts index bff5e9339..cf78c6e9 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 { if (this._mux && this._muxSession) { return this._mux.sendInput(this.id, data); } diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 14c2fc83..0172dbc0 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -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>(); // 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 void>(); private fileWatcherErrorHandlers = new Map 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; } /** diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 4da3d154..6a812ac9 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -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 { 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 } ); } diff --git a/src/web/public/app.js b/src/web/public/app.js index d57a3813..f0c32532 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 + this.teamTasks = new Map(); // Map + this.teammateMap = new Map(); // Map for quick lookup + + // Teammate tmux pane terminals (Agent Teams feature) + this.teammatePanesByName = new Map(); // Map + this.teammateTerminals = new Map(); // Map + 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} - 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(); } } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 142318a7..39f9db04 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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 { diff --git a/src/web/server.ts b/src/web/server.ts index f439f07a..6a8fbc09 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index abee16ca..5b0cd449 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -23,7 +23,7 @@ class MockSession extends EventEmitter { this.writeBuffer.push(data); } - writeViaScreen(data: string): boolean { + async writeViaScreen(data: string): Promise { this.writeBuffer.push(data); return true; } diff --git a/test/respawn-test-utils.ts b/test/respawn-test-utils.ts index 29659ba6..acafbf34 100644 --- a/test/respawn-test-utils.ts +++ b/test/respawn-test-utils.ts @@ -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 { this.writeBuffer.push(data); return true; } diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index f44a4835..7affc78e 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -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') );