diff --git a/.agents/configs/aidevops-config.schema.json b/.agents/configs/aidevops-config.schema.json index 3b6d147ea..b90f11abb 100644 --- a/.agents/configs/aidevops-config.schema.json +++ b/.agents/configs/aidevops-config.schema.json @@ -123,6 +123,12 @@ "default": "haiku", "enum": ["haiku", "flash", "sonnet", "pro", "opus"], "description": "Model tier used for verification checks." + }, + "max_interactive_sessions": { + "type": "integer", + "default": 5, + "minimum": 0, + "description": "Maximum concurrent interactive AI sessions before warning. 0 disables. Env: AIDEVOPS_MAX_SESSIONS" } }, "additionalProperties": false diff --git a/.agents/configs/aidevops.defaults.jsonc b/.agents/configs/aidevops.defaults.jsonc index 35774ba20..61d96af1c 100644 --- a/.agents/configs/aidevops.defaults.jsonc +++ b/.agents/configs/aidevops.defaults.jsonc @@ -107,7 +107,13 @@ "verification_enabled": true, // Model tier used for verification checks (cheapest sufficient tier). - "verification_tier": "haiku" + "verification_tier": "haiku", + + // Maximum number of concurrent interactive AI sessions before warning. + // Counts processes matching known AI coding assistants (opencode, claude, etc.). + // Set to 0 to disable the session count check. + // Env override: AIDEVOPS_MAX_SESSIONS + "max_interactive_sessions": 5 }, // --------------------------------------------------------------------------- diff --git a/.agents/scripts/aidevops-update-check.sh b/.agents/scripts/aidevops-update-check.sh index c2865149c..26d0a98c8 100755 --- a/.agents/scripts/aidevops-update-check.sh +++ b/.agents/scripts/aidevops-update-check.sh @@ -247,6 +247,15 @@ main() { echo "$nudge_output" fi + # Check for excessive concurrent interactive sessions (t1398.4) + local session_warning="" + if [[ -x "${script_dir}/session-count-helper.sh" ]]; then + session_warning="$("${script_dir}/session-count-helper.sh" check 2>/dev/null || true)" + fi + if [[ -n "$session_warning" ]]; then + echo "$session_warning" + fi + # Cache output for agents without Bash (e.g., Plan+) local cache_dir="$HOME/.aidevops/cache" mkdir -p "$cache_dir" @@ -254,6 +263,7 @@ main() { echo "$output" [[ -n "$runtime_hint" ]] && echo "$runtime_hint" [[ -n "$nudge_output" ]] && echo "$nudge_output" + [[ -n "$session_warning" ]] && echo "$session_warning" } >"$cache_dir/session-greeting.txt" return 0 diff --git a/.agents/scripts/pre-edit-check.sh b/.agents/scripts/pre-edit-check.sh index 9f241835c..0f8c0b163 100755 --- a/.agents/scripts/pre-edit-check.sh +++ b/.agents/scripts/pre-edit-check.sh @@ -417,6 +417,14 @@ else run_operation_verification "$VERIFY_OP" fi + # Session count warning (t1398.4) — non-blocking, informational only + if [[ -x "$SCRIPT_DIR/session-count-helper.sh" ]]; then + session_warning=$("$SCRIPT_DIR/session-count-helper.sh" check 2>/dev/null || true) + if [[ -n "$session_warning" ]]; then + echo -e "${YELLOW}${session_warning}${NC}" + fi + fi + echo -e "${GREEN}OK${NC} - On branch: ${BOLD}$current_branch${NC} (in worktree)" exit 0 fi diff --git a/.agents/scripts/session-count-helper.sh b/.agents/scripts/session-count-helper.sh new file mode 100755 index 000000000..0b80367f0 --- /dev/null +++ b/.agents/scripts/session-count-helper.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +# ============================================================================= +# Session Count Helper (t1398.4) +# ============================================================================= +# Counts concurrent interactive AI coding sessions and warns when the count +# exceeds a configurable threshold (default: 5). +# +# Interactive sessions are AI coding assistants running in a terminal (TUI), +# as opposed to headless workers dispatched via `opencode run` or `claude -p`. +# +# Usage: +# session-count-helper.sh count # Print session count +# session-count-helper.sh check # Check against threshold, warn if exceeded +# session-count-helper.sh list # List detected sessions with details +# session-count-helper.sh help # Show usage +# +# Configuration: +# Config key: safety.max_interactive_sessions (default: 5, 0 = disabled) +# Env override: AIDEVOPS_MAX_SESSIONS +# +# Exit codes: +# 0 - OK (count within threshold, or check disabled) +# 1 - Warning (count exceeds threshold) +# 2 - Error (invalid usage) +# ============================================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Source shared constants for config_get, colors, logging +# shellcheck source=shared-constants.sh +source "${SCRIPT_DIR}/shared-constants.sh" + +# ============================================================================= +# Session Detection +# ============================================================================= +# Detects interactive AI coding sessions by examining running processes. +# Distinguishes interactive (TUI) sessions from headless workers. +# +# Known AI coding assistants and their process signatures: +# opencode (interactive): .opencode (no "run" argument in cmdline) +# opencode (headless): .opencode run ... (has "run" in cmdline) +# claude (interactive): claude (no "-p" or "--print" in cmdline) +# claude (headless): claude -p ... or claude --print ... +# cursor: Cursor process +# windsurf: Windsurf process +# aider: aider (Python process) + +# Get the configured maximum session count. +# Priority: env var > JSONC config > default (5) +get_max_sessions() { + # Environment variable override (highest priority) + if [[ -n "${AIDEVOPS_MAX_SESSIONS:-}" ]]; then + echo "$AIDEVOPS_MAX_SESSIONS" + return 0 + fi + + # JSONC config system + if type config_get &>/dev/null; then + local val + val=$(config_get "safety.max_interactive_sessions" "5") + echo "$val" + return 0 + fi + + # Default + echo "5" + return 0 +} + +# Count interactive AI sessions. +# Returns the count on stdout. +# Uses pgrep + /proc/cmdline (Linux) or ps (macOS) to distinguish +# interactive from headless sessions. +count_interactive_sessions() { + local count=0 + + # --- OpenCode sessions --- + # Interactive opencode: the .opencode binary running WITHOUT "run" as + # the first argument after the binary name. + # Headless: .opencode run ... + local opencode_pids="" + opencode_pids=$(pgrep -f '\.opencode' 2>/dev/null || true) + + if [[ -n "$opencode_pids" ]]; then + local pid + while IFS= read -r pid; do + [[ -z "$pid" ]] && continue + local cmdline="" + if [[ -r "/proc/${pid}/cmdline" ]]; then + # Linux: /proc/PID/cmdline has null-separated args + cmdline=$(tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true) + else + # macOS fallback: ps -o args= + cmdline=$(ps -o args= -p "$pid" 2>/dev/null || true) + fi + + # Skip if this is a headless worker (has "run" after .opencode) + # Also skip language servers and helper processes + if echo "$cmdline" | grep -qE '\.opencode run '; then + continue + fi + # Skip language servers spawned by opencode + if echo "$cmdline" | grep -qE '(typescript-language-server|eslintServer|vscode-)'; then + continue + fi + # Skip node wrapper processes (the actual .opencode binary is what matters) + if echo "$cmdline" | grep -qE '^node .*/bin/opencode'; then + continue + fi + # This is an interactive opencode session + count=$((count + 1)) + done <<<"$opencode_pids" + fi + + # --- Claude Code sessions --- + # Interactive claude: the claude binary WITHOUT "-p", "--print", or "run" + local claude_pids="" + claude_pids=$(pgrep -x claude 2>/dev/null || true) + + if [[ -n "$claude_pids" ]]; then + local pid + while IFS= read -r pid; do + [[ -z "$pid" ]] && continue + local cmdline="" + if [[ -r "/proc/${pid}/cmdline" ]]; then + cmdline=$(tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true) + else + cmdline=$(ps -o args= -p "$pid" 2>/dev/null || true) + fi + + # Skip headless modes + if echo "$cmdline" | grep -qE 'claude (-p|--print|run) '; then + continue + fi + count=$((count + 1)) + done <<<"$claude_pids" + fi + + # --- Cursor sessions --- + local cursor_count=0 + cursor_count=$(pgrep -c -f 'Cursor\.app' 2>/dev/null || true) + count=$((count + cursor_count)) + + # --- Windsurf sessions --- + local windsurf_count=0 + windsurf_count=$(pgrep -c -f 'Windsurf' 2>/dev/null || true) + count=$((count + windsurf_count)) + + # --- Aider sessions --- + local aider_count=0 + aider_count=$(pgrep -c -f 'aider' 2>/dev/null || true) + count=$((count + aider_count)) + + echo "$count" + return 0 +} + +# List detected interactive sessions with details. +# Output format: PID | APP | RSS_MB | UPTIME | DIR +list_sessions() { + local found=0 + + # --- OpenCode sessions --- + local opencode_pids="" + opencode_pids=$(pgrep -f '\.opencode' 2>/dev/null || true) + + if [[ -n "$opencode_pids" ]]; then + local pid + while IFS= read -r pid; do + [[ -z "$pid" ]] && continue + local cmdline="" + if [[ -r "/proc/${pid}/cmdline" ]]; then + cmdline=$(tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true) + else + cmdline=$(ps -o args= -p "$pid" 2>/dev/null || true) + fi + + # Skip headless, language servers, node wrappers + if echo "$cmdline" | grep -qE '\.opencode run '; then + continue + fi + if echo "$cmdline" | grep -qE '(typescript-language-server|eslintServer|vscode-)'; then + continue + fi + if echo "$cmdline" | grep -qE '^node .*/bin/opencode'; then + continue + fi + + local rss_mb etime + rss_mb=$(ps -o rss= -p "$pid" 2>/dev/null || echo "0") + rss_mb=$((${rss_mb:-0} / 1024)) + etime=$(ps -o etime= -p "$pid" 2>/dev/null || echo "unknown") + etime=$(echo "$etime" | tr -d ' ') + + echo " PID ${pid} | OpenCode | ${rss_mb} MB | uptime: ${etime}" + found=$((found + 1)) + done <<<"$opencode_pids" + fi + + # --- Claude Code sessions --- + local claude_pids="" + claude_pids=$(pgrep -x claude 2>/dev/null || true) + + if [[ -n "$claude_pids" ]]; then + local pid + while IFS= read -r pid; do + [[ -z "$pid" ]] && continue + local cmdline="" + if [[ -r "/proc/${pid}/cmdline" ]]; then + cmdline=$(tr '\0' ' ' <"/proc/${pid}/cmdline" 2>/dev/null || true) + else + cmdline=$(ps -o args= -p "$pid" 2>/dev/null || true) + fi + + if echo "$cmdline" | grep -qE 'claude (-p|--print|run) '; then + continue + fi + + local rss_mb etime + rss_mb=$(ps -o rss= -p "$pid" 2>/dev/null || echo "0") + rss_mb=$((${rss_mb:-0} / 1024)) + etime=$(ps -o etime= -p "$pid" 2>/dev/null || echo "unknown") + etime=$(echo "$etime" | tr -d ' ') + + echo " PID ${pid} | Claude Code | ${rss_mb} MB | uptime: ${etime}" + found=$((found + 1)) + done <<<"$claude_pids" + fi + + if [[ "$found" -eq 0 ]]; then + echo " No interactive AI sessions detected" + fi + + return 0 +} + +# Check session count against threshold and output a warning if exceeded. +# Returns 0 if within threshold, 1 if exceeded. +check_sessions() { + local max_sessions + max_sessions=$(get_max_sessions) + + # Disabled if max is 0 + if [[ "$max_sessions" -eq 0 ]]; then + return 0 + fi + + local session_count + session_count=$(count_interactive_sessions) + + if [[ "$session_count" -gt "$max_sessions" ]]; then + echo "SESSION_WARNING: ${session_count} interactive AI sessions detected (threshold: ${max_sessions}). Consider closing unused sessions to reduce memory pressure (~100-400 MB each)." + return 1 + fi + + return 0 +} + +# ============================================================================= +# CLI Interface +# ============================================================================= + +show_help() { + echo "Usage: $(basename "$0") " + echo "" + echo "Commands:" + echo " count Print the number of interactive AI sessions" + echo " check Check against threshold, warn if exceeded (exit 1)" + echo " list List detected sessions with PID, app, RSS, uptime" + echo " help Show this help" + echo "" + echo "Configuration:" + echo " Config key: safety.max_interactive_sessions (default: 5)" + echo " Env override: AIDEVOPS_MAX_SESSIONS (0 = disabled)" + return 0 +} + +main() { + local command="${1:-check}" + + case "$command" in + count) + count_interactive_sessions + ;; + check) + check_sessions + ;; + list) + echo "Interactive AI sessions:" + list_sessions + echo "" + echo "Total: $(count_interactive_sessions) interactive | Threshold: $(get_max_sessions)" + ;; + help | --help | -h) + show_help + ;; + *) + print_error "Unknown command: $command" + show_help + return 2 + ;; + esac +} + +main "$@" diff --git a/.agents/scripts/shared-constants.sh b/.agents/scripts/shared-constants.sh index 0bf333a3f..0834ce813 100755 --- a/.agents/scripts/shared-constants.sh +++ b/.agents/scripts/shared-constants.sh @@ -1327,6 +1327,7 @@ _ft_env_map() { repo_sync) echo "AIDEVOPS_REPO_SYNC" ;; openclaw_auto_update) echo "AIDEVOPS_OPENCLAW_AUTO_UPDATE" ;; openclaw_freshness_hours) echo "AIDEVOPS_OPENCLAW_FRESHNESS_HOURS" ;; + max_interactive_sessions) echo "AIDEVOPS_MAX_SESSIONS" ;; *) echo "" ;; esac return 0