From 428556e831c6304d4d67ba16eb91fa6b7146e067 Mon Sep 17 00:00:00 2001 From: arkon Date: Mon, 26 Jan 2026 14:27:21 +0100 Subject: [PATCH] refactor: extract magic numbers to named constants Low severity code quality improvements: - session.ts: Add timing constants (SCREEN_STARTUP_DELAY_MS, etc.) - screen-manager.ts: Add EXEC_TIMEOUT_MS, CR_MAX_ATTEMPTS, etc. - subagent-watcher.ts: Add display length constants - server.ts: Add STATS_COLLECTION_INTERVAL_MS, SESSION_LIMIT_WAIT_MS, etc. - spawn-orchestrator.ts: Add budget ratio constants - respawn-controller.ts: Use pre-compiled ANSI_ESCAPE_PATTERN - state-store.ts: Use ES6 import for unlinkSync instead of dynamic require - app.js: Add notification timing constants Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 2 +- package.json | 2 +- src/respawn-controller.ts | 9 ++++-- src/screen-manager.ts | 62 ++++++++++++++++++++++++++------------- src/session.ts | 43 ++++++++++++++++++++------- src/spawn-orchestrator.ts | 33 ++++++++++++++++----- src/state-store.ts | 4 +-- src/subagent-watcher.ts | 35 ++++++++++++++-------- src/web/public/app.js | 26 +++++++++++++--- src/web/server.ts | 22 +++++++++----- 10 files changed, 168 insertions(+), 70 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ceb2955..7de8878b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ When user says "COM": 1) Increment version in BOTH `package.json` AND `CLAUDE.md Claudeman is a Claude Code session manager with a web interface and autonomous Ralph Loop. It spawns Claude CLI processes via PTY, streams output in real-time via SSE, and supports scheduled/timed runs. -**Version**: 0.1383 (must match `package.json`) +**Version**: 0.1384 (must match `package.json`) **Tech Stack**: TypeScript (ES2022/NodeNext, strict mode), Node.js, Fastify, Server-Sent Events, node-pty diff --git a/package.json b/package.json index 5b987408..b044818e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "claudeman", - "version": "0.1383", + "version": "0.1384", "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/respawn-controller.ts b/src/respawn-controller.ts index 9a183a34..27a19b55 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -77,6 +77,9 @@ const PLAN_MODE_OPTION_PATTERN = /\d+\.\s+(Yes|No|Type|Cancel|Skip|Proceed|Appro /** Pre-filter: selection indicator arrow for plan mode detection */ const PLAN_MODE_SELECTOR_PATTERN = /[❯>]\s*\d+\./; +/** Pattern to strip ANSI escape codes from terminal output */ +const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g; + // Note: The old '↵ send' indicator is no longer reliable in Claude Code 2024+ // Detection now uses completion message patterns ("for Xm Xs") instead. @@ -1277,7 +1280,8 @@ export class RespawnController extends EventEmitter { // This prevents false triggers when Claude pauses briefly mid-work. if (this._state === 'confirming_idle' || this._state === 'ai_checking') { // Strip ANSI escape codes to check if there's real content - const stripped = data.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim(); + ANSI_ESCAPE_PATTERN.lastIndex = 0; + const stripped = data.replace(ANSI_ESCAPE_PATTERN, '').trim(); if (stripped.length > 2) { if (this._state === 'ai_checking') { this.log(`Substantial output during AI check ("${stripped.substring(0, 40)}..."), cancelling`); @@ -1924,7 +1928,8 @@ export class RespawnController extends EventEmitter { const tail = buffer.slice(-2000); // Strip ANSI codes for pattern matching - const stripped = tail.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); + ANSI_ESCAPE_PATTERN.lastIndex = 0; + const stripped = tail.replace(ANSI_ESCAPE_PATTERN, ''); // Must find numbered option pattern if (!PLAN_MODE_OPTION_PATTERN.test(stripped)) return false; diff --git a/src/screen-manager.ts b/src/screen-manager.ts index c8d1c6ad..61d4beef 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -34,6 +34,28 @@ const CLAUDE_SEARCH_DIRS = [ `${homedir()}/bin`, ]; +// ============================================================================ +// Timing Constants +// ============================================================================ + +/** Timeout for exec commands (5 seconds) */ +const EXEC_TIMEOUT_MS = 5000; + +/** Delay after screen creation (500ms) */ +const SCREEN_CREATION_WAIT_MS = 500; + +/** Delay after screen kill command (200ms) */ +const SCREEN_KILL_WAIT_MS = 200; + +/** Delay for graceful shutdown (100ms) */ +const GRACEFUL_SHUTDOWN_WAIT_MS = 100; + +/** Default stats collection interval (2 seconds) */ +const DEFAULT_STATS_INTERVAL_MS = 2000; + +/** Maximum retry attempts for carriage return (3) */ +const CR_MAX_ATTEMPTS = 3; + /** Cached directory containing the claude binary */ let _claudeDir: string | null = null; @@ -45,7 +67,7 @@ function findClaudeDir(): string | null { if (_claudeDir !== null) return _claudeDir; try { - const result = execSync('which claude', { encoding: 'utf-8', timeout: 5000 }).trim(); + const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); if (result && existsSync(result)) { _claudeDir = dirname(result); return _claudeDir; @@ -234,7 +256,7 @@ export class ScreenManager extends EventEmitter { screenProcess.unref(); // Wait a moment for screen to start - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise(resolve => setTimeout(resolve, SCREEN_CREATION_WAIT_MS)); // Get the PID of the screen session const pid = this.getScreenPid(screenName); @@ -276,7 +298,7 @@ export class ScreenManager extends EventEmitter { const escapedName = shellEscape(screenName); const output = execSync(`screen -ls | grep "${escapedName}"`, { encoding: 'utf-8', - timeout: 5000 + timeout: EXEC_TIMEOUT_MS }); // Output format: "12345.claudeman-abc12345 (Detached)" const match = output.match(/(\d+)\./); @@ -292,7 +314,7 @@ export class ScreenManager extends EventEmitter { try { const output = execSync(`pgrep -P ${pid}`, { encoding: 'utf-8', - timeout: 5000 + timeout: EXEC_TIMEOUT_MS }).trim(); if (output) { for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p))) { @@ -334,7 +356,7 @@ export class ScreenManager extends EventEmitter { } // Give processes a moment to terminate gracefully - await new Promise(resolve => setTimeout(resolve, 200)); + await new Promise(resolve => setTimeout(resolve, SCREEN_KILL_WAIT_MS)); // Force kill any remaining children for (const childPid of childPids) { @@ -349,7 +371,7 @@ export class ScreenManager extends EventEmitter { // Strategy 2: Kill the entire process group (catches any orphans we missed) try { process.kill(-currentPid, 'SIGTERM'); - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS)); process.kill(-currentPid, 'SIGKILL'); } catch { // Process group may not exist or already terminated @@ -358,7 +380,7 @@ export class ScreenManager extends EventEmitter { // Strategy 3: Kill screen session by name try { execSync(`screen -S ${screen.screenName} -X quit`, { - timeout: 5000 + timeout: EXEC_TIMEOUT_MS }); } catch { // Screen may already be dead @@ -425,7 +447,7 @@ export class ScreenManager extends EventEmitter { try { const output = execSync('screen -ls 2>/dev/null || true', { encoding: 'utf-8', - timeout: 5000 + timeout: EXEC_TIMEOUT_MS }); // Match: "12345.claudeman-abc12345 (Detached)" or similar // Reset lastIndex since we're reusing the global regex @@ -485,7 +507,7 @@ export class ScreenManager extends EventEmitter { // Get memory and CPU usage using ps const psOutput = execSync( `ps -o rss=,pcpu= -p ${screen.pid} 2>/dev/null || echo "0 0"`, - { encoding: 'utf-8', timeout: 5000 } + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } ).trim(); const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0); @@ -495,7 +517,7 @@ export class ScreenManager extends EventEmitter { try { const childOutput = execSync( `pgrep -P ${screen.pid} | wc -l`, - { encoding: 'utf-8', timeout: 5000 } + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } ).trim(); childCount = parseInt(childOutput, 10) || 0; } catch { @@ -528,7 +550,7 @@ export class ScreenManager extends EventEmitter { // Single ps call for all PIDs const psOutput = execSync( `ps -o pid=,rss=,pcpu= -p ${pids.join(',')} 2>/dev/null || true`, - { encoding: 'utf-8', timeout: 5000 } + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } ).trim(); // Parse output - each line: "PID RSS CPU" @@ -552,7 +574,7 @@ export class ScreenManager extends EventEmitter { // Batch child count query - single pgrep call const pgrepOutput = execSync( `for p in ${pids.join(' ')}; do echo "$p $(pgrep -P $p 2>/dev/null | wc -l)"; done`, - { encoding: 'utf-8', timeout: 5000 } + { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } ).trim(); for (const line of pgrepOutput.split('\n')) { @@ -582,7 +604,7 @@ export class ScreenManager extends EventEmitter { } // Start periodic stats collection - startStatsCollection(intervalMs: number = 2000): void { + startStatsCollection(intervalMs: number = DEFAULT_STATS_INTERVAL_MS): void { if (this.statsInterval) { clearInterval(this.statsInterval); } @@ -646,7 +668,7 @@ export class ScreenManager extends EventEmitter { // Check if screen is available on the system static isScreenAvailable(): boolean { try { - execSync('which screen', { encoding: 'utf-8', timeout: 5000 }); + execSync('which screen', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }); return true; } catch { return false; @@ -681,7 +703,7 @@ export class ScreenManager extends EventEmitter { // Send text first (if any) if (escapedText) { const textCmd = `screen -S ${screen.screenName} -p 0 -X stuff "${escapedText}"`; - execSync(textCmd, { encoding: 'utf-8', timeout: 5000 }); + execSync(textCmd, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }); } // Send carriage return separately (Enter key for Ink) @@ -696,15 +718,15 @@ export class ScreenManager extends EventEmitter { const crCmd = `screen -S ${screen.screenName} -p 0 -X stuff "$(printf '\\015')"`; - // Try up to 3 times with increasing delays + // Try up to CR_MAX_ATTEMPTS times with increasing delays let success = false; - for (let attempt = 1; attempt <= 3 && !success; attempt++) { + for (let attempt = 1; attempt <= CR_MAX_ATTEMPTS && !success; attempt++) { try { - execSync(crCmd, { encoding: 'utf-8', timeout: 5000 }); + execSync(crCmd, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }); success = true; } catch (crErr) { - console.warn(`[ScreenManager] Carriage return attempt ${attempt}/3 failed`); - if (attempt < 3) { + console.warn(`[ScreenManager] Carriage return attempt ${attempt}/${CR_MAX_ATTEMPTS} failed`); + if (attempt < CR_MAX_ATTEMPTS) { execSync(`sleep 0.${attempt}`, { timeout: 1000 }); // 0.1s, 0.2s delays } } diff --git a/src/session.ts b/src/session.ts index 641567b1..f2240a30 100644 --- a/src/session.ts +++ b/src/session.ts @@ -56,6 +56,27 @@ const MAX_LINE_BUFFER_SIZE = 64 * 1024; /** Line buffer flush interval (100ms) - forces processing of partial lines */ const LINE_BUFFER_FLUSH_INTERVAL = 100; +// ============================================================================ +// Timing Constants +// ============================================================================ + +/** Timeout for exec commands like 'which claude' (5 seconds) */ +const EXEC_TIMEOUT_MS = 5000; + +/** Delay after screen creation before sending commands (300ms) */ +const SCREEN_STARTUP_DELAY_MS = 300; + +/** Delay before declaring session idle after last output (2 seconds) */ +const IDLE_DETECTION_DELAY_MS = 2000; + +/** Delay for auto-compact/clear retry attempts (2 seconds) */ +const AUTO_RETRY_DELAY_MS = 2000; + +/** Delay for auto-compact/clear initial check (1 second) */ +const AUTO_INITIAL_DELAY_MS = 1000; + +/** Graceful shutdown delay when stopping session (100ms) */ +const GRACEFUL_SHUTDOWN_DELAY_MS = 100; // Filter out terminal focus escape sequences (focus in/out reports) // ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences @@ -106,7 +127,7 @@ export function getAugmentedPath(): string { // Try `which` first (respects current PATH) try { - const result = execSync('which claude', { encoding: 'utf-8', timeout: 5000 }).trim(); + const result = execSync('which claude', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); if (result && existsSync(result)) { claudeDir = dirname(result); } @@ -813,7 +834,7 @@ export class Session extends EventEmitter { console.log('[Session] Created screen session:', this._screenSession.screenName); // Wait a moment for screen to fully start - await new Promise(resolve => setTimeout(resolve, 300)); + await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS)); } // Attach to the screen session via PTY @@ -930,7 +951,7 @@ export class Session extends EventEmitter { this._lastPromptTime = Date.now(); this.emit('idle'); } - }, 2000); + }, IDLE_DETECTION_DELAY_MS); } // Detect when Claude starts working (thinking, writing, etc) @@ -1019,7 +1040,7 @@ export class Session extends EventEmitter { console.log('[Session] Created screen session:', this._screenSession.screenName); // Wait a moment for screen to fully start - await new Promise(resolve => setTimeout(resolve, 300)); + await new Promise(resolve => setTimeout(resolve, SCREEN_STARTUP_DELAY_MS)); } // Attach to the screen session via PTY @@ -1559,16 +1580,16 @@ export class Session extends EventEmitter { }, 10000); } } else { - // Check again in 2 seconds + // Check again after delay if (!this._isStopped) { - this._autoCompactTimer = setTimeout(checkAndCompact, 2000); + this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_RETRY_DELAY_MS); } } }; // Start checking after a short delay if (!this._isStopped) { - this._autoCompactTimer = setTimeout(checkAndCompact, 1000); + this._autoCompactTimer = setTimeout(checkAndCompact, AUTO_INITIAL_DELAY_MS); } } } @@ -1603,16 +1624,16 @@ export class Session extends EventEmitter { }, 5000); } } else { - // Check again in 2 seconds + // Check again after delay if (!this._isStopped) { - this._autoClearTimer = setTimeout(checkAndClear, 2000); + this._autoClearTimer = setTimeout(checkAndClear, AUTO_RETRY_DELAY_MS); } } }; // Start checking after a short delay if (!this._isStopped) { - this._autoClearTimer = setTimeout(checkAndClear, 1000); + this._autoClearTimer = setTimeout(checkAndClear, AUTO_INITIAL_DELAY_MS); } } } @@ -1811,7 +1832,7 @@ export class Session extends EventEmitter { } // Give it a moment to terminate gracefully - await new Promise(resolve => setTimeout(resolve, 100)); + await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS)); // Force kill with SIGKILL if still alive try { diff --git a/src/spawn-orchestrator.ts b/src/spawn-orchestrator.ts index 3d656221..31ee3dc3 100644 --- a/src/spawn-orchestrator.ts +++ b/src/spawn-orchestrator.ts @@ -44,6 +44,23 @@ import { import { generateAgentClaudeMd, buildInitialPrompt } from './spawn-claude-md.js'; import { getErrorMessage } from './types.js'; +// ========== Local Constants ========== + +/** UUID truncation length for fallback agent IDs */ +const UUID_TRUNCATE_LENGTH = 8; + +/** Message sequence number padding length */ +const MESSAGE_SEQUENCE_PAD_LENGTH = 3; + +/** Timeout warning threshold (90% of timeout) */ +const TIMEOUT_WARNING_RATIO = 0.9; + +/** Budget hard limit ratio (110% - force stop) */ +const BUDGET_HARD_LIMIT_RATIO = 1.1; + +/** Budget soft limit ratio (100% - warning) */ +const BUDGET_SOFT_LIMIT_RATIO = 1.0; + // ========== Types for integration ========== /** @@ -170,7 +187,7 @@ export class SpawnOrchestrator extends EventEmitter { // Parse task file const content = readFileSync(resolvedPath, 'utf-8'); - const fallbackId = `agent-${uuidv4().slice(0, 8)}`; + const fallbackId = `agent-${uuidv4().slice(0, UUID_TRUNCATE_LENGTH)}`; const parsed = parseTaskSpecFile(content, fallbackId); if (!parsed) { @@ -293,7 +310,7 @@ export class SpawnOrchestrator extends EventEmitter { } const seq = existingMessages.length + 1; - const seqStr = String(seq).padStart(3, '0'); + const seqStr = String(seq).padStart(MESSAGE_SEQUENCE_PAD_LENGTH, '0'); const fileName = `${seqStr}-parent.md`; const message: SpawnMessage = { @@ -466,7 +483,7 @@ export class SpawnOrchestrator extends EventEmitter { parentWorkingDir: string, parentDepth: number = 0 ): Promise { - const fallbackId = `agent-${uuidv4().slice(0, 8)}`; + const fallbackId = `agent-${uuidv4().slice(0, UUID_TRUNCATE_LENGTH)}`; const parsed = parseTaskSpecFile(taskContent, fallbackId); if (!parsed) return null; @@ -702,7 +719,7 @@ export class SpawnOrchestrator extends EventEmitter { const timeoutMs = agent.task.spec.timeoutMinutes * 60 * 1000; // Warning at 90% - store timer for cleanup - const warningMs = timeoutMs * 0.9; + const warningMs = timeoutMs * TIMEOUT_WARNING_RATIO; agent.warningTimer = setTimeout(() => { if (agent.status === 'running' && this._sessionCreator && agent.sessionId) { this._sessionCreator.writeToSession( @@ -728,11 +745,11 @@ export class SpawnOrchestrator extends EventEmitter { const tokensUsed = this._sessionCreator.getSessionTokens(agent.sessionId); const ratio = tokensUsed / agent.tokenBudget; - if (ratio >= 1.1) { + if (ratio >= BUDGET_HARD_LIMIT_RATIO) { // Force kill at 110% this.handleAgentTimeout(agent); return; - } else if (ratio >= 1.0) { + } else if (ratio >= BUDGET_SOFT_LIMIT_RATIO) { // Graceful shutdown this._sessionCreator.writeToSession( agent.sessionId, @@ -753,10 +770,10 @@ export class SpawnOrchestrator extends EventEmitter { const costUsed = this._sessionCreator.getSessionCost(agent.sessionId); const ratio = costUsed / agent.costBudget; - if (ratio >= 1.1) { + if (ratio >= BUDGET_HARD_LIMIT_RATIO) { this.handleAgentTimeout(agent); return; - } else if (ratio >= 1.0) { + } else if (ratio >= BUDGET_SOFT_LIMIT_RATIO) { this._sessionCreator.writeToSession( agent.sessionId, 'You have exceeded your cost budget. Write your result.md NOW and output your completion phrase.\r' diff --git a/src/state-store.ts b/src/state-store.ts index a4d2ded1..88ab176b 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -14,7 +14,7 @@ * @module state-store */ -import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs'; +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js'; @@ -138,7 +138,6 @@ export class StateStore { // Try to clean up temp file on error try { if (existsSync(tempPath)) { - const { unlinkSync } = require('node:fs'); unlinkSync(tempPath); } } catch { /* ignore cleanup errors */ } @@ -479,7 +478,6 @@ export class StateStore { // Try to clean up temp file on error try { if (existsSync(tempPath)) { - const { unlinkSync } = require('node:fs'); unlinkSync(tempPath); } } catch { /* ignore cleanup errors */ } diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 00f081ab..ceeae169 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -125,6 +125,15 @@ const POLL_INTERVAL_MS = 1000; // Check for new files every second const LIVENESS_CHECK_MS = 10000; // Check if subagent processes are still alive every 10s const STALE_AGENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // Remove completed agents older than 24 hours +// Display/preview length constants +const TEXT_PREVIEW_LENGTH = 200; // Length for text previews in tool results +const USER_TEXT_PREVIEW_LENGTH = 80; // Length for user message previews +const SMART_TITLE_MAX_LENGTH = 45; // Max length for smart title extraction +const MESSAGE_TEXT_LIMIT = 500; // Max length for message text content +const COMMAND_DISPLAY_LENGTH = 60; // Max length for command display +const INPUT_TRUNCATE_LENGTH = 100; // Max length for input value truncation +const FILE_CONTENT_DEBOUNCE_MS = 100; // Debounce delay for file content updates + // ========== SubagentWatcher Class ========== export class SubagentWatcher extends EventEmitter { @@ -492,7 +501,7 @@ export class SubagentWatcher extends EventEmitter { if (typeof entry.message.content === 'string') { const text = entry.message.content.trim(); if (text.length > 0) { - const preview = text.length > 200 ? text.substring(0, 200) + '...' : text; + const preview = text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text; lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); } } else { @@ -502,7 +511,7 @@ export class SubagentWatcher extends EventEmitter { } else if (content.type === 'text' && content.text) { const text = content.text.trim(); if (text.length > 0) { - const preview = text.length > 200 ? text.substring(0, 200) + '...' : text; + const preview = text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text; lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); } } @@ -513,14 +522,14 @@ export class SubagentWatcher extends EventEmitter { if (typeof entry.message.content === 'string') { const text = entry.message.content.trim(); if (text.length < 100 && !text.includes('{')) { - lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, 80)}`); + lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`); } } else { const firstContent = entry.message.content[0]; if (firstContent?.type === 'text' && firstContent.text) { const text = firstContent.text.trim(); if (text.length < 100 && !text.includes('{')) { - lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, 80)}`); + lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`); } } } @@ -544,7 +553,7 @@ export class SubagentWatcher extends EventEmitter { * Aims for ~40-50 chars that convey what the agent is doing */ private extractSmartTitle(text: string): string { - const MAX_LEN = 45; + const MAX_LEN = SMART_TITLE_MAX_LENGTH; // Get first line/sentence let title = text.split('\n')[0].trim(); @@ -773,7 +782,7 @@ export class SubagentWatcher extends EventEmitter { if (existsSync(filePath)) { this.watchAgentFile(filePath, projectHash, sessionId); } - }, 100); + }, FILE_CONTENT_DEBOUNCE_MS); } }); @@ -1015,7 +1024,7 @@ export class SubagentWatcher extends EventEmitter { sessionId, timestamp: entry.timestamp, role: 'assistant', - text: text.substring(0, 500), + text: text.substring(0, MESSAGE_TEXT_LIMIT), }; this.emit('subagent:message', message); } @@ -1060,7 +1069,7 @@ export class SubagentWatcher extends EventEmitter { timestamp: entry.timestamp, toolUseId: content.tool_use_id, tool: toolName, - preview: resultContent.substring(0, 500), + preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT), contentLength: resultContent.length, isError: content.is_error || false, }; @@ -1073,7 +1082,7 @@ export class SubagentWatcher extends EventEmitter { sessionId, timestamp: entry.timestamp, role: 'assistant', - text: text.substring(0, 500), // Limit text length + text: text.substring(0, MESSAGE_TEXT_LIMIT), // Limit text length }; this.emit('subagent:message', message); } @@ -1110,7 +1119,7 @@ export class SubagentWatcher extends EventEmitter { timestamp: entry.timestamp, toolUseId: content.tool_use_id, tool: toolName, - preview: resultContent.substring(0, 500), + preview: resultContent.substring(0, MESSAGE_TEXT_LIMIT), contentLength: resultContent.length, isError: content.is_error || false, }; @@ -1154,9 +1163,9 @@ export class SubagentWatcher extends EventEmitter { private getTruncatedInput(_tool: string, input: Record): Record { const truncated: Record = {}; for (const [key, value] of Object.entries(input)) { - if (typeof value === 'string' && value.length > 100) { + if (typeof value === 'string' && value.length > INPUT_TRUNCATE_LENGTH) { // Keep short preview of long strings - truncated[key] = value.substring(0, 100) + '...'; + truncated[key] = value.substring(0, INPUT_TRUNCATE_LENGTH) + '...'; } else { truncated[key] = value; } @@ -1212,7 +1221,7 @@ export class SubagentWatcher extends EventEmitter { details = input.file_path as string; } else if (name === 'Bash' && input.command) { const cmd = input.command as string; - details = cmd.length > 60 ? cmd.substring(0, 60) + '...' : cmd; + details = cmd.length > COMMAND_DISPLAY_LENGTH ? cmd.substring(0, COMMAND_DISPLAY_LENGTH) + '...' : cmd; } else if (name === 'Glob' && input.pattern) { details = input.pattern as string; } else if (name === 'Grep' && input.pattern) { diff --git a/src/web/public/app.js b/src/web/public/app.js index 50cf008f..0e01e5e4 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1,7 +1,25 @@ // Claudeman App - Tab-based Terminal UI + +// ============================================================================ +// Constants +// ============================================================================ + // Default terminal scrollback (can be changed via settings) const DEFAULT_SCROLLBACK = 5000; +// Timing constants +const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection +const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping window +const NOTIFICATION_LIST_CAP = 100; // Max notifications in list +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_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 + // DEC mode 2026 - Synchronized Output // Wrap terminal writes with these markers to prevent partial-frame flicker. // Terminal buffers all output between markers and renders atomically. @@ -98,7 +116,7 @@ class NotificationManager { enabled: true, browserNotifications: true, audioAlerts: false, - stuckThresholdMs: 600000, + stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS, muteCritical: false, muteWarning: false, muteInfo: false, @@ -140,7 +158,7 @@ class NotificationManager { existing.notification.message = message; existing.notification.timestamp = Date.now(); clearTimeout(existing.timeout); - existing.timeout = setTimeout(() => this.groupingMap.delete(groupKey), 5000); + existing.timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS); this.scheduleRender(); return; } @@ -163,7 +181,7 @@ class NotificationManager { if (this.notifications.length > 100) this.notifications.pop(); // Track for grouping - const timeout = setTimeout(() => this.groupingMap.delete(groupKey), 5000); + const timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS); this.groupingMap.set(groupKey, { notification, timeout }); // Update unread @@ -236,7 +254,7 @@ class NotificationManager { document.title = this.titleFlashState ? `\u26A0\uFE0F (${this.unreadCount}) Claudeman` : this.originalTitle; - }, 1500); + }, TITLE_FLASH_INTERVAL_MS); // Set immediately document.title = `\u26A0\uFE0F (${this.unreadCount}) Claudeman`; } diff --git a/src/web/server.ts b/src/web/server.ts index 377ff847..92b0c788 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -105,6 +105,14 @@ const MAX_TERMINAL_ROWS = 200; const MAX_SESSION_NAME_LENGTH = 128; // Maximum hook data size (prevents oversized SSE broadcasts) const MAX_HOOK_DATA_SIZE = 8 * 1024; +// Stats collection interval (2 seconds) +const STATS_COLLECTION_INTERVAL_MS = 2000; +// Session limit wait time before retrying (5 seconds) +const SESSION_LIMIT_WAIT_MS = 5000; +// Pause between scheduled run iterations (2 seconds) +const ITERATION_PAUSE_MS = 2000; +// SSE batch flush threshold (number of items) +const BATCH_FLUSH_THRESHOLD = 1024; // Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation) const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/; const CTRL_L_PATTERN = /\x0c/g; @@ -1703,7 +1711,7 @@ export class WebServer extends EventEmitter { // Start stats collection this.app.post('/api/screens/stats/start', async () => { - this.screenManager.startStatsCollection(2000); + this.screenManager.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); return { success: true }; }); @@ -2700,7 +2708,7 @@ export class WebServer extends EventEmitter { // Check session limit before creating new session if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { addLog(`Waiting: maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); - await new Promise(r => setTimeout(r, 5000)); + await new Promise(r => setTimeout(r, SESSION_LIMIT_WAIT_MS)); continue; } @@ -2733,7 +2741,7 @@ export class WebServer extends EventEmitter { run.sessionId = null; // Small pause between iterations - await new Promise(r => setTimeout(r, 2000)); + await new Promise(r => setTimeout(r, ITERATION_PAUSE_MS)); } catch (err) { addLog(`Error: ${getErrorMessage(err)}`); this.broadcast('scheduled:updated', run); @@ -2749,7 +2757,7 @@ export class WebServer extends EventEmitter { } // Continue despite errors - await new Promise(r => setTimeout(r, 5000)); + await new Promise(r => setTimeout(r, SESSION_LIMIT_WAIT_MS)); } } @@ -2884,8 +2892,8 @@ export class WebServer extends EventEmitter { const newBatch = existing + data; this.terminalBatches.set(sessionId, newBatch); - // Flush immediately if batch is large (>1KB) for responsiveness - if (newBatch.length > 1024) { + // Flush immediately if batch is large for responsiveness + if (newBatch.length > BATCH_FLUSH_THRESHOLD) { if (this.terminalBatchTimer) { clearTimeout(this.terminalBatchTimer); this.terminalBatchTimer = null; @@ -3222,7 +3230,7 @@ export class WebServer extends EventEmitter { } // Start stats collection to show screen info - this.screenManager.startStatsCollection(2000); + this.screenManager.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); } if (dead.length > 0) {