mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1437
This commit is contained in:
@@ -16,7 +16,7 @@ When user says "COM":
|
||||
1. Increment version in BOTH `package.json` AND `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.1436 (must match `package.json`)
|
||||
**Version**: 0.1437 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
+50
@@ -588,6 +588,50 @@ add_to_path() {
|
||||
success "Added to $profile - restart your shell or run: source $profile"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Screen Configuration
|
||||
# ============================================================================
|
||||
|
||||
setup_screenrc() {
|
||||
local screenrc="$HOME/.screenrc"
|
||||
|
||||
# Check if screenrc already exists and has our config
|
||||
if [[ -f "$screenrc" ]] && grep -q "mousetrack on" "$screenrc" 2>/dev/null; then
|
||||
info "Screen configuration already set up"
|
||||
return 0
|
||||
fi
|
||||
|
||||
info "Setting up screen configuration..."
|
||||
|
||||
# Backup existing screenrc if it exists
|
||||
if [[ -f "$screenrc" ]]; then
|
||||
cp "$screenrc" "${screenrc}.backup.$(date +%Y%m%d%H%M%S)"
|
||||
info "Backed up existing .screenrc"
|
||||
fi
|
||||
|
||||
# Create or append screen config
|
||||
cat >> "$screenrc" << 'EOF'
|
||||
|
||||
# ============================================================================
|
||||
# Claudeman screen configuration
|
||||
# ============================================================================
|
||||
|
||||
# Enable mouse tracking for scrolling in mobile terminals (Termius, etc.)
|
||||
mousetrack on
|
||||
|
||||
# Increase scrollback buffer (default is 100)
|
||||
defscrollback 10000
|
||||
|
||||
# Disable startup message
|
||||
startup_message off
|
||||
|
||||
# Enable alternate screen (better compatibility)
|
||||
altscreen on
|
||||
EOF
|
||||
|
||||
success "Screen configuration saved to $screenrc"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Systemd Service Setup (Linux only)
|
||||
# ============================================================================
|
||||
@@ -838,6 +882,12 @@ main() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# ========================================================================
|
||||
# Screen Configuration
|
||||
# ========================================================================
|
||||
|
||||
setup_screenrc
|
||||
|
||||
# ========================================================================
|
||||
# Systemd Service (Linux only)
|
||||
# ========================================================================
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1436",
|
||||
"version": "0.1437",
|
||||
"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",
|
||||
|
||||
+39
-18
@@ -140,34 +140,49 @@ find_full_session_id() {
|
||||
}
|
||||
|
||||
# Get session name from Claudeman state or screens.json
|
||||
# Falls back to working directory basename if name is empty
|
||||
get_session_name() {
|
||||
local session_id="$1"
|
||||
local name=""
|
||||
local workdir=""
|
||||
|
||||
# First, try screens.json (array format) - it has the name field
|
||||
# First, try screens.json (array format) - it has both name and workingDir
|
||||
if [ -f "$CLAUDEMAN_SCREENS" ]; then
|
||||
local name
|
||||
name=$(jq -r --arg id "$session_id" '
|
||||
.[] | select(.sessionId | startswith($id)) | .name // empty
|
||||
local result
|
||||
result=$(jq -r --arg id "$session_id" '
|
||||
.[] | select(.sessionId | startswith($id)) | "\(.name // "")\t\(.workingDir // "")"
|
||||
' "$CLAUDEMAN_SCREENS" 2>/dev/null | head -1)
|
||||
if [ -n "$name" ] && [ "$name" != "null" ]; then
|
||||
echo "$name"
|
||||
return
|
||||
if [ -n "$result" ]; then
|
||||
name="${result%% *}"
|
||||
workdir="${result#* }"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Try state.json with full session ID lookup
|
||||
if [ -f "$CLAUDEMAN_STATE" ]; then
|
||||
local name
|
||||
name=$(jq -r --arg id "$session_id" '
|
||||
.sessions | to_entries[] | select(.key | startswith($id)) | .value.name // empty
|
||||
# If no name yet, try state.json
|
||||
if [ -z "$name" ] && [ -f "$CLAUDEMAN_STATE" ]; then
|
||||
local result
|
||||
result=$(jq -r --arg id "$session_id" '
|
||||
.sessions | to_entries[] | select(.key | startswith($id)) | "\(.value.name // "")\t\(.value.workingDir // "")"
|
||||
' "$CLAUDEMAN_STATE" 2>/dev/null | head -1)
|
||||
if [ -n "$name" ] && [ "$name" != "null" ]; then
|
||||
echo "$name"
|
||||
return
|
||||
if [ -n "$result" ]; then
|
||||
name="${result%% *}"
|
||||
[ -z "$workdir" ] && workdir="${result#* }"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Fallback: use short ID
|
||||
# If we have a name, use it
|
||||
if [ -n "$name" ]; then
|
||||
echo "$name"
|
||||
return
|
||||
fi
|
||||
|
||||
# Fallback to working directory basename
|
||||
if [ -n "$workdir" ]; then
|
||||
echo "${workdir##*/}"
|
||||
return
|
||||
fi
|
||||
|
||||
# Last resort: use short ID
|
||||
echo "${session_id:0:8}"
|
||||
}
|
||||
|
||||
@@ -468,6 +483,7 @@ attach_screen() {
|
||||
local idx="$1"
|
||||
local pid="${SCREEN_PIDS[$idx]}"
|
||||
local name="${SCREEN_NAMES[$idx]}"
|
||||
local state="${SCREEN_STATES[$idx]}"
|
||||
|
||||
if [ -z "$pid" ]; then
|
||||
return 1
|
||||
@@ -478,8 +494,13 @@ attach_screen() {
|
||||
echo -e "${D}(Ctrl+A D to detach)${R}"
|
||||
sleep 0.3
|
||||
|
||||
# Attach
|
||||
screen -r "$pid.$name"
|
||||
# Use -x for attached screens (multi-display mode), -r for detached
|
||||
# -x allows joining a screen that's already attached elsewhere
|
||||
if [[ "$state" == "Attached" ]] || [[ "$state" == "Multi" ]]; then
|
||||
screen -x "$pid.$name"
|
||||
else
|
||||
screen -r "$pid.$name"
|
||||
fi
|
||||
|
||||
# After detach, return to chooser
|
||||
return 0
|
||||
|
||||
@@ -132,8 +132,13 @@ const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when ex
|
||||
const INTERNAL_AGENT_PATTERNS = [
|
||||
/^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode
|
||||
/^Suggest what user might/i, // Suggestion mode prompt variant
|
||||
/^aprompt$/i, // Internal prompt agent
|
||||
/^a\s?prompt/i, // Variants of internal prompt agent
|
||||
];
|
||||
|
||||
// Minimum description length - very short descriptions are likely internal or malformed
|
||||
const MIN_DESCRIPTION_LENGTH = 5;
|
||||
|
||||
// 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
|
||||
@@ -165,9 +170,12 @@ export class SubagentWatcher extends EventEmitter {
|
||||
/**
|
||||
* Check if a description matches internal Claude Code agent patterns.
|
||||
* These are not real user-initiated subagents and should be filtered out.
|
||||
* Also filters out very short descriptions that are likely internal or malformed.
|
||||
*/
|
||||
private isInternalAgent(description: string | undefined): boolean {
|
||||
if (!description) return false;
|
||||
// Filter out very short descriptions (likely internal or malformed)
|
||||
if (description.length < MIN_DESCRIPTION_LENGTH) return true;
|
||||
return INTERNAL_AGENT_PATTERNS.some(pattern => pattern.test(description));
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,19 @@ export interface SessionConfig {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Available session colors for visual differentiation
|
||||
*/
|
||||
export type SessionColor =
|
||||
| 'default'
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'yellow'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'pink';
|
||||
|
||||
/**
|
||||
* Current state of a session
|
||||
*/
|
||||
@@ -163,6 +176,8 @@ export interface SessionState {
|
||||
niceEnabled?: boolean;
|
||||
/** Nice value (-20 to 19) */
|
||||
niceValue?: number;
|
||||
/** User-assigned color for visual differentiation */
|
||||
color?: SessionColor;
|
||||
}
|
||||
|
||||
// ========== Global Stats Types ==========
|
||||
@@ -381,6 +396,26 @@ export interface RespawnConfig {
|
||||
aiPlanCheckCooldownMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Named respawn configuration preset for quick setup
|
||||
*/
|
||||
export interface RespawnPreset {
|
||||
/** Unique preset identifier */
|
||||
id: string;
|
||||
/** User-friendly preset name */
|
||||
name: string;
|
||||
/** Description of when to use this preset */
|
||||
description?: string;
|
||||
/** The respawn configuration (without enabled flag) */
|
||||
config: Omit<RespawnConfig, 'enabled'>;
|
||||
/** Duration in minutes (optional default) */
|
||||
durationMinutes?: number;
|
||||
/** Whether this is a built-in preset */
|
||||
builtIn?: boolean;
|
||||
/** Timestamp when created */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Application configuration
|
||||
*/
|
||||
|
||||
+4
-97
@@ -685,8 +685,6 @@ class ClaudemanApp {
|
||||
// SSE connection status tracking
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.lastDataReceived = Date.now();
|
||||
this.freshnessInterval = null;
|
||||
this.isOnline = navigator.onLine;
|
||||
|
||||
// Accessibility: Focus trap for modals
|
||||
@@ -781,8 +779,6 @@ class ClaudemanApp {
|
||||
this.startSystemStatsPolling();
|
||||
// Setup online/offline detection
|
||||
this.setupOnlineDetection();
|
||||
// Start freshness timer
|
||||
this.startFreshnessTimer();
|
||||
// Load server-stored settings (async, re-applies visibility after load)
|
||||
this.loadAppSettingsFromServer().then(() => {
|
||||
this.applyHeaderVisibilitySettings();
|
||||
@@ -1274,13 +1270,8 @@ class ClaudemanApp {
|
||||
// Store all event listeners for cleanup on reconnect
|
||||
const listeners = [];
|
||||
const addListener = (event, handler) => {
|
||||
// Wrap handler to mark data received on every SSE event
|
||||
const wrappedHandler = (e) => {
|
||||
this.markDataReceived();
|
||||
handler(e);
|
||||
};
|
||||
this.eventSource.addEventListener(event, wrappedHandler);
|
||||
listeners.push({ event, handler: wrappedHandler });
|
||||
this.eventSource.addEventListener(event, handler);
|
||||
listeners.push({ event, handler });
|
||||
};
|
||||
|
||||
// Create cleanup function to remove all listeners
|
||||
@@ -1295,7 +1286,6 @@ class ClaudemanApp {
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.lastDataReceived = Date.now();
|
||||
this.setConnectionStatus('connected');
|
||||
};
|
||||
this.eventSource.onerror = () => {
|
||||
@@ -2197,35 +2187,8 @@ class ClaudemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
setConnectionStatus(status) {
|
||||
const dot = document.querySelector('.status-dot');
|
||||
const text = document.querySelector('.status-text');
|
||||
if (!dot || !text) return;
|
||||
|
||||
dot.className = 'status-dot ' + status;
|
||||
|
||||
const statusLabels = {
|
||||
connected: 'Connected',
|
||||
connecting: 'Connecting...',
|
||||
reconnecting: `Reconnecting (${this.reconnectAttempts}/${this.maxReconnectAttempts})`,
|
||||
disconnected: 'Disconnected',
|
||||
offline: 'Offline'
|
||||
};
|
||||
text.textContent = statusLabels[status] || status;
|
||||
|
||||
// Update title with more details
|
||||
const container = document.getElementById('connectionStatus');
|
||||
if (container) {
|
||||
if (status === 'reconnecting') {
|
||||
container.title = `SSE reconnecting - attempt ${this.reconnectAttempts} of ${this.maxReconnectAttempts}`;
|
||||
} else if (status === 'disconnected') {
|
||||
container.title = 'SSE connection lost - max reconnection attempts reached';
|
||||
} else if (status === 'offline') {
|
||||
container.title = 'Browser is offline - waiting for network';
|
||||
} else {
|
||||
container.title = 'SSE connection status';
|
||||
}
|
||||
}
|
||||
setConnectionStatus(_status) {
|
||||
// Connection status UI removed - method kept for compatibility
|
||||
}
|
||||
|
||||
setupOnlineDetection() {
|
||||
@@ -2240,62 +2203,6 @@ class ClaudemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
startFreshnessTimer() {
|
||||
// Clear existing interval if any
|
||||
if (this.freshnessInterval) {
|
||||
clearInterval(this.freshnessInterval);
|
||||
}
|
||||
|
||||
// Update freshness display every second
|
||||
this.freshnessInterval = setInterval(() => {
|
||||
this.updateFreshnessDisplay();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
updateFreshnessDisplay() {
|
||||
const freshnessEl = document.getElementById('statusFreshness');
|
||||
if (!freshnessEl) return;
|
||||
|
||||
const dot = document.querySelector('.status-dot');
|
||||
const isConnected = dot?.classList.contains('connected');
|
||||
|
||||
// Only show freshness when connected
|
||||
if (!isConnected) {
|
||||
freshnessEl.textContent = '';
|
||||
freshnessEl.className = 'status-freshness';
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Math.floor((Date.now() - this.lastDataReceived) / 1000);
|
||||
|
||||
// Format the elapsed time
|
||||
let timeText;
|
||||
if (elapsed < 60) {
|
||||
timeText = `${elapsed}s ago`;
|
||||
} else if (elapsed < 3600) {
|
||||
const mins = Math.floor(elapsed / 60);
|
||||
timeText = `${mins}m ago`;
|
||||
} else {
|
||||
const hours = Math.floor(elapsed / 3600);
|
||||
timeText = `${hours}h ago`;
|
||||
}
|
||||
|
||||
freshnessEl.textContent = timeText;
|
||||
|
||||
// Apply staleness classes
|
||||
freshnessEl.className = 'status-freshness';
|
||||
if (elapsed > 30) {
|
||||
freshnessEl.classList.add('very-stale');
|
||||
} else if (elapsed > 10) {
|
||||
freshnessEl.classList.add('stale');
|
||||
}
|
||||
}
|
||||
|
||||
// Call this when any SSE event is received to update lastDataReceived
|
||||
markDataReceived() {
|
||||
this.lastDataReceived = Date.now();
|
||||
}
|
||||
|
||||
handleInit(data) {
|
||||
// Update version display
|
||||
if (data.version) {
|
||||
|
||||
+139
-17
@@ -13,6 +13,8 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Skip link for keyboard users -->
|
||||
<a href="#terminalContainer" class="skip-link">Skip to terminal</a>
|
||||
<div class="app">
|
||||
<!-- Compact Header with Session Tabs -->
|
||||
<header class="header">
|
||||
@@ -21,14 +23,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Session Tabs -->
|
||||
<div class="session-tabs" id="sessionTabs">
|
||||
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)">A-</button>
|
||||
<span class="font-size-display" id="fontSizeDisplay">14</span>
|
||||
<button class="btn-icon-header btn-sm" onclick="app.increaseFontSize()" title="Increase font (Ctrl++)">A+</button>
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
<span class="font-size-display" id="fontSizeDisplay" aria-label="Current font size">14</span>
|
||||
<button class="btn-icon-header btn-sm" onclick="app.increaseFontSize()" title="Increase font (Ctrl++)" aria-label="Increase font size">A+</button>
|
||||
</div>
|
||||
<div class="header-system-stats" id="headerSystemStats" title="System resource usage">
|
||||
<div class="stat-item">
|
||||
@@ -46,11 +48,11 @@
|
||||
<span class="stat-value" id="statMem">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notification-badge" id="notifBadge" style="display:none;">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-header btn-settings" onclick="app.openAppSettings()" title="App Settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
<button class="btn-icon-header btn-settings" onclick="app.openAppSettings()" title="App Settings" aria-label="Open app settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -62,7 +64,7 @@
|
||||
<div class="timer-progress"><div class="timer-progress-fill" id="timerProgress"></div></div>
|
||||
<span class="timer-meta" id="timerMeta">0 tasks | $0.00</span>
|
||||
</div>
|
||||
<button class="btn-icon-only" onclick="app.stopCurrentRun()" title="Stop">■</button>
|
||||
<button class="btn-icon-only" onclick="app.stopCurrentRun()" title="Stop" aria-label="Stop current run">■</button>
|
||||
</div>
|
||||
|
||||
<!-- Respawn Banner - Compact 2-column layout -->
|
||||
@@ -76,7 +78,7 @@
|
||||
<span class="respawn-cycles">#<span id="respawnCycleCount">0</span></span>
|
||||
<span class="respawn-timer" id="respawnTimer" style="display: none;"></span>
|
||||
<span class="respawn-tokens" id="respawnTokens" style="display: none;"></span>
|
||||
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn">■</button>
|
||||
<button class="btn-icon-only" onclick="app.stopRespawn()" title="Stop Respawn" aria-label="Stop respawn">■</button>
|
||||
</div>
|
||||
<div class="respawn-status-row2" id="respawnStatusRow2">
|
||||
<span class="detection-hook" id="detectionHook" style="display: none;"></span>
|
||||
@@ -309,7 +311,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Keyboard Shortcuts</h3>
|
||||
<button class="modal-close" onclick="app.closeHelp()">×</button>
|
||||
<button class="modal-close" onclick="app.closeHelp()" aria-label="Close help">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="shortcuts-grid">
|
||||
@@ -412,7 +414,7 @@
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Session Options</h3>
|
||||
<button class="modal-close" onclick="app.closeSessionOptions()">×</button>
|
||||
<button class="modal-close" onclick="app.closeSessionOptions()" aria-label="Close session options">×</button>
|
||||
</div>
|
||||
<!-- Modal Tabs -->
|
||||
<div class="modal-tabs">
|
||||
@@ -456,6 +458,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section-header">Presets</div>
|
||||
<div class="form-row">
|
||||
<label>Load Preset</label>
|
||||
<div class="preset-selector">
|
||||
<select id="respawnPresetSelect" onchange="app.updatePresetDescription()">
|
||||
<option value="">-- Select preset --</option>
|
||||
<optgroup label="Built-in" id="builtinPresetsGroup">
|
||||
</optgroup>
|
||||
<optgroup label="Custom" id="customPresetsGroup">
|
||||
</optgroup>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm" onclick="app.loadRespawnPreset()" title="Apply preset">Apply</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" onclick="app.saveCurrentAsPreset()" title="Save current config as preset">Save As...</button>
|
||||
</div>
|
||||
<p class="form-hint" id="presetDescriptionHint"></p>
|
||||
</div>
|
||||
|
||||
<div class="form-section-header">Respawn Cycle</div>
|
||||
<div class="form-row">
|
||||
<label>1. Update Prompt</label>
|
||||
@@ -492,6 +511,37 @@
|
||||
|
||||
<!-- Context Management Tab Content -->
|
||||
<div class="modal-tab-content hidden" id="context-tab">
|
||||
<div class="form-section-header">Appearance</div>
|
||||
<div class="form-row">
|
||||
<label>Session Color</label>
|
||||
<div class="color-picker" id="sessionColorPicker">
|
||||
<button type="button" class="color-swatch" data-color="default" title="Default">
|
||||
<span class="swatch-inner" style="background: var(--bg-hover)"></span>
|
||||
</button>
|
||||
<button type="button" class="color-swatch" data-color="red" title="Red">
|
||||
<span class="swatch-inner" style="background: var(--session-red)"></span>
|
||||
</button>
|
||||
<button type="button" class="color-swatch" data-color="orange" title="Orange">
|
||||
<span class="swatch-inner" style="background: var(--session-orange)"></span>
|
||||
</button>
|
||||
<button type="button" class="color-swatch" data-color="yellow" title="Yellow">
|
||||
<span class="swatch-inner" style="background: var(--session-yellow)"></span>
|
||||
</button>
|
||||
<button type="button" class="color-swatch" data-color="green" title="Green">
|
||||
<span class="swatch-inner" style="background: var(--session-green)"></span>
|
||||
</button>
|
||||
<button type="button" class="color-swatch" data-color="blue" title="Blue">
|
||||
<span class="swatch-inner" style="background: var(--session-blue)"></span>
|
||||
</button>
|
||||
<button type="button" class="color-swatch" data-color="purple" title="Purple">
|
||||
<span class="swatch-inner" style="background: var(--session-purple)"></span>
|
||||
</button>
|
||||
<button type="button" class="color-swatch" data-color="pink" title="Pink">
|
||||
<span class="swatch-inner" style="background: var(--session-pink)"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section-header">Token Management</div>
|
||||
<div class="context-settings-grid">
|
||||
<div class="context-setting">
|
||||
@@ -618,7 +668,7 @@
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>Close Session</h3>
|
||||
<button class="modal-close" onclick="app.cancelCloseSession()">×</button>
|
||||
<button class="modal-close" onclick="app.cancelCloseSession()" aria-label="Cancel close session">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-session-name" id="closeConfirmSessionName"></p>
|
||||
@@ -645,7 +695,7 @@
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>App Settings</h3>
|
||||
<button class="modal-close" onclick="app.closeAppSettings()">×</button>
|
||||
<button class="modal-close" onclick="app.closeAppSettings()" aria-label="Close app settings">×</button>
|
||||
</div>
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="settings-display">Display</button>
|
||||
@@ -879,6 +929,7 @@
|
||||
<span class="settings-status" id="notifPermissionStatus">?</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="form-hint" style="grid-column: 1 / -1; margin-top: -8px;">For remote access, HTTPS is required. Start with: <code>claudeman web --https</code></span>
|
||||
|
||||
<!-- Alerts Section -->
|
||||
<div class="settings-section-header">Alerts</div>
|
||||
@@ -923,6 +974,52 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Type Preferences -->
|
||||
<div class="settings-grid" style="margin-top: 12px;">
|
||||
<div class="settings-section-header">Per-Event Settings</div>
|
||||
</div>
|
||||
<div class="event-type-grid">
|
||||
<div class="event-header">Event</div>
|
||||
<div class="event-header">On</div>
|
||||
<div class="event-header">Browser</div>
|
||||
<div class="event-header">Sound</div>
|
||||
|
||||
<div class="event-label">Permission prompts</div>
|
||||
<input type="checkbox" id="eventPermissionEnabled" checked>
|
||||
<input type="checkbox" id="eventPermissionBrowser" checked>
|
||||
<input type="checkbox" id="eventPermissionAudio" checked>
|
||||
|
||||
<div class="event-label">Questions from Claude</div>
|
||||
<input type="checkbox" id="eventQuestionEnabled" checked>
|
||||
<input type="checkbox" id="eventQuestionBrowser" checked>
|
||||
<input type="checkbox" id="eventQuestionAudio" checked>
|
||||
|
||||
<div class="event-label">Session idle</div>
|
||||
<input type="checkbox" id="eventIdleEnabled" checked>
|
||||
<input type="checkbox" id="eventIdleBrowser" checked>
|
||||
<input type="checkbox" id="eventIdleAudio">
|
||||
|
||||
<div class="event-label">Response complete</div>
|
||||
<input type="checkbox" id="eventStopEnabled" checked>
|
||||
<input type="checkbox" id="eventStopBrowser">
|
||||
<input type="checkbox" id="eventStopAudio">
|
||||
|
||||
<div class="event-label">Respawn cycles</div>
|
||||
<input type="checkbox" id="eventRespawnEnabled" checked>
|
||||
<input type="checkbox" id="eventRespawnBrowser">
|
||||
<input type="checkbox" id="eventRespawnAudio">
|
||||
|
||||
<div class="event-label">Task complete</div>
|
||||
<input type="checkbox" id="eventRalphEnabled" checked>
|
||||
<input type="checkbox" id="eventRalphBrowser" checked>
|
||||
<input type="checkbox" id="eventRalphAudio" checked>
|
||||
|
||||
<div class="event-label">Subagent activity</div>
|
||||
<input type="checkbox" id="eventSubagentEnabled">
|
||||
<input type="checkbox" id="eventSubagentBrowser">
|
||||
<input type="checkbox" id="eventSubagentAudio">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
@@ -938,7 +1035,7 @@
|
||||
<div class="modal-content modal-lg">
|
||||
<div class="modal-header">
|
||||
<h3>Add Case</h3>
|
||||
<button class="modal-close" onclick="app.closeCreateCaseModal()">×</button>
|
||||
<button class="modal-close" onclick="app.closeCreateCaseModal()" aria-label="Close create case">×</button>
|
||||
</div>
|
||||
<div class="modal-tabs">
|
||||
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
|
||||
@@ -990,7 +1087,7 @@
|
||||
<span class="agent-count" id="wizardAgentCount">0</span>
|
||||
</button>
|
||||
<button class="btn-icon-only wizard-minimize-btn" id="wizardMinimizeBtn" onclick="app.minimizeRalphWizard()" title="Minimize wizard">‒</button>
|
||||
<button class="modal-close" onclick="app.closeRalphWizard()">×</button>
|
||||
<button class="modal-close" onclick="app.closeRalphWizard()" aria-label="Close Ralph wizard">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1187,7 +1284,7 @@
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>Kill All Sessions</h3>
|
||||
<button class="modal-close" onclick="app.closeKillAllModal()">×</button>
|
||||
<button class="modal-close" onclick="app.closeKillAllModal()" aria-label="Close kill all modal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-warning-text">Choose how to close <span id="killAllCount">0</span> session(s):</p>
|
||||
@@ -1228,7 +1325,7 @@
|
||||
<div class="modal-content token-stats-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Token Usage Statistics</h3>
|
||||
<button class="modal-close" onclick="app.closeTokenStats()">×</button>
|
||||
<button class="modal-close" onclick="app.closeTokenStats()" aria-label="Close token statistics">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="stats-summary" id="statsSummary"></div>
|
||||
@@ -1246,6 +1343,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Preset Modal -->
|
||||
<div class="modal" id="savePresetModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSavePresetModal()"></div>
|
||||
<div class="modal-content modal-sm">
|
||||
<div class="modal-header">
|
||||
<h3>Save Respawn Preset</h3>
|
||||
<button class="modal-close" onclick="app.closeSavePresetModal()" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<label>Preset Name</label>
|
||||
<input type="text" id="presetNameInput" placeholder="My Custom Preset" maxlength="30">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Description (optional)</label>
|
||||
<input type="text" id="presetDescriptionInput" placeholder="When to use this preset" maxlength="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" onclick="app.closeSavePresetModal()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="app.confirmSavePreset()">Save Preset</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SVG Overlay for Subagent Connection Lines -->
|
||||
<svg id="connectionLines" class="connection-lines-svg">
|
||||
<!-- Lines drawn dynamically -->
|
||||
|
||||
@@ -336,50 +336,6 @@ body {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.connection-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--yellow);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-dot.connected { background: var(--green); }
|
||||
.status-dot.disconnected { background: var(--red); }
|
||||
.status-dot.connecting {
|
||||
background: var(--yellow);
|
||||
animation: status-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
.status-dot.reconnecting {
|
||||
background: var(--orange, #f97316);
|
||||
animation: status-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
.status-dot.offline {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
@keyframes status-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.status-freshness {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-dim);
|
||||
margin-left: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-freshness.stale { color: var(--yellow); }
|
||||
.status-freshness.very-stale { color: var(--red); }
|
||||
|
||||
.header-tokens {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
/**
|
||||
* Cleanup fixture for E2E tests
|
||||
* Tracks and cleans up all resources (sessions, cases, screens)
|
||||
*
|
||||
* CRITICAL SAFETY: This fixture protects user screens by:
|
||||
* 1. Capturing pre-existing screens at MODULE LOAD time - these are NEVER killed
|
||||
* 2. Tracking screens created during tests - these ARE cleaned up
|
||||
* 3. Protecting current process screen ($CLAUDEMAN_SCREEN_NAME)
|
||||
*
|
||||
* KEY INSIGHT: The cleanup is based on WHEN screens were created, not naming patterns.
|
||||
* Screens that existed before this module loaded are user screens and protected.
|
||||
* Screens created after are test screens and will be cleaned up.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
@@ -8,6 +17,73 @@ import { existsSync, rmSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/**
|
||||
* Capture pre-existing screens at MODULE LOAD time (before any tests run).
|
||||
* These screens existed before tests started and must NEVER be killed.
|
||||
* This is the ONLY reliable way to distinguish user screens from test screens.
|
||||
*/
|
||||
const PRE_EXISTING_SCREENS: Set<string> = new Set();
|
||||
const CURRENT_SCREEN_NAME = process.env.CLAUDEMAN_SCREEN_NAME || '';
|
||||
|
||||
// Capture pre-existing screens immediately when this module loads
|
||||
try {
|
||||
const output = execSync('screen -ls 2>/dev/null || true', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
for (const line of output.split('\n')) {
|
||||
const match = line.match(/\d+\.([^\s]+)/);
|
||||
if (match) {
|
||||
PRE_EXISTING_SCREENS.add(match[1]);
|
||||
}
|
||||
}
|
||||
if (PRE_EXISTING_SCREENS.size > 0) {
|
||||
console.log(`[CleanupTracker] Protected ${PRE_EXISTING_SCREENS.size} pre-existing user screens`);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during capture
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a screen name matches user-created patterns (w1-*, s1-*, etc.)
|
||||
*/
|
||||
function isUserScreenPattern(screenName: string): boolean {
|
||||
// w1-, w2-, s1-, s2-, etc. prefixes are user session patterns
|
||||
return /^[ws]\d+-/.test(screenName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a screen name looks like a test screen (contains 'test' or 'e2e')
|
||||
*/
|
||||
function isE2ETestScreen(screenName: string): boolean {
|
||||
return screenName.includes('test') || screenName.includes('e2e');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a screen is protected (should NEVER be killed)
|
||||
*
|
||||
* CRITICAL: Protection is based on WHEN the screen was created, not naming.
|
||||
* - Screens in PRE_EXISTING_SCREENS existed before tests started = USER screens
|
||||
* - Current process screen is always protected
|
||||
* - User patterns (w1-*, s1-*) are protected as extra safety
|
||||
*/
|
||||
function isProtectedScreen(screenName: string): boolean {
|
||||
// Pre-existing screens are ALWAYS protected - this is the primary safeguard
|
||||
if (PRE_EXISTING_SCREENS.has(screenName)) {
|
||||
return true;
|
||||
}
|
||||
// Current process's screen is protected
|
||||
if (CURRENT_SCREEN_NAME && screenName === CURRENT_SCREEN_NAME) {
|
||||
return true;
|
||||
}
|
||||
// User-created screen patterns (w1-*, s1-*) are protected as extra safety
|
||||
if (isUserScreenPattern(screenName)) {
|
||||
return true;
|
||||
}
|
||||
// Everything else can be cleaned up (it was created after tests started)
|
||||
return false;
|
||||
}
|
||||
|
||||
export class CleanupTracker {
|
||||
private sessions: Set<string> = new Set();
|
||||
private cases: Set<string> = new Set();
|
||||
@@ -21,23 +97,30 @@ export class CleanupTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a session for cleanup
|
||||
* Track a session for cleanup.
|
||||
* Sessions are deleted via API which handles screen cleanup.
|
||||
*/
|
||||
trackSession(sessionId: string): void {
|
||||
this.sessions.add(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a case for cleanup
|
||||
* Track a case for cleanup.
|
||||
* SAFETY: Only e2e-test-* cases will be deleted during cleanup.
|
||||
*/
|
||||
trackCase(caseName: string): void {
|
||||
this.cases.add(caseName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a screen for cleanup
|
||||
* Track a screen for cleanup.
|
||||
* SAFETY: Protected screens (pre-existing) will be skipped during actual cleanup.
|
||||
*/
|
||||
trackScreen(screenName: string): void {
|
||||
// Still warn but allow tracking - actual protection happens at kill time
|
||||
if (isProtectedScreen(screenName)) {
|
||||
console.warn(`[CleanupTracker] WARNING: Tracking protected screen ${screenName} - will be skipped during cleanup`);
|
||||
}
|
||||
this.screens.add(screenName);
|
||||
}
|
||||
|
||||
@@ -57,8 +140,13 @@ export class CleanupTracker {
|
||||
}
|
||||
this.sessions.clear();
|
||||
|
||||
// Delete case directories
|
||||
// Delete case directories - ONLY e2e-test-* cases
|
||||
for (const caseName of this.cases) {
|
||||
// SAFETY: Double-check case name before deletion
|
||||
if (!caseName.startsWith('e2e-test-')) {
|
||||
console.warn(`[CleanupTracker] BLOCKED deletion of non-e2e-test case: ${caseName}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const casePath = join(this.casesDir, caseName);
|
||||
if (existsSync(casePath)) {
|
||||
@@ -79,9 +167,23 @@ export class CleanupTracker {
|
||||
|
||||
/**
|
||||
* Kill a specific screen session
|
||||
* SAFETY: Refuses to kill protected or non-e2e-test screens
|
||||
*/
|
||||
private killScreen(screenName: string): void {
|
||||
// CRITICAL SAFETY CHECK: Never kill protected screens
|
||||
if (isProtectedScreen(screenName)) {
|
||||
console.warn(`[CleanupTracker] BLOCKED attempt to kill protected screen: ${screenName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Double-check: only kill e2e-test screens
|
||||
if (!isE2ETestScreen(screenName)) {
|
||||
console.warn(`[CleanupTracker] BLOCKED attempt to kill non-e2e-test screen: ${screenName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`[CleanupTracker] Killing e2e-test screen: ${screenName}`);
|
||||
execSync(`screen -S ${screenName} -X quit 2>/dev/null`, {
|
||||
stdio: 'pipe',
|
||||
timeout: 5000,
|
||||
@@ -92,28 +194,35 @@ export class CleanupTracker {
|
||||
}
|
||||
|
||||
/**
|
||||
* Force cleanup ALL e2e-test-* cases and claudeman-* screens
|
||||
* Use this in afterAll to ensure no orphans remain
|
||||
* Force cleanup ALL test-created resources.
|
||||
* This cleans up:
|
||||
* - All tracked sessions (via API)
|
||||
* - All e2e-test-* case directories
|
||||
* - ALL claudeman screens created AFTER this module loaded (not in PRE_EXISTING_SCREENS)
|
||||
*/
|
||||
async forceCleanupAll(): Promise<void> {
|
||||
// First, clean tracked resources
|
||||
await this.cleanup();
|
||||
|
||||
// Force kill ALL e2e-test sessions via API
|
||||
// Delete ONLY e2e-test sessions via API
|
||||
// CRITICAL: NEVER delete sessions based on screen name - only explicit e2e-test markers
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/api/sessions`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.sessions) {
|
||||
for (const session of data.sessions) {
|
||||
if (session.name?.startsWith('e2e-test-') || session.workingDir?.includes('e2e-test-')) {
|
||||
try {
|
||||
await fetch(`${this.baseUrl}/api/sessions/${session.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
const sessions = Array.isArray(data) ? data : data.sessions || [];
|
||||
for (const session of sessions) {
|
||||
// ONLY delete sessions with explicit e2e-test markers
|
||||
// Never use screen name matching - it's not reliable
|
||||
const isTestSession = session.name?.includes('e2e-test') ||
|
||||
session.workingDir?.includes('e2e-test');
|
||||
if (isTestSession) {
|
||||
try {
|
||||
await fetch(`${this.baseUrl}/api/sessions/${session.id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,7 +250,9 @@ export class CleanupTracker {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Kill all e2e-test claudeman screens
|
||||
// Kill ONLY screens that explicitly contain 'test' in the name
|
||||
// CRITICAL: Never kill screens based on timing/pre-existing checks alone
|
||||
// This is the only safe approach - rely on explicit test naming
|
||||
try {
|
||||
const screenList = execSync('screen -ls 2>/dev/null || true', {
|
||||
encoding: 'utf-8',
|
||||
@@ -150,11 +261,14 @@ export class CleanupTracker {
|
||||
|
||||
const screenLines = screenList.split('\n');
|
||||
for (const line of screenLines) {
|
||||
// Match claudeman-e2e-test-* screens
|
||||
const match = line.match(/\d+\.(claudeman-e2e-test-[^\s]+)/);
|
||||
// Match any claudeman-* screen
|
||||
const match = line.match(/\d+\.(claudeman-[^\s]+)/);
|
||||
if (match) {
|
||||
const screenName = match[1];
|
||||
this.killScreen(screenName);
|
||||
// ONLY kill screens with explicit 'test' in the name
|
||||
if (screenName.includes('test') && !isProtectedScreen(screenName)) {
|
||||
this.killScreen(screenName);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -203,3 +317,6 @@ export class CleanupTracker {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export safety utilities for use in other test files
|
||||
export { PRE_EXISTING_SCREENS, isProtectedScreen, isE2ETestScreen, isUserScreenPattern };
|
||||
|
||||
@@ -91,9 +91,10 @@ describe('Ralph Loop Wizard E2E', () => {
|
||||
}
|
||||
}, E2E_TIMEOUTS.TEST);
|
||||
|
||||
it('should start Ralph Loop with claudeman-ios using initprompt.md', async () => {
|
||||
it('should start Ralph Loop with test case using initprompt.md', async () => {
|
||||
let browser: BrowserFixture | null = null;
|
||||
const caseName = 'claudeman-ios'; // Use existing case
|
||||
// CRITICAL: Use e2e-test- prefix to ensure cleanup safety
|
||||
const caseName = generateCaseName('ralph-wizard');
|
||||
const casePath = join(homedir(), 'claudeman-cases', caseName);
|
||||
|
||||
try {
|
||||
@@ -103,20 +104,14 @@ describe('Ralph Loop Wizard E2E', () => {
|
||||
if (!cleanup) {
|
||||
cleanup = new CleanupTracker(serverFixture.baseUrl);
|
||||
}
|
||||
cleanup.trackCase(caseName);
|
||||
|
||||
// Read the initprompt.md from the case
|
||||
const initPromptPath = join(casePath, 'initprompt.md');
|
||||
let taskDescription = 'Test task for Ralph Loop';
|
||||
if (existsSync(initPromptPath)) {
|
||||
taskDescription = readFileSync(initPromptPath, 'utf-8').trim();
|
||||
console.log('[RalphLoopE2E] Using initprompt.md:', taskDescription.slice(0, 100) + '...');
|
||||
} else {
|
||||
console.log('[RalphLoopE2E] initprompt.md not found, using default task');
|
||||
}
|
||||
// Use a test task description (not from a real user case)
|
||||
const taskDescription = 'Test task for Ralph Loop E2E: implement a simple hello world function';
|
||||
console.log('[RalphLoopE2E] Using test task:', taskDescription);
|
||||
|
||||
// Check if @fix_plan.md exists (for reuse)
|
||||
const fixPlanPath = join(casePath, '@fix_plan.md');
|
||||
const hasExistingPlan = existsSync(fixPlanPath);
|
||||
// No existing fix plan for test case
|
||||
const hasExistingPlan = false;
|
||||
console.log('[RalphLoopE2E] Existing @fix_plan.md:', hasExistingPlan);
|
||||
|
||||
browser = await createBrowserFixture();
|
||||
@@ -129,6 +124,29 @@ describe('Ralph Loop Wizard E2E', () => {
|
||||
await clickElement(page, '.btn-ralph');
|
||||
await page.waitForSelector('#ralphWizardModal.active', { timeout: E2E_TIMEOUTS.ELEMENT_VISIBLE });
|
||||
|
||||
// For test case, we need to create it first via API, then select it
|
||||
// Create the test case via quick-start API first
|
||||
const createRes = await fetch(`${serverFixture.baseUrl}/api/quick-start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName, mode: 'claude' }),
|
||||
});
|
||||
const createData = await createRes.json();
|
||||
if (createData.success && createData.sessionId) {
|
||||
cleanup.trackSession(createData.sessionId);
|
||||
// Delete the session but keep the case (we'll use it for Ralph wizard)
|
||||
await fetch(`${serverFixture.baseUrl}/api/sessions/${createData.sessionId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Refresh page to pick up the new case
|
||||
await page.reload();
|
||||
await page.waitForSelector('.btn-ralph', { timeout: E2E_TIMEOUTS.ELEMENT_VISIBLE });
|
||||
await clickElement(page, '.btn-ralph');
|
||||
await page.waitForSelector('#ralphWizardModal.active', { timeout: E2E_TIMEOUTS.ELEMENT_VISIBLE });
|
||||
|
||||
// Select case in dropdown
|
||||
const caseSelect = await page.$('#ralphCaseSelect');
|
||||
if (caseSelect) {
|
||||
@@ -201,7 +219,7 @@ describe('Ralph Loop Wizard E2E', () => {
|
||||
expect(Array.isArray(sessions)).toBe(true);
|
||||
expect(sessions.length).toBeGreaterThan(0);
|
||||
|
||||
// Find the NEWEST session for claudeman-ios (sort by createdAt descending)
|
||||
// Find the NEWEST session for our e2e-test case (sort by createdAt descending)
|
||||
const matchingSessions = sessions
|
||||
.filter((s: any) => s.workingDir?.includes(caseName))
|
||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
@@ -213,9 +231,11 @@ describe('Ralph Loop Wizard E2E', () => {
|
||||
expect(session).toBeDefined();
|
||||
cleanup.trackSession(session.id);
|
||||
|
||||
// Verify screen was created
|
||||
// Verify screen was created - note: screen name won't contain 'e2e-test' since it uses sessionId
|
||||
// This is fine - the CleanupTracker will refuse to track it but that's OK since
|
||||
// the session deletion via API will kill the screen anyway
|
||||
const screenName = `claudeman-${session.id.slice(0, 8)}`;
|
||||
cleanup.trackScreen(screenName);
|
||||
// Note: Don't track screen directly - let session deletion handle it
|
||||
const screenList = execSync('screen -ls 2>/dev/null || true', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
|
||||
+103
-2
@@ -6,7 +6,13 @@
|
||||
* - Tracked resource cleanup (only kills what tests create)
|
||||
* - Global beforeAll/afterAll hooks
|
||||
*
|
||||
* SAFETY: This setup ONLY cleans up resources that the test suite itself creates.
|
||||
* CRITICAL SAFETY GUARANTEES:
|
||||
* 1. Pre-existing screens (captured at MODULE LOAD) are NEVER killed
|
||||
* 2. Current process screen ($CLAUDEMAN_SCREEN_NAME) is NEVER killed
|
||||
* 3. Only screens explicitly registered via registerTestScreen() can be killed
|
||||
* 4. All screen names must pass validation before being accepted
|
||||
*
|
||||
* This setup ONLY cleans up resources that the test suite itself creates.
|
||||
* It will NEVER kill Claude processes or screens that weren't spawned by tests.
|
||||
* This makes it safe to run tests from within a Claudeman-managed session.
|
||||
*/
|
||||
@@ -27,12 +33,97 @@ const activeTestClaudePids = new Set<number>();
|
||||
let currentScreenCount = 0;
|
||||
const screenWaiters: Array<() => void> = [];
|
||||
|
||||
/**
|
||||
* CRITICAL: Pre-existing screens captured at MODULE LOAD time.
|
||||
* These screens existed before any test code ran and must NEVER be killed.
|
||||
* This is captured immediately when the module loads, not in beforeAll.
|
||||
*/
|
||||
const preExistingScreensAtModuleLoad = new Set<string>();
|
||||
|
||||
/** Current process's screen name - NEVER kill this */
|
||||
const CURRENT_PROCESS_SCREEN = process.env.CLAUDEMAN_SCREEN_NAME || '';
|
||||
|
||||
// Capture pre-existing screens IMMEDIATELY when this module loads
|
||||
// This happens before any test runs, providing maximum protection
|
||||
try {
|
||||
const output = execSync('screen -ls 2>/dev/null || true', { encoding: 'utf-8', timeout: 5000 });
|
||||
for (const line of output.split('\n')) {
|
||||
const match = line.match(/\d+\.([^\s]+)/);
|
||||
if (match) {
|
||||
preExistingScreensAtModuleLoad.add(match[1]);
|
||||
}
|
||||
}
|
||||
if (preExistingScreensAtModuleLoad.size > 0 || CURRENT_PROCESS_SCREEN) {
|
||||
console.log(`[Test Setup] MODULE LOAD: Protected ${preExistingScreensAtModuleLoad.size} pre-existing screens`);
|
||||
if (CURRENT_PROCESS_SCREEN) {
|
||||
console.log(`[Test Setup] MODULE LOAD: Current process screen: ${CURRENT_PROCESS_SCREEN}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors during capture
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a screen name matches user-created patterns (w1-*, s1-*)
|
||||
*/
|
||||
function isUserScreenPattern(screenName: string): boolean {
|
||||
return /^[ws]\d+-/.test(screenName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a screen name looks like a test screen (contains 'test')
|
||||
*/
|
||||
function isTestScreen(screenName: string): boolean {
|
||||
return screenName.toLowerCase().includes('test');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a screen is protected and must NEVER be killed.
|
||||
*
|
||||
* CRITICAL: Protection is based on WHEN the screen was created:
|
||||
* - Screens in preExistingScreensAtModuleLoad existed before tests = USER screens
|
||||
* - Current process screen is always protected
|
||||
* - User patterns (w1-*, s1-*) are protected as extra safety
|
||||
*/
|
||||
function isScreenProtected(screenName: string): boolean {
|
||||
// Pre-existing screens from module load are ALWAYS protected
|
||||
if (preExistingScreensAtModuleLoad.has(screenName)) {
|
||||
return true;
|
||||
}
|
||||
// Current process's screen is protected
|
||||
if (CURRENT_PROCESS_SCREEN && screenName === CURRENT_PROCESS_SCREEN) {
|
||||
return true;
|
||||
}
|
||||
// Also protect screens from preExistingScreens set (captured in beforeAll)
|
||||
if (preExistingScreens.has(screenName)) {
|
||||
return true;
|
||||
}
|
||||
// Protect user-created screen patterns (w1-*, s1-*) as extra safety
|
||||
if (isUserScreenPattern(screenName)) {
|
||||
return true;
|
||||
}
|
||||
// Everything else can be cleaned up
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill only the screens that tests have registered via registerTestScreen()
|
||||
* SAFETY: Only kills screens with 'test' in the name AND not protected
|
||||
*/
|
||||
function killTrackedTestScreens(): void {
|
||||
for (const screenName of activeTestScreens) {
|
||||
// CRITICAL: Only kill screens with explicit 'test' marker
|
||||
if (!screenName.includes('test')) {
|
||||
console.warn(`[Test Setup] SKIPPING: Screen ${screenName} doesn't contain 'test' - not killing`);
|
||||
continue;
|
||||
}
|
||||
// Double-check protection before killing
|
||||
if (isScreenProtected(screenName)) {
|
||||
console.warn(`[Test Setup] BLOCKED: Refusing to kill protected screen: ${screenName}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
console.log(`[Test Setup] Killing test screen: ${screenName}`);
|
||||
execSync(`screen -S ${screenName} -X quit 2>/dev/null || true`, { encoding: 'utf-8' });
|
||||
} catch {
|
||||
// Ignore errors
|
||||
@@ -100,9 +191,15 @@ export function releaseScreenSlot(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a screen session for tracking
|
||||
* Register a screen session for tracking.
|
||||
* SAFETY: Protected screens will be skipped at cleanup time.
|
||||
*/
|
||||
export function registerTestScreen(screenName: string): void {
|
||||
// Warn if registering a protected screen, but allow it
|
||||
// (protection happens at kill time, not registration time)
|
||||
if (isScreenProtected(screenName)) {
|
||||
console.warn(`[Test Setup] WARNING: Registering protected screen ${screenName} - will be skipped during cleanup`);
|
||||
}
|
||||
activeTestScreens.add(screenName);
|
||||
}
|
||||
|
||||
@@ -250,4 +347,8 @@ export {
|
||||
killTrackedTestScreens,
|
||||
killTrackedTestClaudeProcesses,
|
||||
MAX_CONCURRENT_SCREENS,
|
||||
isScreenProtected,
|
||||
isTestScreen,
|
||||
isUserScreenPattern,
|
||||
preExistingScreensAtModuleLoad,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user