mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user