From a6f380fde0aa400fc2f05f0357e938dff551f0d6 Mon Sep 17 00:00:00 2001 From: ispaydeu <31388982+ispaydeu@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:44:34 -0400 Subject: [PATCH] feat: active hours + idle detection gates for session-guardian (#413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add project cooldown log to prevent rapid observer re-spawn Adds session-guardian.sh, called by observer-loop.sh before each Haiku spawn. It reads ~/.claude/observer-last-run.log and blocks the cycle if the same project was observed within OBSERVER_INTERVAL_SECONDS (default 300s). Prevents self-referential loops where a spawned session triggers observe.sh, which signals the observer before the cooldown has elapsed. Uses a mkdir-based lock for safe concurrent access across multiple simultaneously-observed projects. Log entries use tab-delimited format to handle paths containing spaces. Fails open on lock contention. Config: OBSERVER_INTERVAL_SECONDS default: 300 OBSERVER_LAST_RUN_LOG default: ~/.claude/observer-last-run.log No external dependencies. Works on macOS, Linux, Windows (Git Bash/MSYS2). * feat: extend session-guardian with time window and idle detection gates Adds Gate 1 (active hours check) and Gate 3 (system idle detection) to session-guardian.sh, building on the per-project cooldown log from PR 1. Gate 1 — Time Window: - OBSERVER_ACTIVE_HOURS_START/END (default 800–2300 local time) - Uses date +%k%M with 10# prefix to avoid octal crash at midnight - Toolless on all platforms; set both vars to 0 to disable Gate 3 — Idle Detection: - macOS: ioreg + awk (built-in, no deps) - Linux: xprintidle if available, else fail open - Windows (Git Bash/MSYS2): PowerShell GetLastInputInfo via Add-Type - Unknown/headless: always returns 0 (fail open) - OBSERVER_MAX_IDLE_SECONDS=0 disables gate Fixes in this commit: - 10# base-10 prefix prevents octal arithmetic crash on midnight minutes containing digits 8 or 9 (e.g. 00:08 = "008" is invalid octal) - PowerShell output piped through tr -d '\r' to strip Windows CRLF; also uses [long] cast to avoid TickCount 32-bit overflow after 24 days - mktemp now uses log file directory instead of TMPDIR to ensure same-filesystem mv on Linux (atomic rename instead of copy+unlink) - mkdir -p failure exits 0 (fail open) rather than crashing under set -e - Numeric validation on last_spawn prevents arithmetic error on corrupt log Gate execution order: 1 (time, ~0ms) → 2 (cooldown, ~1ms) → 3 (idle, ~50ms) * fix: harden session guardian gates --------- Co-authored-by: Affaan Mustafa --- .../agents/observer-loop.sh | 6 + .../agents/session-guardian.sh | 150 ++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100755 skills/continuous-learning-v2/agents/session-guardian.sh diff --git a/skills/continuous-learning-v2/agents/observer-loop.sh b/skills/continuous-learning-v2/agents/observer-loop.sh index a0f655dd..b5db7264 100755 --- a/skills/continuous-learning-v2/agents/observer-loop.sh +++ b/skills/continuous-learning-v2/agents/observer-loop.sh @@ -38,6 +38,12 @@ analyze_observations() { return fi + # session-guardian: gate observer cycle (active hours, cooldown, idle detection) + if ! bash "$(dirname "$0")/session-guardian.sh"; then + echo "[$(date)] Observer cycle skipped by session-guardian" >> "$LOG_FILE" + return + fi + prompt_file="$(mktemp "${TMPDIR:-/tmp}/ecc-observer-prompt.XXXXXX")" cat > "$prompt_file" <&2 + exit 1 + fi +fi + +# ── Gate 2: Project Cooldown Log ───────────────────────────────────────────── +# Prevent the same project being observed faster than OBSERVER_INTERVAL_SECONDS. +# Key: PROJECT_DIR when provided by the observer, otherwise git root path. +# Uses mkdir-based lock for safe concurrent access. Skips the cycle on lock contention. +# stderr uses basename only — never prints the full absolute path. + +project_root="${PROJECT_DIR:-}" +if [ -z "$project_root" ] || [ ! -d "$project_root" ]; then + project_root="$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD")" +fi +project_name="$(basename "$project_root")" +now="$(date +%s)" + +mkdir -p "$(dirname "$LOG_PATH")" || { + echo "session-guardian: cannot create log dir, proceeding" >&2 + exit 0 +} + +_lock_dir="${LOG_PATH}.lock" +if ! mkdir "$_lock_dir" 2>/dev/null; then + # Another observer holds the lock — skip this cycle to avoid double-spawns + echo "session-guardian: log locked by concurrent process, skipping cycle" >&2 + exit 1 +else + trap 'rm -rf "$_lock_dir"' EXIT INT TERM + + last_spawn=0 + last_spawn=$(awk -F '\t' -v key="$project_root" '$1 == key { value = $2 } END { if (value != "") print value }' "$LOG_PATH" 2>/dev/null) || true + last_spawn="${last_spawn:-0}" + [[ "$last_spawn" =~ ^[0-9]+$ ]] || last_spawn=0 + + elapsed=$(( now - last_spawn )) + if [ "$elapsed" -lt "$INTERVAL" ]; then + rm -rf "$_lock_dir" + trap - EXIT INT TERM + echo "session-guardian: cooldown active for '${project_name}' (last spawn ${elapsed}s ago, interval ${INTERVAL}s)" >&2 + exit 1 + fi + + # Update log: remove old entry for this project, append new timestamp (tab-delimited) + tmp_log="$(mktemp "$(dirname "$LOG_PATH")/observer-last-run.XXXXXX")" + awk -F '\t' -v key="$project_root" '$1 != key' "$LOG_PATH" > "$tmp_log" 2>/dev/null || true + printf '%s\t%s\n' "$project_root" "$now" >> "$tmp_log" + mv "$tmp_log" "$LOG_PATH" + + rm -rf "$_lock_dir" + trap - EXIT INT TERM +fi + +# ── Gate 3: Idle Detection ──────────────────────────────────────────────────── +# Skip cycles when no user input received for too long. Fail open if idle time +# cannot be determined (Linux without xprintidle, headless, unknown OS). +# Set OBSERVER_MAX_IDLE_SECONDS=0 to disable this gate. + +get_idle_seconds() { + local _raw + case "$(uname -s)" in + Darwin) + _raw=$( { /usr/sbin/ioreg -c IOHIDSystem \ + | /usr/bin/awk '/HIDIdleTime/ {print int($NF/1000000000); exit}'; } \ + 2>/dev/null ) || true + printf '%s\n' "${_raw:-0}" | head -n1 + ;; + Linux) + if command -v xprintidle >/dev/null 2>&1; then + _raw=$(xprintidle 2>/dev/null) || true + echo $(( ${_raw:-0} / 1000 )) + else + echo 0 # fail open: xprintidle not installed + fi + ;; + *MINGW*|*MSYS*|*CYGWIN*) + _raw=$(powershell.exe -NoProfile -NonInteractive -Command \ + "try { \ + Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO p); [StructLayout(LayoutKind.Sequential)] public struct LASTINPUTINFO { public uint cbSize; public int dwTime; }' -Name WinAPI -Namespace PInvoke; \ + \$l = New-Object PInvoke.WinAPI+LASTINPUTINFO; \$l.cbSize = 8; \ + [PInvoke.WinAPI]::GetLastInputInfo([ref]\$l) | Out-Null; \ + [int][Math]::Max(0, [long]([Environment]::TickCount - [long]\$l.dwTime) / 1000) \ + } catch { 0 }" \ + 2>/dev/null | tr -d '\r') || true + printf '%s\n' "${_raw:-0}" | head -n1 + ;; + *) + echo 0 # fail open: unknown platform + ;; + esac +} + +if [ "$MAX_IDLE" -gt 0 ]; then + idle_seconds=$(get_idle_seconds) + if [ "$idle_seconds" -gt "$MAX_IDLE" ]; then + echo "session-guardian: user idle ${idle_seconds}s (threshold ${MAX_IDLE}s), skipping" >&2 + exit 1 + fi +fi + +exit 0