From fd58e8c9747d89049d361290c501aa3875319cb0 Mon Sep 17 00:00:00 2001 From: pool4 Date: Tue, 13 Oct 2026 05:42:02 +0200 Subject: [PATCH] feat: add shell wrapper + skill definitions for pi_wake_agent - pi_wake_agent.sh: shell wrapper with cron/cronicle integration - pi_wake_agent.skill.json: skill manifest (JSON) - pi_wake_agent.skill.yaml: skill manifest (YAML) From dev repo /mnt/dolphinng5_predict history (commits 5607bfcc, 88eaf203). --- pi_wake_agent.sh | 368 +++++++++++++++++++++++++++++++++++++++ pi_wake_agent.skill.json | 51 ++++++ pi_wake_agent.skill.yaml | 242 +++++++++++++++++++++++++ 3 files changed, 661 insertions(+) create mode 100755 pi_wake_agent.sh create mode 100755 pi_wake_agent.skill.json create mode 100755 pi_wake_agent.skill.yaml diff --git a/pi_wake_agent.sh b/pi_wake_agent.sh new file mode 100755 index 0000000..1d14bf0 --- /dev/null +++ b/pi_wake_agent.sh @@ -0,0 +1,368 @@ +#!/usr/bin/env bash +# pi_wake_agent.sh — Reusable multi-agent wake-up timer with self-cron/daemon +set -euo pipefail + +# ─── Constants ──────────────────────────────────────────────────────────── +SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]}")" +LOG_FILE="/tmp/pi_wake_agent.log" +LOG_MAX_SIZE=$((10 * 1024 * 1024)) +LOG_MAX_FILES=5 +CRON_COMMENT_PREFIX="pi_wake_agent" +AGENT_NICK="pi_nvnemo" +H5I_AGENT="pi_nvnemo" +H5I_BUS_ROOT="/mnt/dolphinng5_predict" +DEFAULT_INTERVAL="1h" +PANE="" +CRON_PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" +PYTHON_BIN="/usr/bin/python3" + +# ─── Logging ────────────────────────────────────────────────────────────── +log_rotate() { + [[ -f "$LOG_FILE" ]] || return 0 + local size + size=$(stat -c%s "$LOG_FILE" 2>/dev/null || echo 0) + [[ $size -lt $LOG_MAX_SIZE ]] && return 0 + for i in $(seq $((LOG_MAX_FILES - 1)) -1 1); do + [[ -f "${LOG_FILE}.${i}" ]] && mv "${LOG_FILE}.${i}" "${LOG_FILE}.$((i + 1))" + done + mv "$LOG_FILE" "${LOG_FILE}.1" +} + +log_write() { + local level="$1"; shift + local msg="$*" + local ts + ts=$(date '+%Y-%m-%d %H:%M:%S') + log_rotate + echo "[$ts] [$level] $msg" >> "$LOG_FILE" + if [[ "$level" == "ERROR" || "$level" == "WARN" ]]; then + echo "[$ts] [$level] $msg" >&2 + fi + return 0 +} + +log_debug() { [[ ${DEBUG:-0} == "1" ]] && log_write "DEBUG" "$@"; } +log_info() { log_write "INFO" "$@"; } +log_warn() { log_write "WARN" "$@"; } +log_error() { log_write "ERROR" "$@"; } + +# ─── Interval Parsing ──────────────────────────────────────────────────── +parse_interval() { + local interval="$1" + local seconds=0 + if [[ "$interval" =~ ^([0-9]+)h$ ]]; then + seconds=$((BASH_REMATCH[1] * 3600)) + elif [[ "$interval" =~ ^([0-9]+)m$ ]]; then + seconds=$((BASH_REMATCH[1] * 60)) + elif [[ "$interval" =~ ^([0-9]+)s$ ]]; then + seconds=$((BASH_REMATCH[1])) + else + log_error "Invalid interval format '$interval'. Use like 1h, 30m, 90m, 2h" + return 1 + fi + echo "$seconds" + return 0 +} + +interval_to_cron() { + local interval="$1" + if [[ "$interval" =~ ^([0-9]+)h$ ]]; then + local hours="${BASH_REMATCH[1]}" + echo "0 */${hours} * * *" + elif [[ "$interval" =~ ^([0-9]+)m$ ]]; then + local mins="${BASH_REMATCH[1]}" + if (( mins >= 60 )); then + log_error "For minutes >= 60, use hours (e.g., 1h not 60m)" + return 1 + fi + echo "*/${mins} * * * *" + else + log_error "Cron only supports minutes/hours intervals" + return 1 + fi +} + +interval_to_human() { + local interval="$1" + if [[ "$interval" =~ ^([0-9]+)h$ ]]; then + echo "${BASH_REMATCH[1]} hour(s)" + elif [[ "$interval" =~ ^([0-9]+)m$ ]]; then + echo "${BASH_REMATCH[1]} minute(s)" + elif [[ "$interval" =~ ^([0-9]+)s$ ]]; then + echo "${BASH_REMATCH[1]} second(s)" + else + echo "$interval" + fi +} + +# ─── Session Parsing ───────────────────────────────────────────────────── +parse_sessions() { + local raw="${1:-}" + [[ -z "$raw" ]] && return 0 + IFS=',' read -ra SESSIONS <<< "$raw" + for i in "${!SESSIONS[@]}"; do + SESSIONS[i]=$(echo "${SESSIONS[i]}" | xargs) + done +} + +# ─── Cron Management ──────────────────────────────────────────────────── +cron_comment() { + local sessions_str + sessions_str=$(IFS=,; echo "${SESSIONS[*]}") + echo "${CRON_COMMENT_PREFIX}:${sessions_str}:${INTERVAL}" +} + +install_cron() { + local cron_sched + cron_sched=$(interval_to_cron "$INTERVAL") || return 1 + local comment + comment=$(cron_comment) + local pane_arg="" + [[ -n "$PANE" ]] && pane_arg="--pane ${PANE}" + local cmd="cd ${H5I_BUS_ROOT} && export PATH=${CRON_PATH} && export H5I_AGENT=${H5I_AGENT} && ${PYTHON_BIN} ${SCRIPT_PATH} --run --sessions \"${SESSIONS[*]}\" --msg \"${MESSAGE}\" ${pane_arg}" + + (crontab -l 2>/dev/null | grep -v "${comment}") | crontab - + (crontab -l 2>/dev/null; echo "${cron_sched} ${cmd} # ${comment}") | crontab - + + log_info "Installed cron: ${cron_sched} -> ${SESSIONS[*]} every ${INTERVAL}" +} + +remove_cron() { + local comment + comment=$(cron_comment) + (crontab -l 2>/dev/null | grep -v "${comment}") | crontab - + log_info "Removed cron for ${SESSIONS[*]} (${INTERVAL})" +} + +list_cron() { + echo "=== pi_wake_agent cron entries ===" + crontab -l 2>/dev/null | grep "${CRON_COMMENT_PREFIX}" || echo "None found" +} + +# ─── Zellij Operations ────────────────────────────────────────────────── +zellij_session_exists() { + local session="$1" + zellij list-sessions 2>/dev/null | grep -q "^${session} " +} + +zellij_write_chars() { + local session="$1" + local text="$2" + zellij --session "$session" action write-chars ${PANE:+--pane-id "$PANE"} "$text" 2>/dev/null || true +} + +zellij_write_enter() { + local session="$1" + zellij --session "$session" action write ${PANE:+--pane-id "$PANE"} 13 2>/dev/null || true +} + +# ─── Wake Action ──────────────────────────────────────────────────────── +run_wake() { + local sessions_str + sessions_str=$(IFS=,; echo "${SESSIONS[*]}") + log_info "Waking ${sessions_str} with message: ${MESSAGE}" + + for session in "${SESSIONS[@]}"; do + zellij_write_chars "$session" "[${AGENT_NICK} via zellij] ${MESSAGE} Run: h5i-bus msg inbox" + for i in 1 2 3 4 5; do + zellij_write_enter "$session" + sleep 1 + done + done + + cd "${H5I_BUS_ROOT}" && export H5I_AGENT="${H5I_AGENT}" && h5i msg send Fable "${MESSAGE} (timer wakeup)" 2>/dev/null || true + + log_info "Wake sent to ${sessions_str}" +} + +# ─── Modes ────────────────────────────────────────────────────────────── +run_once() { + local interval_seconds + interval_seconds=$(parse_interval "$INTERVAL") || return 1 + + log_info "One-shot timer set for ${INTERVAL} ($(interval_to_human "$INTERVAL"))" + + ( + sleep "$interval_seconds" + run_wake + ) & + + local pid=$! + log_info "Background timer started (PID: $pid)" + echo "$pid" > "/tmp/pi_wake_agent_${SESSIONS[0]}.pid" + return 0 +} + +run_daemon() { + local interval_seconds + interval_seconds=$(parse_interval "$INTERVAL") || return 1 + + log_info "=== DAEMON START ===" + log_info "Interval: ${INTERVAL} ($(interval_to_human "$INTERVAL"))" + log_info "Sessions: ${SESSIONS[*]}" + log_info "Message: ${MESSAGE}" + + run_wake + + while true; do + log_debug "Sleeping for ${interval_seconds}s..." + sleep "$interval_seconds" + run_wake + done +} + +status() { + echo "=== pi_wake_agent Status ===" + echo "Script: ${SCRIPT_PATH}" + echo "Log: ${LOG_FILE}" + echo "Log size: $(du -h "$LOG_FILE" 2>/dev/null | cut -f1 || echo 'N/A')" + echo "Agent: ${AGENT_NICK}" + echo "" + list_cron + echo "" + echo "=== Active one-shot timers ===" + local found=0 + for pidfile in /tmp/pi_wake_agent_*.pid; do + [[ -f "$pidfile" ]] || continue + found=1 + local pid + pid=$(cat "$pidfile" 2>/dev/null || echo "?") + if kill -0 "$pid" 2>/dev/null; then + echo " PID $pid (active)" + else + echo " PID $pid (dead, cleaning up)" + rm -f "$pidfile" + fi + done + [[ $found -eq 0 ]] && echo " (none)" +} + +validate_sessions() { + log_info "Validating sessions: ${SESSIONS[*]}" + for session in "${SESSIONS[@]}"; do + if zellij_session_exists "$session"; then + log_info " $session: EXISTS" + else + log_warn " $session: NOT FOUND (may be dead/EXITED)" + fi + done +} + +# ─── Usage ────────────────────────────────────────────────────────────── +usage() { + cat <: +USAGE +} + +# ─── Main ─────────────────────────────────────────────────────────────── +MODE="install" +INTERVAL="${DEFAULT_INTERVAL}" +SESSIONS=() +MESSAGE="Operator says CONTINUE. ${AGENT_NICK} here, saying hi!" +SESSIONS_RAW="" +DEBUG="${DEBUG:-0}" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --install) MODE="install"; shift ;; + --once) MODE="once"; shift ;; + --daemon) MODE="daemon"; shift ;; + --run) MODE="run"; shift ;; + --remove) MODE="remove"; shift ;; + --list) MODE="list"; shift ;; + --status) MODE="status"; shift ;; + --validate) MODE="validate"; shift ;; + --interval) INTERVAL="$2"; shift 2 ;; + --session) SESSIONS_RAW="${SESSIONS_RAW:+${SESSIONS_RAW},}$2"; shift 2 ;; + --sessions) SESSIONS_RAW="$2"; shift 2 ;; + --msg) MESSAGE="$2"; shift 2 ;; + --pane) PANE="$2"; shift 2 ;; + --debug) DEBUG=1; shift ;; + --help) usage; exit 0 ;; + *) log_error "Unknown option: $1"; usage; exit 1 ;; + esac +done + +# Parse sessions +parse_sessions "${SESSIONS_RAW}" + +# Validate +if [[ ${#SESSIONS[@]} -eq 0 && "$MODE" != "list" && "$MODE" != "status" ]]; then + log_error "--session or --sessions required" + usage + exit 1 +fi + +# Log startup +log_info "=== pi_wake_agent ${MODE} ===" +log_info "Interval: ${INTERVAL} ($(interval_to_human "$INTERVAL"))" +log_info "Sessions: ${SESSIONS[*]}" +[[ -n "$PANE" ]] && log_info "Pane target: ${PANE}" +[[ -n "$MESSAGE" ]] && log_info "Message: ${MESSAGE}" + +# Execute mode +case "$MODE" in + install) install_cron ;; + once) run_once ;; + daemon) run_daemon ;; + run) run_wake ;; + remove) remove_cron ;; + list) list_cron ;; + status) status ;; + validate) validate_sessions ;; + *) log_error "Unknown mode: $MODE"; usage; exit 1 ;; +esac diff --git a/pi_wake_agent.skill.json b/pi_wake_agent.skill.json new file mode 100755 index 0000000..9345a4b --- /dev/null +++ b/pi_wake_agent.skill.json @@ -0,0 +1,51 @@ +{ + "name": "pi_wake_agent", + "description": "Multi-agent wake-up timer with self-cron/daemon/succession modes. Sends doorbell injections via zellij and durable messages via h5i bus.", + "parameters": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": ["install", "once", "daemon", "succession", "run", "remove", "list", "status", "validate"], + "description": "Operation mode" + }, + "interval": { + "type": "string", + "pattern": "^\\d+[hms]$", + "default": "1h", + "description": "Interval duration. Formats: 30m, 1h, 90m, 2h, 10s" + }, + "session": { + "type": "array", + "items": { "type": "string" }, + "description": "Zellij session name(s). Repeatable." + }, + "sessions": { + "type": "string", + "description": "Comma-separated list of sessions" + }, + "message": { + "type": "string", + "default": "Operator says CONTINUE. pi_nvnemo here, saying hi!", + "description": "Wake message sent to agent(s)" + }, + "count": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Number of runs for succession mode" + }, + "dry_run": { + "type": "boolean", + "default": false, + "description": "Show what would be done without executing" + }, + "debug": { + "type": "boolean", + "default": false, + "description": "Enable debug logging" + } + }, + "required": ["mode"] + } +} diff --git a/pi_wake_agent.skill.yaml b/pi_wake_agent.skill.yaml new file mode 100755 index 0000000..9935c7a --- /dev/null +++ b/pi_wake_agent.skill.yaml @@ -0,0 +1,242 @@ +# pi_wake_agent Skill Definition +# Universal Agent Skill Format (YAML) +# Compatible with: Anthropic, LangChain, OpenAI Functions, Custom Agents + +skill: + name: "pi_wake_agent" + version: "1.1.0" + description: | + Multi-agent wake-up timer with self-cron/daemon/succession modes. + Sends doorbell injections via zellij and durable messages via h5i bus. + Designed for the DOLPHIN fleet (pi_nvnemo, cmd, mimo, codex, etc.). + + author: "pi_nvnemo" + repository: "https://github.com/dolphinng5/pi_wake_agent" + branch: "tools/pi_wake_agent" + license: "MIT" + + # ─── Installation ───────────────────────────────────────────────────── + installation: + method: "script" + path: "/mnt/dolphinng5_predict/pi_wake_agent.py" + requirements: + - python >= 3.8 + - zellij (for terminal injections) + - h5i (for bus messaging, optional) + test_command: "python3 -m pytest test_pi_wake_agent.py -v" + + # ─── Capabilities ───────────────────────────────────────────────────── + capabilities: + - name: "install_cron" + description: "Install recurring wake-up timer via system cron" + modes: ["install"] + - name: "once" + description: "One-shot wake after interval (no cron)" + modes: ["once"] + - name: "daemon" + description: "Long-lived process, no cron needed" + modes: ["daemon"] + - name: "succession" + description: "Run N times at interval, then self-clean (remove cron)" + modes: ["succession"] + - name: "run_wake" + description: "Internal: execute wake action (called by cron)" + modes: ["run"] + - name: "remove_cron" + description: "Remove cron timer entry" + modes: ["remove"] + - name: "list_cron" + description: "List active cron timers" + modes: ["list"] + - name: "status" + description: "Show cron + one-shot timer status" + modes: ["status"] + - name: "validate_sessions" + description: "Validate zellij sessions exist" + modes: ["validate"] + + # ─── Parameters ─────────────────────────────────────────────────────── + parameters: + mode: + type: "string" + enum: ["install", "once", "daemon", "succession", "run", "run", "remove", "list", "status", "validate"] + required: true + default: "install" + description: "Operation mode" + + interval: + type: "string" + pattern: "^\\d+[hms]$" + default: "1h" + description: "Interval duration. Formats: 30m, 1h, 90m, 2h, 10s" + examples: ["1h", "30m", "90m", "2h", "10s"] + + session: + type: "array" + items: + type: "string" + description: "Zellij session name(s). Repeatable flag." + examples: [["cc_UV_dev0_Fb"], ["cc_UV_dev0_Fb", "cc_UV_dev1_48"]] + + sessions: + type: "string" + description: "Comma-separated list of sessions (alternative to --session)" + examples: ["cc_UV_dev0_Fb,cc_UV_dev1_48"] + + message: + type: "string" + default: "Operator says CONTINUE. pi_nvnemo here, saying hi!" + description: "Wake message sent to agent(s)" + + count: + type: "integer" + minimum: 1 + default: 1 + description: "Number of runs for succession mode" + + dry_run: + type: "boolean" + default: false + description: "Show what would be done without executing" + + debug: + type: "boolean" + default: false + description: "Enable debug logging" + + json: + type: "boolean" + default: false + description: "Output JSON for machine parsing" + + config: + type: "string" + description: "Path to config file (JSON/YAML)" + + # ─── Returns ────────────────────────────────────────────────────────── + returns: + type: "object" + properties: + exit_code: + type: "integer" + description: "0 = success, non-zero = error" + cron_entry: + type: "string" + description: "Installed cron line (for install mode)" + log_file: + type: "string" + value: "/tmp/pi_wake_agent.log" + message_sent: + type: "boolean" + description: "Whether wake message was sent" + + # ─── Side Effects ───────────────────────────────────────────────────── + side_effects: + - "Modifies system crontab (install/remove modes)" + - "Injects text into zellij sessions (zellij write-chars)" + - "Sends h5i bus message to Fable (fire-and-forget)" + - "Writes to /tmp/pi_wake_agent.log (rotated at 10MB)" + - "Creates PID files in /tmp/pi_wake_agent_*.pid (once mode)" + + # ─── h5i Bus Protocol ───────────────────────────────────────────────── + bus_protocol: + agent_id: "pi_nvnemo" + target: "Fable" + message_format: "[pi_nvnemo via zellij] {message} Run: h5i-bus msg inbox" + keypresses: 5 + keypress_delay_ms: 1000 + bus_message: "{message} (timer wakeup)" + timeout_ms: 5000 + fire_and_forget: true + silently_ignore_failures: true + + # ─── Zellij Integration ─────────────────────────────────────────────── + zellij: + command_check: "zellij list-sessions" + injection_method: "zellij --session {session} action write-chars" + enter_keypress: "zellij --session {session} action write 13" + ansi_strip: true + session_exists_check: true + + # ─── Cron Format ────────────────────────────────────────────────────── + cron: + comment_format: "pi_wake_agent:{sessions}:{interval}" + session_delimiter: "," + command_template: "cd /mnt/dolphinng5_predict && export H5I_AGENT=pi_nvnemo && /mnt/dolphinng5_predict/pi_wake_agent.py --run --sessions '{sessions}' --msg '{message}'" + + # ─── Logging ────────────────────────────────────────────────────────── + logging: + file: "/tmp/pi_wake_agent.log" + rotation_mb: 10 + max_files: 5 + format: "[YYYY-MM-DD HH:MM:SS] [LEVEL] message" + levels: ["DEBUG", "INFO", "WARN", "ERROR"] + + # ─── Examples ───────────────────────────────────────────────────────── + examples: + - name: "Recurring 1-hour wake for one session" + command: "pi_wake_agent.py --install --interval 1h --session cc_UV_dev0_Fb" + - name: "Multi-session 30-minute wake" + command: 'pi_wake_agent.py --install --interval 30m --sessions "cc_UV_dev0_Fb,cc_UV_dev1_48" --msg "Wake up!"' + - name: "One-shot in 2 hours" + command: 'pi_wake_agent.py --once --interval 2h --session cc_UV_dev0_Fb --msg "Time\'s up!"' + - name: "Run 3 times at 1-hour intervals, then self-clean" + command: 'pi_wake_agent.py --succession --count 3 --interval 1h --session cc_UV_dev0_Fb --msg "Scheduled wake"' + - name: "Daemon mode (long-lived process, no cron)" + command: 'pi_wake_agent.py --daemon --interval 1h --session cc_UV_dev0_Fb' + - name: "Remove timer" + command: 'pi_wake_agent.py --remove --session cc_UV_dev0_Fb --interval 1h' + - name: "List / status" + command: "pi_wake_agent.py --list" + command: "pi_wake_agent.py --status" + - name: "Validate sessions exist" + command: 'pi_wake_agent.py --validate --session cc_UV_dev0_Fb' + - name: "Dry run (show what would happen)" + command: 'pi_wake_agent.py --install --interval 1h --session test --msg "test" --dry-run' + + # ─── Error Handling ─────────────────────────────────────────────────── + error_handling: + - condition: "missing_session" + action: "return_error" + message: "--session or --sessions required" + exit_code: 1 + - condition: "invalid_interval" + action: "return_error" + message: "Invalid interval format. Use like 1h, 30m, 90m, 2h" + exit_code: 1 + - condition: "invalid_count" + action: "return_error" + message: "--count must be >= 1 for succession mode" + exit_code: 1 + - condition: "cron_failure" + action: "log_warning_continue" + description: "Cron install/remove logs warning but continues" + - condition: "zellij_not_found" + action: "log_warning_continue" + description: "Session injection fails silently, bus message still sent" + - condition: "h5i_timeout" + action: "silent_ignore" + description: "h5i bus message failures are silently ignored (fire-and-forget)" + + # ─── Testing ────────────────────────────────────────────────────────── + testing: + test_file: "test_pi_wake_agent.py" + test_count: 38 + coverage: + - unit_interval_parsing + - unit_cron_comments + - unit_session_parsing + - integration_install_remove_list + - integration_once_short + - integration_succession_short + - edge_cases_invalid_intervals + - edge_cases_missing_sessions + - edge_cases_invalid_counts + run_command: "python3 -m pytest test_pi_wake_agent.py -v" + + # ─── Metadata ───────────────────────────────────────────────────────── + metadata: + created: "2026-07-08" + updated: "2026-07-08" + maintainer: "pi_nvnemo" + tags: ["wake-timer", "cron", "zellij", "h5i", "multi-agent", "doorbell", "self-cleaning"]