#!/usr/bin/env bash # # monitor-skill.sh - Skill monitor for humanize # # Provides the _humanize_monitor_skill function for monitoring # skill invocations (ask-codex, ask-gemini) from .humanize/skill directory. # # This file is sourced by humanize.sh and depends on: # - monitor-common.sh (monitor_get_yaml_value, monitor_format_timestamp, etc.) # - humanize.sh (humanize_split_to_array) # Monitor skill invocations from .humanize/skill # Shows a fixed status bar with aggregate stats and latest invocation details, # with live output display in the scrollable area below. # # Accepts --tool-filter to show only invocations from a # specific tool. Without the filter, all invocations are shown. _humanize_monitor_skill() { # Enable 0-indexed arrays in zsh for bash compatibility # no_monitor suppresses background job notifications ([1] PID) [[ -n "${ZSH_VERSION:-}" ]] && setopt localoptions ksharrays no_monitor local skill_dir=".humanize/skill" local current_skill_dir="" local current_file="" local check_interval=2 local status_bar_height=9 local once_mode=false local tool_filter="" # Parse arguments while [[ $# -gt 0 ]]; do case "$1" in --once) once_mode=true; shift ;; --tool-filter) tool_filter="${2:-}" shift 2 ;; *) shift ;; esac done # Check if .humanize/skill exists if [[ ! -d "$skill_dir" ]]; then echo "Error: $skill_dir directory not found in current directory" echo "Run /humanize:ask-codex or /humanize:ask-gemini first to create skill invocations" return 1 fi # Determine the tool for a given invocation directory. # Reads metadata.md first (completed), falls back to input.md (running). # Returns: codex, gemini, or unknown _skill_get_tool() { local dir="$1" if [[ -f "$dir/metadata.md" ]]; then local t=$(monitor_get_yaml_value "tool" "$dir/metadata.md") [[ -n "$t" ]] && { echo "$t"; return; } fi if [[ -f "$dir/input.md" ]]; then local t=$(grep -E '^- Tool:' "$dir/input.md" 2>/dev/null | sed 's/- Tool: //') [[ -n "$t" ]] && { echo "$t"; return; } fi echo "unknown" } # Check whether a directory passes the current tool filter. # Returns 0 (pass) or 1 (skip). _skill_passes_filter() { [[ -z "$tool_filter" ]] && return 0 local t=$(_skill_get_tool "$1") [[ "$t" == "$tool_filter" ]] && return 0 # Legacy invocations without a tool tag are treated as codex [[ "$t" == "unknown" && "$tool_filter" == "codex" ]] && return 0 return 1 } # List all valid skill invocation directories sorted newest-first # Skill dirs use YYYY-MM-DD_HH-MM-SS or YYYY-MM-DD_HH-MM-SS-PID-RANDOM naming _skill_list_dirs_sorted() { local dirs=() while IFS= read -r d; do [[ -z "$d" ]] && continue [[ ! -d "$d" ]] && continue local name=$(basename "$d") [[ "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2} ]] || continue _skill_passes_filter "$d" || continue dirs+=("$d") done < <(find "$skill_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null) printf '%s\n' "${dirs[@]}" | sort -r } # Find latest skill invocation directory (by timestamp in name) _skill_find_latest_dir() { _skill_list_dirs_sorted | head -1 } # Find the best invocation to monitor: newest with watchable content. # Falls back to the absolute newest if nothing has content. # Returns: dir|file (pipe-delimited pair) _skill_find_best_invocation() { local best_dir="" best_file="" while IFS= read -r d; do [[ -z "$d" ]] && continue local f=$(_skill_find_monitored_file "$d") if [[ -n "$f" && -s "$f" ]]; then best_dir="$d"; best_file="$f" break fi done < <(_skill_list_dirs_sorted) # Fallback to absolute newest even if no content if [[ -z "$best_dir" ]]; then best_dir=$(_skill_find_latest_dir) [[ -n "$best_dir" ]] && best_file=$(_skill_find_monitored_file "$best_dir") fi echo "${best_dir}|${best_file}" } # Count invocations by status # Returns: total|success|error|timeout|empty|running _skill_count_stats() { local total=0 success=0 err=0 tmo=0 empty=0 running=0 while IFS= read -r d; do [[ -z "$d" ]] && continue [[ ! -d "$d" ]] && continue local name=$(basename "$d") [[ ! "$name" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2} ]] && continue _skill_passes_filter "$d" || continue ((total++)) if [[ -f "$d/metadata.md" ]]; then local st=$(monitor_get_yaml_value "status" "$d/metadata.md") case "$st" in success) ((success++)) ;; error) ((err++)) ;; timeout) ((tmo++)) ;; empty_response) ((empty++)) ;; esac else ((running++)) fi done < <(find "$skill_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null) echo "$total|$success|$err|$tmo|$empty|$running" } # Extract question text from input.md _skill_get_question() { local dir="$1" [[ ! -f "$dir/input.md" ]] && echo "N/A" && return local q=$(sed -n '/^## Question$/,/^## /p' "$dir/input.md" \ | grep -v '^##' | grep -v '^$' | head -1 | sed 's/^[[:space:]]*//') echo "${q:-N/A}" } # Find the global cache directory for a skill invocation (display only) # Returns the ~/.cache/humanize/... path if it exists, empty otherwise. _skill_find_cache_dir() { local unique_id=$(basename "$1") local project_root=$(git rev-parse --show-toplevel 2>/dev/null || pwd) local sanitized=$(echo "$project_root" | sed 's/[^a-zA-Z0-9._-]/-/g' | sed 's/--*/-/g') local cache_base="${XDG_CACHE_HOME:-$HOME/.cache}" local cache_dir="$cache_base/humanize/$sanitized/skill-$unique_id" [[ -d "$cache_dir" ]] && echo "$cache_dir" || echo "" } # Find the best file to monitor for a skill invocation # Searches both global cache (~/.cache/humanize/), local cache ($dir/cache/), # and project-local files (.humanize/skill/) for the best content. # Supports both codex (codex-run.*) and gemini (gemini-run.*) cache files. _skill_find_monitored_file() { local dir="$1" local gcache=$(_skill_find_cache_dir "$dir") local lcache="$dir/cache" local is_running=false [[ ! -f "$dir/metadata.md" ]] && is_running=true # Determine which tool produced this invocation for cache file naming local inv_tool=$(_skill_get_tool "$dir") local run_prefix="codex-run" [[ "$inv_tool" == "gemini" ]] && run_prefix="gemini-run" # Helper: check a cache directory for best file # Args: cache_dir, prefer_log (true for running, false for completed) _check_cache_files() { local c="$1" prefer_log="$2" [[ ! -d "$c" ]] && return if [[ "$prefer_log" == "true" ]]; then [[ -f "$c/${run_prefix}.log" && -s "$c/${run_prefix}.log" ]] && { echo "$c/${run_prefix}.log"; return; } [[ -f "$c/${run_prefix}.out" && -s "$c/${run_prefix}.out" ]] && { echo "$c/${run_prefix}.out"; return; } [[ -f "$c/${run_prefix}.log" ]] && { echo "$c/${run_prefix}.log"; return; } # Fallback: try the other prefix for legacy/mixed invocations [[ -f "$c/codex-run.log" && -s "$c/codex-run.log" ]] && { echo "$c/codex-run.log"; return; } [[ -f "$c/gemini-run.log" && -s "$c/gemini-run.log" ]] && { echo "$c/gemini-run.log"; return; } else [[ -f "$c/${run_prefix}.out" && -s "$c/${run_prefix}.out" ]] && { echo "$c/${run_prefix}.out"; return; } [[ -f "$c/${run_prefix}.log" && -s "$c/${run_prefix}.log" ]] && { echo "$c/${run_prefix}.log"; return; } # Fallback [[ -f "$c/codex-run.out" && -s "$c/codex-run.out" ]] && { echo "$c/codex-run.out"; return; } [[ -f "$c/gemini-run.out" && -s "$c/gemini-run.out" ]] && { echo "$c/gemini-run.out"; return; } fi } if [[ "$is_running" == "true" ]]; then # Running: prefer cache logs (stderr has live progress) local f f=$(_check_cache_files "$gcache" true); [[ -n "$f" ]] && { echo "$f"; return; } f=$(_check_cache_files "$lcache" true); [[ -n "$f" ]] && { echo "$f"; return; } [[ -f "$dir/input.md" ]] && { echo "$dir/input.md"; return; } else # Completed: prefer output.md with content, then cache files [[ -f "$dir/output.md" && -s "$dir/output.md" ]] && { echo "$dir/output.md"; return; } local f f=$(_check_cache_files "$gcache" false); [[ -n "$f" ]] && { echo "$f"; return; } f=$(_check_cache_files "$lcache" false); [[ -n "$f" ]] && { echo "$f"; return; } [[ -f "$dir/output.md" ]] && { echo "$dir/output.md"; return; } fi echo "" } # Build the monitor title based on filter _skill_monitor_title() { case "$tool_filter" in codex) echo " Humanize Skill Monitor [codex]" ;; gemini) echo " Humanize Skill Monitor [gemini]" ;; *) echo " Humanize Skill Monitor" ;; esac } # Draw the status bar at the top _skill_draw_status_bar() { local latest_dir="$1" local monitored_file="$2" local term_width=$(tput cols) # ANSI colors local green="\033[1;32m" yellow="\033[1;33m" cyan="\033[1;36m" local magenta="\033[1;35m" red="\033[1;31m" reset="\033[0m" local bg="\033[44m" bold="\033[1m" dim="\033[2m" local clr_eol="\033[K" # Aggregate stats local -a stats humanize_split_to_array stats "$(_skill_count_stats)" local total="${stats[0]}" success="${stats[1]}" err="${stats[2]}" local tmo="${stats[3]}" empty="${stats[4]}" running="${stats[5]}" # Parse latest invocation metadata local inv_status="running" model="N/A" effort="N/A" duration="N/A" started_at="N/A" local inv_tool="unknown" if [[ -n "$latest_dir" && -f "$latest_dir/metadata.md" ]]; then inv_status=$(monitor_get_yaml_value "status" "$latest_dir/metadata.md") model=$(monitor_get_yaml_value "model" "$latest_dir/metadata.md") effort=$(monitor_get_yaml_value "effort" "$latest_dir/metadata.md") duration=$(monitor_get_yaml_value "duration" "$latest_dir/metadata.md") started_at=$(monitor_get_yaml_value "started_at" "$latest_dir/metadata.md") inv_tool=$(monitor_get_yaml_value "tool" "$latest_dir/metadata.md") elif [[ -n "$latest_dir" && -f "$latest_dir/input.md" ]]; then model=$(grep -E '^- Model:' "$latest_dir/input.md" 2>/dev/null | sed 's/- Model: //') effort=$(grep -E '^- Effort:' "$latest_dir/input.md" 2>/dev/null | sed 's/- Effort: //') inv_tool=$(grep -E '^- Tool:' "$latest_dir/input.md" 2>/dev/null | sed 's/- Tool: //') fi inv_status="${inv_status:-unknown}"; model="${model:-N/A}"; effort="${effort:-N/A}" inv_tool="${inv_tool:-unknown}" # Status color local status_color="$dim" case "$inv_status" in success) status_color="$green" ;; error|timeout) status_color="$red" ;; empty_response) status_color="$yellow" ;; running) status_color="$yellow" ;; esac # Question (truncated) local question="N/A" [[ -n "$latest_dir" ]] && question=$(_skill_get_question "$latest_dir") local max_q_len=$((term_width - 14)) [[ ${#question} -gt $max_q_len ]] && question="${question:0:$((max_q_len - 3))}..." # Format timestamps local start_display=$(monitor_format_timestamp "$started_at") # Resolve cache directory for display local cache_dir="" [[ -n "$latest_dir" ]] && cache_dir=$(_skill_find_cache_dir "$latest_dir") # Truncate paths for display local max_path_len=$((term_width - 14)) local file_display="${monitored_file:-none}" if [[ ${#file_display} -gt $max_path_len ]]; then local suffix_len=$((max_path_len - 3)) file_display="...${file_display: -$suffix_len}" fi local cache_display="${cache_dir:-not found}" if [[ ${#cache_display} -gt $max_path_len ]]; then local csuffix_len=$((max_path_len - 3)) cache_display="...${cache_display: -$csuffix_len}" fi # Model display: for gemini, no effort; for codex, show (effort) local model_display="$model" if [[ "$inv_tool" == "gemini" ]] || [[ "$effort" == "N/A" ]]; then model_display="$model" else model_display="$model ($effort)" fi tput sc tput cup 0 0 # Line 1: Title printf "${bg}${bold}%-${term_width}s${reset}${clr_eol}\n" "$(_skill_monitor_title)" # Line 2: Aggregate stats printf "${cyan}Total:${reset} ${bold}${total}${reset} invocations" [[ "$success" -gt 0 ]] && printf " | ${green}${success} success${reset}" [[ "$err" -gt 0 ]] && printf " | ${red}${err} error${reset}" [[ "$tmo" -gt 0 ]] && printf " | ${red}${tmo} timeout${reset}" [[ "$empty" -gt 0 ]] && printf " | ${yellow}${empty} empty${reset}" [[ "$running" -gt 0 ]] && printf " | ${yellow}${running} running${reset}" printf "${clr_eol}\n" # Line 3: Focused invocation status + tool + model + duration printf "${magenta}Focused:${reset} ${status_color}%s${reset} | ${dim}[%s]${reset} ${yellow}Model:${reset} %s | ${cyan}Duration:${reset} %s${clr_eol}\n" "$inv_status" "$inv_tool" "$model_display" "${duration:-N/A}" # Line 4: Started at printf "${cyan}Started:${reset} %s${clr_eol}\n" "$start_display" # Line 5: Question printf "${cyan}Question:${reset} %s${clr_eol}\n" "$question" # Line 6: Cache directory printf "${dim}Cache:${reset} %s${clr_eol}\n" "$cache_display" # Line 7: Watching file printf "${dim}Watching:${reset} %s${clr_eol}\n" "$file_display" # Line 8: Separator printf "%.s-" $(seq 1 $term_width) printf "${clr_eol}\n" tput rc } # --once mode: print summary and exit if [[ "$once_mode" == "true" ]]; then local latest=$(_skill_find_latest_dir) if [[ -z "$latest" ]]; then local filter_msg="" [[ -n "$tool_filter" ]] && filter_msg=" (filter: $tool_filter)" echo "No skill invocations found in $skill_dir$filter_msg" return 1 fi # Find best invocation with content local best_result=$(_skill_find_best_invocation) local best_dir="${best_result%%|*}" local best_file="${best_result#*|}" # Use best_dir for display (it has content); fall back to latest local focus_dir="${best_dir:-$latest}" local -a stats humanize_split_to_array stats "$(_skill_count_stats)" local inv_status="running" model="N/A" effort="N/A" duration="N/A" started_at="N/A" local inv_tool="unknown" if [[ -f "$focus_dir/metadata.md" ]]; then inv_status=$(monitor_get_yaml_value "status" "$focus_dir/metadata.md") model=$(monitor_get_yaml_value "model" "$focus_dir/metadata.md") effort=$(monitor_get_yaml_value "effort" "$focus_dir/metadata.md") duration=$(monitor_get_yaml_value "duration" "$focus_dir/metadata.md") started_at=$(monitor_get_yaml_value "started_at" "$focus_dir/metadata.md") inv_tool=$(monitor_get_yaml_value "tool" "$focus_dir/metadata.md") fi inv_tool="${inv_tool:-unknown}" local question=$(_skill_get_question "$focus_dir") local cache_dir=$(_skill_find_cache_dir "$focus_dir") local title=$(_skill_monitor_title) echo "==========================================" echo "$title" echo "==========================================" echo "" echo "Total Invocations: ${stats[0]}" echo " Success: ${stats[1]} Error: ${stats[2]} Timeout: ${stats[3]} Empty: ${stats[4]} Running: ${stats[5]}" echo "" echo "Focused: $(basename "$focus_dir")" echo " Tool: ${inv_tool}" echo " Status: ${inv_status:-unknown}" echo " Model: ${model:-N/A} (${effort:-N/A})" echo " Duration: ${duration:-N/A}" echo " Started: ${started_at:-N/A}" echo " Question: $question" echo " Cache: ${cache_dir:-not found}" echo " Watching: ${best_file:-none}" echo "" echo "==========================================" echo " Watched Output" echo "==========================================" echo "" if [[ -n "$best_file" && -s "$best_file" ]]; then cat "$best_file" elif [[ "$inv_status" == "running" ]]; then echo "(Still running...)" else echo "(No output available)" fi echo "" echo "==========================================" echo " Recent Invocations" echo "==========================================" echo "" local count=0 while IFS= read -r d; do [[ -z "$d" ]] && continue local name=$(basename "$d") local st="running" dur="" t="?" if [[ -f "$d/metadata.md" ]]; then st=$(monitor_get_yaml_value "status" "$d/metadata.md") dur=$(monitor_get_yaml_value "duration" "$d/metadata.md") t=$(monitor_get_yaml_value "tool" "$d/metadata.md") fi t="${t:-?}" local q=$(_skill_get_question "$d") [[ ${#q} -gt 50 ]] && q="${q:0:47}..." printf " %-38s %-7s %-14s %-6s %s\n" "$name" "[$t]" "$st" "$dur" "$q" ((count++)) [[ $count -ge 10 ]] && break done < <(_skill_list_dirs_sorted) echo "" echo "==========================================" return 0 fi # Interactive mode: live terminal monitor tput smcup # Save screen tput civis # Hide cursor clear tput csr $status_bar_height $(($(tput lines) - 1)) local monitor_running=true local cleanup_done=false local TAIL_PID="" # Cleanly stop the tail background process # Uses disown to remove from zsh job table, preventing "[N] terminated" messages _skill_stop_tail() { if [[ -n "${TAIL_PID:-}" ]]; then disown "$TAIL_PID" 2>/dev/null || true kill "$TAIL_PID" 2>/dev/null || true wait "$TAIL_PID" 2>/dev/null || true TAIL_PID="" fi } _skill_cleanup() { [[ "${cleanup_done:-false}" == "true" ]] && return cleanup_done=true monitor_running=false trap - INT TERM EXIT 2>/dev/null || true _skill_stop_tail # Reset scroll region before restoring screen printf '\033[r' 2>/dev/null || true tput cnorm 2>/dev/null || true tput rmcup 2>/dev/null || true echo "" echo "Monitor stopped." } # Signal handlers (bash/zsh compatible) if [[ -n "${ZSH_VERSION:-}" ]]; then TRAPINT() { _skill_cleanup; return 130; } TRAPTERM() { _skill_cleanup; return 143; } trap '_skill_cleanup' EXIT else trap '_skill_cleanup' EXIT INT TERM fi # Main monitoring loop while [[ "$monitor_running" == "true" ]]; do # Check if skill directory still exists if [[ ! -d "$skill_dir" ]]; then _skill_cleanup echo "Skill directory deleted." return 0 fi # Find best invocation with watchable content local best_result=$(_skill_find_best_invocation) local focus_dir="${best_result%%|*}" local monitored_file="${best_result#*|}" if [[ -z "$focus_dir" ]]; then tput cup $status_bar_height 0 echo "Waiting for skill invocations..." sleep "$check_interval" continue fi # Detect if the focused invocation changed if [[ "$focus_dir" != "$current_skill_dir" ]]; then current_skill_dir="$focus_dir" current_file="" _skill_stop_tail fi # Draw status bar _skill_draw_status_bar "$focus_dir" "$monitored_file" # Switch to new file if the monitored file changed if [[ "$monitored_file" != "$current_file" ]] && [[ -n "$monitored_file" ]]; then current_file="$monitored_file" _skill_stop_tail tput cup $status_bar_height 0 tput ed tail -n +1 -f "$current_file" 2>/dev/null & TAIL_PID=$! fi if [[ -z "$current_file" ]]; then tput cup $status_bar_height 0 echo "Waiting for skill output..." fi sleep "$check_interval" done # Reset trap handlers if [[ -n "${ZSH_VERSION:-}" ]]; then unfunction TRAPINT TRAPTERM 2>/dev/null || true else trap - INT TERM EXIT fi }