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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-26 14:27:21 +01:00
co-authored by Claude Opus 4.5
parent 3c2e89f427
commit 428556e831
10 changed files with 168 additions and 70 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+7 -2
View File
@@ -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;
+42 -20
View File
@@ -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
}
}
+32 -11
View File
@@ -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 {
+25 -8
View File
@@ -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<string | null> {
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'
+1 -3
View File
@@ -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 */ }
+22 -13
View File
@@ -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<string, unknown>): Record<string, unknown> {
const truncated: Record<string, unknown> = {};
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) {
+22 -4
View File
@@ -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`;
}
+15 -7
View File
@@ -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) {