Skill: u0003-yxuan/openclaw-auto-recovery (version 1.0.7)

--- name: openclaw-auto-recovery description: OpenClaw 基础设施心跳监控与自动恢复。用于：部署/卸载心跳守护进程、配置主机监控（Gateway/磁盘/内存/CPU/进程）、设置飞书告警、自动重启故障 Gateway。当用户提到"心跳监控"、"heartbeat daemon"、"服务器告警"、"自动重启"、"健康检查"、"auto recovery"时触发。 version: 1.0.7 changelog: | - 1.0.7: 修复 CPU 检测精度：改用 vmstat 1 1（列15固定为idle），替代 top 避免中文locale下列位置不稳定问题；99992361为飞书API频率限制非代码bug，建议检查alert发送频率 - 1.0.6: 修复中文locale兼容性问题：check_memory改用awk NR==2；check_cpu原方案(top/grep)有缺陷已废弃 - 1.0.4: 修复log语句中全角字符导致的bash语法错误 - 1.0.3: 新增RELEASE.md完整升级历史文档 - 1.0.2: 修复回滚逻辑缺陷：重启失败时依次尝试last-good.1/2/3，避免回滚到刚保存的坏文件；MD5去重保存；新增rollback_one() - 1.0.1: 将示例App ID改为占位符，避免泄露真实值 --- # infra-heartbeat OpenClaw 基础设施心跳守护进程，负责： - **Gateway 监控**：WebSocket 连通性检测，DOWN 时自动重启（最多连续 3 次） - **主机资源**：磁盘 / 内存 / CPU / 进程监控，超阈值飞书告警 - **配置安全**：变更前自动备份，JSON 损坏时自动回滚 - **进程保活**：由 systemd 托管，崩溃自动重启，不依赖 nohup ## 快速开始 ### 首次安装 ```bash bash ~/.openclaw/extensions/infra-heartbeat/scripts/install-heartbeat.sh ``` 交互式安装向导会要求填写必填配置（飞书 App ID/Secret、Gateway Token、通知目标）。 ### 常用操作 ```bash # 查看状态 systemctl --user status heartbeat-daemon # 查看日志 tail -f ~/.openclaw/workspace-infra/heartbeat.log # 重启服务 systemctl --user restart heartbeat-daemon # 卸载 bash ~/.openclaw/extensions/infra-heartbeat/scripts/uninstall-heartbeat.sh ``` ## 配置 配置文件：`~/.config/infra-heartbeat/config.env` 详细配置参数见 [references/configure.md](references/configure.md)。 ### 关键参数速查 | 参数 | 必填 | 默认值 | 说明 | |------|------|--------|------| | `FEISHU_APP_ID` | ✅ | — | 飞书应用 App ID | | `FEISHU_APP_SECRET` | ✅ | — | 飞书应用 App Secret | | `GATEWAY_TOKEN` | ✅ | — | Gateway 访问令牌 | | `TARGET_OPEN_ID` | ✅ | — | 通知目标 open_id | | `CHECK_INTERVAL` | — | 180s | 检测间隔 | | `DISK_THRESHOLD` | — | 80% | 磁盘告警阈值 | | `MEM_THRESHOLD` | — | 85% | 内存告警阈值 | | `CPU_THRESHOLD` | — | 60% | CPU 告警阈值 | ### 修改配置后生效 ```bash systemctl --user restart heartbeat-daemon ``` ## 部署到新机器 1. 将 `infra-heartbeat/` skill 目录复制到新机器 `~/.openclaw/extensions/` 2. 运行 `bash install-heartbeat.sh` 3. 填写新机器的 `GATEWAY_TOKEN`、`TARGET_OPEN_ID` 等参数 无需修改脚本，配置全部外部化。 ## 自动恢复逻辑 ``` 锚点机制： - 每次检测到 Gateway UP → 若配置 MD5 有变化 → 保存 last-good.1/.2/.3（最多3份，轮转） - last-good.N 之间 MD5 均不同，确保每份都是真实变更过的配置 Gateway DOWN ├─ 连续失败 < 3 次 │ ├─ 备份当前配置（仅供历史记录，不用于回滚） │ ├─ 校验 JSON → INVALID → rollback_one（仅回滚，不重启） │ ├─ restart → sleep 60 → check │ │ ├─ UP → 成功，清零计数，下次 UP 时更新 last-good │ │ └─ DOWN → rollback_config() 依次尝试： │ │ last-good.1 → restart → check │ │ ↓ DOWN last-good.2 → restart → check │ │ ↓ DOWN last-good.3 → restart → check │ │ ↓ DOWN → 失败计数+1，告警"需人工介入" │ └─ 连续 ≥ 3 次 → 停止自动重启 │ └─ 连续失败 ≥ 3 次 → 停止自动重启 → 发"需人工介入"通知 ``` ## 与 cron 健康检查的区别 - **Heartbeat Daemon**：3 分钟间隔，发现故障自动恢复，systemd 进程保活 - **Cron `daily-health-check`**：每天一次，纯告警，不自动恢复 两者互为补充，生产环境建议同时运行。 ## 获取当前配置参考值 ```bash # Gateway Token cat ~/.openclaw/openclaw.json | jq -r '.gateway.auth.secret' # 查看当前运行的配置 systemctl --user show heartbeat-daemon --property=ExecStart ```

path: RELEASE.md
content: # openclaw-auto-recovery Release Notes

## Version 1.0.7 — 2026-04-07

### 修复：CPU 检测改用 vmstat（彻底解决 locale 问题）

**问题描述**

`top -bn1 | awk 'NR==3 {print $4}'` 在不同 Linux 发行版和 locale 下，`top` 第 3 行的列位置不固定，导致 `$4` 在某些中文环境下拿到的是错误的字段名，算出的 CPU 使用率是乱值。

**修复方案**

改用 `vmstat 1 1`，其列输出格式固定为英文，`NR==3` 的第 15 列（idle）始终可靠：

```bash
# 修复后（不依赖 top 列位置）
check_cpu() {
    vmstat 1 1 | awk 'NR==3 {printf "%.1f\n", 100-$15}'
}
```

**关于飞书错误码 99992361**

这是飞书消息接口的**频率限制**错误（QPS 限流），非代码 bug。可能原因：
- 短时间内有多条告警同时触发（如 CPU + 内存 + 磁盘同时超阈值）
- 重启恢复通知等连续告警
- 建议检查 alert 发送频率是否集中在短时间窗口内

---

## Version 1.0.6 — 2026-04-07

### 修复：中文 locale 兼容性问题

**问题描述**

在非英文 locale 环境下（如中文 Linux），`free` 命令输出第一列为 `内存:` 而非 `Mem:`，`top` 命令的 CPU 行标签也可能被翻译。导致：

- `check_memory`：调用 `free | grep Mem` 匹配失败，命令返回空，在 `set -euo pipefail` 下脚本直接退出
- `check_cpu`：调用 `top -bn1 | grep "Cpu(s)"` 在某些中文环境下标签不一致，同样导致 grep 失败

**修复内容**

- `check_memory`：改用 `free | awk 'NR==2 {printf "%.0f\n", $3/$2 * 100}'`，不依赖英文标签
- `check_cpu`：改用 `top -bn1 | awk 'NR==3 {printf "%.1f\n", 100-$4}'`，`$4` 是 idle 值，100减去得到实际 CPU 使用率，不依赖 `Cpu(s)` 等英文标签

---

## Version 1.0.2 — 2026-04-07

### 核心修复：回滚逻辑缺陷（Critical Fix）

**问题描述**

在 v1.0.1 及之前版本中，当 Gateway DOWN 且重启失败时，回滚逻辑存在致命缺陷：

```
Gateway DOWN → backup_config() 保存当前（坏）配置为 abc
             → restart 失败
             → rollback_config() → ls -t 取最新 → 命中最新的 abc（还是那份坏配置）
             → 回滚到坏配置，无任何意义
```

核心问题：触发备份和回滚使用的是**同一套文件命名规则**，`ls -t` 取到的"最新备份"就是刚保存的那份坏文件。

**修复方案：last-good 锚点机制**

引入独立的健康配置锚点文件，与普通备份完全隔离：

- **只在 Gateway UP 时保存**：每次检测到 Gateway 在线，才更新 `openclaw.json.last-good.{1,2,3}`
- **MD5 去重**：配置内容未变化时不重复保存，避免锚点被坏配置覆盖
- **回滚只读锚点**：`rollback_config()` / `rollback_one()` 只回滚到 `last-good.N`，不用 `ls -t` 动态查找

### 新增功能

#### 1. 多版本回滚（最多 3 个版本）

重启失败后，不再只尝试一份配置，而是依次尝试：

```
last-good.1 → restart → check
    ↓ DOWN     last-good.2 → restart → check
    ↓ DOWN     last-good.3 → restart → check
    ↓ DOWN     → 失败计数+1，告警"需人工介入"
```

每次尝试都是完整的"覆盖配置 → 重启 → sleep 60 → 验证 UP/DOWN"循环。

#### 2. `rollback_one()` — JSON 无效专用

当 `validate_json()` 发现配置文件格式已损坏（JSON 语法错误），调用 `rollback_one()`：

- 行为：只回滚配置，**不回滚、不重启**
- 原因：JSON 格式错误时首要目标是换掉坏文件，重启让后续流程统一处理
- 分工明确：`rollback_one()` 专注 JSON 损坏场景，`rollback_config()` 专注重启失败场景

#### 3. 启动时健康锚点保存

心跳守护进程启动时，若 Gateway 已在线，立即保存 `last-good.1`，确保服务重启后锚点可用。

#### 4. MD5 去重保存

`save_last_good()` 在保存前对比 MD5，配置内容无变化时跳过保存，保持锚点队列有真正的历史版本。

### 其他改进

- `heartbeat-daemon.sh` 脚本结构重组：函数定义更清晰，注释更完善
- 所有示例 App ID 改为占位符（`cli_xxxxxxxxxxxxxxxxxxxxxxxxxxxx`），避免隐私泄露

---

## Version 1.0.1 — 2026-04-06

- 将安装脚本和配置文档中的示例 App ID 替换为占位符，防止真实凭证随 skill 扩散

---

## Version 1.0.0 — 2026-04-04

- 初始发布
- 功能：Gateway WebSocket 检测 + 自动重启（最多 3 次连续失败）
- 主机监控：磁盘 / 内存 / CPU / 进程，超阈值飞书告警
- 配置备份回滚：基于 `ls -t` 的简单备份策略
- systemd 托管进程保活


path: _meta.json
content: ownerId: kn79y0pzjf7gd9p93jh1t7r0hh82jgbp
slug: openclaw-auto-recovery
version: 1.0.7
publishedAt: 1743993600000

path: references/configure.md
content: # 配置参考

## 配置文件路径

默认：`~/.config/infra-heartbeat/config.env`

可通过 `heartbeat-daemon.sh --config /path/to/config.env` 覆盖。

## 必填参数

| 参数 | 说明 | 示例 |
|------|------|------|
| `FEISHU_APP_ID` | 飞书应用 App ID | `cli_xxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
| `FEISHU_APP_SECRET` | 飞书应用 App Secret | `Uu4RlQz...` |
| `GATEWAY_TOKEN` | OpenClaw Gateway 访问令牌 | 在 openclaw.json 中查找 |
| `TARGET_OPEN_ID` | 飞书通知目标的 open_id | `ou_xxx` |

## 可选参数（带默认值）

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `GATEWAY_PORT` | `18789` | Gateway WebSocket 端口 |
| `OPENCLAW_JSON` | `~/.openclaw/openclaw.json` | openclaw 配置文件路径 |
| `CHECK_INTERVAL` | `180`（秒） | 心跳检测间隔 |
| `MAX_RESTART_FAILS` | `3` | 连续失败次数阈值（达到后停止自动重启） |
| `DISK_THRESHOLD` | `80`（%） | 磁盘使用率告警阈值 |
| `MEM_THRESHOLD` | `85`（%） | 内存使用率告警阈值 |
| `CPU_THRESHOLD` | `60`（%） | CPU 使用率告警阈值 |
| `WS_MODULE` | 自动检测 | WebSocket 模块路径，通常不需要改 |

## 获取配置值

### Gateway Token

```bash
cat ~/.openclaw/openclaw.json | jq -r '.gateway.auth.secret'
```

### 飞书 App ID / Secret

在飞书开放平台 → 应用凭证 中查看。

### Target Open ID

飞书用户的 open_id，可通过以下方式获取：
- 飞书开发者工具
- 搜索用户 API

## 日志文件

| 文件 | 路径 |
|------|------|
| 主日志 | `~/.openclaw/workspace-infra/heartbeat.log` |
| 告警日志 | `~/.openclaw/workspace-infra/alerts.log` |
| 失败计数 | `~/.openclaw/workspace-infra/restart-fail-count` |
| 自动备份 | `~/.openclaw/openclaw.json.auto.*` |

## 阈值设计建议

| 环境 | CPU | 内存 | 磁盘 |
|------|-----|------|------|
| 个人工作站 | 60-70% | 80-85% | 80% |
| VPS/服务器 | 70-80% | 85-90% | 85% |
| 高可用环境 | 50% | 75% | 70% |

## 与 cron 任务的区别

| | Heartbeat Daemon | Cron 健康检查 |
|---|---|---|
| 实时性 | 3 分钟间隔 | 通常每天一次 |
| 自动恢复 | ✅ 自动重启 Gateway | ❌ 仅告警 |
| 进程保活 | ✅ systemd 托管 | ❌ 依赖 cron |
| 适用场景 | 生产环境需要自动恢复 | 日常巡检/统计 |


path: scripts/heartbeat-daemon.sh
content: #!/bin/bash
# Infra Heartbeat Daemon — OpenClaw 基础设施心跳监控
# 用法: heartbeat-daemon.sh --config <配置文件>
# 不带参数时读取默认配置 ~/.config/infra-heartbeat/config.env

set -euo pipefail

# 默认配置路径
DEFAULT_CONFIG_DIR="${HOME}/.config/infra-heartbeat"
DEFAULT_CONFIG="${DEFAULT_CONFIG_DIR}/config.env"
LOG_DIR="${HOME}/.openclaw/workspace-infra"
LOG="${LOG_DIR}/heartbeat.log"
ALERT_LOG="${LOG_DIR}/alerts.log"
FAIL_COUNT_FILE="${LOG_DIR}/restart-fail-count"

# WS module 路径（自动检测）
detect_ws_module() {
    local openclaw_node_modules
    openclaw_node_modules=$(find "${HOME}/.npm-global/lib/node_modules/openclaw/node_modules/ws" -maxdepth 0 2>/dev/null || echo "")
    if [[ -z "$openclaw_node_modules" ]]; then
        openclaw_node_modules=$(find "${HOME}/.npm-global/lib/node_modules/openclaw" -path "*/node_modules/ws" 2>/dev/null | head -1)
    fi
    echo "$openclaw_node_modules"
}

# 加载配置
load_config() {
    local config_file="${1:-${DEFAULT_CONFIG}}"
    if [[ -f "$config_file" ]]; then
        set -a
        source "$config_file"
        set +a
    else
        echo "ERROR: 配置文件不存在: $config_file" >&2
        exit 1
    fi
}

# 日志
log() {
    echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG"
}

# 校验 JSON
validate_json() {
    node -e "
const fs = require('fs');
try {
    JSON.parse(fs.readFileSync('${OPENCLAW_JSON}', 'utf8'));
    console.log('VALID');
} catch(e) {
    console.log('INVALID: ' + e.message);
}
" 2>/dev/null
}

# 备份配置
backup_config() {
    local bak="${OPENCLAW_JSON}.auto.$(date '+%Y%m%d_%H%M%S')"
    mkdir -p "$(dirname "$bak")"
    cp "$OPENCLAW_JSON" "$bak"
    log "CONFIG: 备份已创建 $bak"
    # 只保留最近5份
    ls -t "${OPENCLAW_JSON}.auto."* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null
}

# 保存"最后一次已知良好"配置（最多3份，MD5 不重复才保存）
save_last_good() {
    local newest="${OPENCLAW_JSON}.last-good.1"
    # 如果存在 last-good.1，检查 MD5 是否真的变了，没变则跳过
    if [[ -f "$newest" ]]; then
        local cur_md5 new_md5
        cur_md5=$(md5sum "$newest" | awk '{print $1}')
        new_md5=$(md5sum "$OPENCLAW_JSON" | awk '{print $1}')
        if [[ "$cur_md5" == "$new_md5" ]]; then
            log "CONFIG: 配置未变更，跳过 last-good 保存"
            return 0
        fi
    fi

    # 轮转：last-good.2 → last-good.3（会被覆盖），last-good.1 → last-good.2
    [[ -f "${OPENCLAW_JSON}.last-good.2" ]] && cp "${OPENCLAW_JSON}.last-good.2" "${OPENCLAW_JSON}.last-good.3" && log "CONFIG: last-good.2 → last-good.3"
    [[ -f "$newest" ]] && cp "$newest" "${OPENCLAW_JSON}.last-good.2" && log "CONFIG: last-good.1 → last-good.2"
    cp "$OPENCLAW_JSON" "$newest"
    log "CONFIG: 已保存 last-good.1（MD5: $(md5sum "$newest" | awk '{print $1}'))"
}

# 仅回滚配置文件到 last-good.1（不回滚、不重启，给 JSON 无效场景用）
rollback_one() {
    local newest="${OPENCLAW_JSON}.last-good.1"
    if [[ -f "$newest" ]]; then
        cp "$newest" "$OPENCLAW_JSON"
        log "CONFIG: 已回滚到 last-good.1"
        return 0
    else
        log "CONFIG: 无 last-good.1 可回滚"
        return 1
    fi
}

# 回滚到 last-good（依次尝试 last-good.1 → .2 → .3）
# 返回：0=恢复成功，1=所有 last-good 都失败
rollback_config() {
    local rolled_back=false

    for ver in 1 2 3; do
        local candidate="${OPENCLAW_JSON}.last-good.${ver}"
        if [[ ! -f "$candidate" ]]; then
            [[ "$ver" == "1" ]] && log "CONFIG: 无 last-good 可回滚（文件不存在）"
            continue
        fi

        local before_md5 after_md5
        before_md5=$(md5sum "$OPENCLAW_JSON" | awk '{print $1}')
        cp "$candidate" "$OPENCLAW_JSON"
        after_md5=$(md5sum "$OPENCLAW_JSON" | awk '{print $1}')
        log "CONFIG: 尝试回滚到 last-good.${ver}（MD5: ${after_md5}）"

        systemctl --user restart openclaw-gateway
        sleep 60
        local gw_check
        gw_check=$(check_gateway)

        if [[ "$gw_check" == "UP" ]]; then
            log "CONFIG: 回滚到 last-good.${ver} 后 Gateway 已恢复"
            return 0
        else
            log "CONFIG: last-good.${ver} 仍失败，继续尝试下一份..."
        fi
    done

    log "CONFIG: 所有 last-good（1/2/3）均回滚失败，需人工介入"
    return 1
}

# Gateway 检测
check_gateway() {
    local ws_module="${WS_MODULE:-$(detect_ws_module)}"
    node -e "
const WebSocket = require('${ws_module}');
const ws = new WebSocket('ws://127.0.0.1:${GATEWAY_PORT}', {
    headers: { 'Authorization': 'Bearer ${GATEWAY_TOKEN}' }
});
ws.on('open', () => { console.log('UP'); ws.close(); process.exit(0); });
ws.on('error', () => { console.log('DOWN'); process.exit(0); });
setTimeout(() => { console.log('DOWN'); process.exit(1); }, 4000);
" 2>/dev/null
}

check_disk() {
    df -h / | awk 'NR==2 {print $5}' | sed 's/%//'
}

check_memory() {
    free | awk 'NR==2 {printf "%.0f\n", $3/$2 * 100}'
}

check_cpu() {
    vmstat 1 1 | awk 'NR==3 {printf "%.1f\n", 100-$15}'
}

check_process() {
    pgrep -f "openclaw" > /dev/null 2>&1 && echo "UP" || echo "DOWN"
}

# 发送飞书告警
send_alert() {
    local msg="$1"
    log "ALERT: $msg"
    echo "$(date '+%Y-%m-%d %H:%M:%S') $msg" >> "$ALERT_LOG"

    local token_resp
    token_resp=$(curl -s -X POST "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" \
        -H "Content-Type: application/json" \
        -d "{\"app_id\": \"${FEISHU_APP_ID}\", \"app_secret\": \"${FEISHU_APP_SECRET}\"}")

    local token
    [REDACTED_SECRET] "$token_resp" | node -e "
const data = require('fs').readFileSync('/dev/stdin', 'utf8');
try {
    const j = JSON.parse(data);
    console.log(j.tenant_access_token || '');
} catch(e) { console.log(''); }
" 2>/dev/null)

    if [[ -z "$token" ]]; then
        log "Feishu token fetch failed"
        return 1
    fi

    local send_resp
    send_resp=$(curl -s -X POST "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id" \
        -H "Authorization: Bearer $token" \
        -H "Content-Type: application/json" \
        -d "{\"receive_id\": \"${TARGET_OPEN_ID}\", \"msg_type\": \"text\", \"content\": \"{\\\"text\\\":\\\"${msg}\\\"}\"}")

    local code
    code=$(echo "$send_resp" | node -e "
const data = require('fs').readFileSync('/dev/stdin', 'utf8');
try {
    const j = JSON.parse(data);
    console.log(j.code || 'PARSE_ERROR');
} catch(e) { console.log('PARSE_ERROR'); }
" 2>/dev/null | tr -d '\n')

    [[ "$code" == "0" ]] && log "Feishu alert sent OK" || log "Feishu alert failed: code=$code"
}

get_fail_count() { [[ -f "$FAIL_COUNT_FILE" ]] && cat "$FAIL_COUNT_FILE" || echo "0"; }
inc_fail_count() { echo "$(($(get_fail_count) + 1))" > "$FAIL_COUNT_FILE"; echo "$(cat "$FAIL_COUNT_FILE")"; }
reset_fail_count() { echo "0" > "$FAIL_COUNT_FILE"; }

# 主循环
run_heartbeat() {
    local config_file="${1:-}"
    [[ -n "$config_file" ]] && load_config "$config_file" || load_config "$DEFAULT_CONFIG"

    mkdir -p "$LOG_DIR"
    : > "$LOG"
    log "Heartbeat daemon started (PID: $$)"

    # 启动时检查 ws_module
    WS_MODULE="${WS_MODULE:-$(detect_ws_module)}"
    log "WS module: $WS_MODULE"

    # 启动时若 Gateway 已在线，保存健康锚点
    if [[ "$(check_gateway)" == "UP" ]]; then
        save_last_good
    fi

    while true; do
        alerts=""
        gw_action_taken=false

        # Gateway
        gw=$(check_gateway)
        if [[ "$gw" != "UP" ]]; then
            local fail_count
            fail_count=$(get_fail_count)

            if [[ "$fail_count" -ge "$MAX_RESTART_FAILS" ]]; then
                log "CHECK: Gateway=DOWN，连续失败${fail_count}次，停止自动重启"
                alerts="${alerts}##【心跳告警】Gateway DOWN，连续失败${fail_count}次，已停止自动重启，请人工检查"
            else
                log "CHECK: Gateway=$gw — 第${fail_count}次失败，尝试重启..."

                backup_config

                local json_valid
                json_valid=$(validate_json)
                if [[ "$json_valid" != VALID* ]]; then
                    log "CONFIG: JSON 无效，尝试回滚 last-good.1..."
                    rollback_one
                fi

                systemctl --user restart openclaw-gateway
                sleep 60
                gw_new=$(check_gateway)

                if [[ "$gw_new" == "UP" ]]; then
                    reset_fail_count
                    log "CHECK: Gateway 已自动恢复"
                    send_alert "【心跳告警】Gateway DOWN，已自动重启恢复 ✅"
                else
                    # 重启失败：依次尝试 last-good.1 → .2 → .3，每次重启验证
                    log "CHECK: Gateway 仍 DOWN，尝试回滚 last-good（最多3个版本）..."
                    if rollback_config; then
                        reset_fail_count
                        send_alert "【心跳告警】Gateway DOWN，重启失败，已自动回滚历史配置并恢复 ✅"
                    else
                        local new_count
                        new_count=$(inc_fail_count)
                        alerts="${alerts}##【心跳告警】Gateway DOWN，所有 last-good 版本均回滚失败（${new_count}/${MAX_RESTART_FAILS}），请人工介入"
                    fi
                fi
            fi
            gw_action_taken=true
        else
            reset_fail_count
            # Gateway 稳定在线，保存已知良好配置锚点
            save_last_good
        fi

        # Disk
        disk=$(check_disk)
        if [[ "$disk" -gt "${DISK_THRESHOLD:-80}" ]]; then
            alerts="${alerts}##【心跳告警】磁盘: ${disk}% > ${DISK_THRESHOLD:-80}%"
            log "CHECK: Disk=$disk%"
        fi

        # Memory
        mem=$(check_memory)
        if [[ -n "$mem" && "$mem" -gt "${MEM_THRESHOLD:-85}" ]]; then
            alerts="${alerts}##【心跳告警】内存: ${mem}% > ${MEM_THRESHOLD:-85}%"
            log "CHECK: Memory=$mem%"
        fi

        # CPU
        cpu=$(check_cpu)
        local cpu_int=${cpu%.*}
        if [[ -n "$cpu_int" && "$cpu_int" -gt "${CPU_THRESHOLD:-60}" ]]; then
            alerts="${alerts}##【心跳告警】CPU: ${cpu}% > ${CPU_THRESHOLD:-60}%"
            log "CHECK: CPU=$cpu%"
        fi

        # Process
        proc=$(check_process)
        if [[ "$proc" != "UP" ]]; then
            alerts="${alerts}##【心跳告警】进程: ${proc}"
            log "CHECK: Process=$proc"
        fi

        # 发告警
        if [[ -n "$alerts" ]]; then
            while IFS= read -r alert; do
                alert=$(echo "$alert" | sed 's/^#*//' | sed 's/^ *//')
                [[ -n "$alert" ]] && send_alert "$alert"
            done <<< "$(echo "$alerts" | tr '#' '\n')"
        else
            log "CHECK: All healthy"
        fi

        sleep "${CHECK_INTERVAL:-180}"
    done
}

# 显示帮助
show_help() {
    echo "Infra Heartbeat Daemon"
    echo ""
    echo "用法:"
    echo "  heartbeat-daemon.sh [--config <文件>]"
    echo "  heartbeat-daemon.sh --help"
    echo ""
    echo "参数:"
    echo "  --config <文件>   指定配置文件（默认: ~/.config/infra-heartbeat/config.env）"
    echo "  --help            显示帮助"
}

case "${1:-}" in
    --help|-h)
        show_help
        exit 0
        ;;
    --config)
        shift
        run_heartbeat "$1"
        ;;
    "")
        run_heartbeat
        ;;
    *)
        echo "未知参数: $1"
        show_help
        exit 1
        ;;
esac


path: scripts/install-heartbeat.sh
content: #!/bin/bash
# 安装 infra-heartbeat systemd 服务
# 用法: install-heartbeat.sh [--config <配置文件>]
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(dirname "$SCRIPT_DIR")"
HEARTBEAT_SCRIPT="${SKILL_DIR}/scripts/heartbeat-daemon.sh"

SYSTEMD_DIR="${HOME}/.config/systemd/user"
SERVICE_FILE="${SYSTEMD_DIR}/heartbeat-daemon.service"
CONFIG_DIR="${HOME}/.config/infra-heartbeat"
CONFIG_FILE="${CONFIG_DIR}/config.env"

# 默认配置值（可被配置文件覆盖）
DEFAULT_FEISHU_APP_ID="${FEISHU_APP_ID:-cli_xxxxxxxxxxxxxxxxxxxxxxxxxxxx}"
DEFAULT_FEISHU_APP_SECRET="${FEISHU_APP_SECRET:-}"
DEFAULT_GATEWAY_TOKEN="${GATEWAY_TOKEN:-}"
DEFAULT_GATEWAY_PORT="${GATEWAY_PORT:-18789}"
DEFAULT_TARGET_OPEN_ID="${TARGET_OPEN_ID:-}"
DEFAULT_OPENCLAW_JSON="${OPENCLAW_JSON:-${HOME}/.openclaw/openclaw.json}"
DEFAULT_CHECK_INTERVAL="${CHECK_INTERVAL:-180}"
DEFAULT_MAX_RESTART_FAILS="${MAX_RESTART_FAILS:-3}"
DEFAULT_DISK_THRESHOLD="${DISK_THRESHOLD:-80}"
DEFAULT_MEM_THRESHOLD="${MEM_THRESHOLD:-85}"
DEFAULT_CPU_THRESHOLD="${CPU_THRESHOLD:-60}"

show_help() {
    echo "install-heartbeat.sh — 安装 infra-heartbeat systemd 服务"
    echo ""
    echo "用法: install-heartbeat.sh [--config <配置文件>]"
    echo ""
    echo "参数:"
    echo "  --config <文件>   覆盖默认配置的配置文件路径"
    echo "  --dry-run         仅显示将要执行的操作，不实际安装"
    echo "  --help            显示帮助"
}

DRY_RUN=false
CONFIG_OVERRIDE=""

while [[ $# -gt 0 ]]; do
    case "$1" in
        --dry-run) DRY_RUN=true; shift ;;
        --config) CONFIG_OVERRIDE="$2"; shift 2 ;;
        --help|-h) show_help; exit 0 ;;
        *) echo "未知参数: $1"; show_help; exit 1 ;;
    esac
done

# 检查 heartbeat-daemon.sh 是否存在
if [[ ! -f "$HEARTBEAT_SCRIPT" ]]; then
    echo "ERROR: 找不到 heartbeat-daemon.sh: $HEARTBEAT_SCRIPT" >&2
    exit 1
fi

# 检测 ws_module 路径
detect_ws_module() {
    local ws_path
    ws_path=$(find "${HOME}/.npm-global/lib/node_modules/openclaw" -path "*/node_modules/ws" -maxdepth 6 2>/dev/null | head -1)
    if [[ -z "$ws_path" ]]; then
        echo "ERROR: 无法找到 openclaw ws module" >&2
        return 1
    fi
    echo "$ws_path"
}

WS_MODULE_PATH=$(detect_ws_module)
echo "检测到 WS module: $WS_MODULE_PATH"

# 加载已有配置（如果有）
if [[ -n "$CONFIG_OVERRIDE" && -f "$CONFIG_OVERRIDE" ]]; then
    echo "使用配置: $CONFIG_OVERRIDE"
    set -a
    source "$CONFIG_OVERRIDE"
    set +a
elif [[ -f "$CONFIG_FILE" ]]; then
    echo "使用现有配置: $CONFIG_FILE"
    set -a
    source "$CONFIG_FILE"
    set +a
fi

# 提示缺失的必要配置
missing=()
[[ -z "${FEISHU_APP_ID:-}" ]] && missing+=("FEISHU_APP_ID")
[[ -z "${FEISHU_APP_SECRET:-}" ]] && missing+=("FEISHU_APP_SECRET")
[[ -z "${GATEWAY_TOKEN:-}" ]] && missing+=("GATEWAY_TOKEN")
[[ -z "${TARGET_OPEN_ID:-}" ]] && missing+=("TARGET_OPEN_ID")

if [[ ${#missing[@]} -gt 0 ]]; then
    echo "ERROR: 以下必填配置缺失: ${missing[*]}" >&2
    echo "请创建配置文件: $CONFIG_FILE"
    echo ""
    generate_config_sample
    exit 1
fi

generate_config_sample() {
    cat <<EOF
# infra-heartbeat 配置文件模板
# 路径: $CONFIG_FILE

# === 必填 ===
FEISHU_APP_ID="${DEFAULT_FEISHU_APP_ID}"
FEISHU_APP_SECRET="${DEFAULT_FEISHU_APP_SECRET}"
GATEWAY_TOKEN="${DEFAULT_GATEWAY_TOKEN}"
TARGET_OPEN_ID="${DEFAULT_TARGET_OPEN_ID}"

# === 可选（带默认值）===
GATEWAY_PORT="${DEFAULT_GATEWAY_PORT}"
OPENCLAW_JSON="${DEFAULT_OPENCLAW_JSON}"
CHECK_INTERVAL="${DEFAULT_CHECK_INTERVAL}"
MAX_RESTART_FAILS="${DEFAULT_MAX_RESTART_FAILS}"
DISK_THRESHOLD="${DEFAULT_DISK_THRESHOLD}"
MEM_THRESHOLD="${DEFAULT_MEM_THRESHOLD}"
CPU_THRESHOLD="${DEFAULT_CPU_THRESHOLD}"

# === 自动检测（通常不需要改）===
WS_MODULE="$WS_MODULE_PATH"
EOF
}

echo ""
echo "=== 安装计划 ==="
echo "Systemd service: $SERVICE_FILE"
echo "Config file: $CONFIG_FILE"
echo "HEARTBEAT script: $HEARTBEAT_SCRIPT"
echo ""

if [[ "$DRY_RUN" == true ]]; then
    echo "[dry-run] 以下文件将被创建:"
    echo "  $SERVICE_FILE"
    echo "  $CONFIG_FILE"
    echo ""
    generate_config_sample
    exit 0
fi

# 读取交互式配置（如果必填缺失）
read_config_interactive() {
    local var_name=$1
    local prompt=$2
    local default=$3
    local value=""

    # 如果已经有值则跳过
    [[ -n "${!var_name:-}" ]] && return 0

    echo -n "$prompt"
    if [[ -n "$default" ]]; then
        echo -n " [$default]"
    fi
    echo -n ": "
    read -r value
    value="${value:-$default}"
    declare "$var_name=$value"
}

echo ""
echo "=== 交互式配置 ==="
echo "（直接回车使用默认值，已在配置文件中的值会被使用）"
echo ""

if [[ -z "${FEISHU_APP_ID:-}" ]]; then
    read_config_interactive FEISHU_APP_ID "飞书 App ID" "$DEFAULT_FEISHU_APP_ID"
fi
if [[ -z "${FEISHU_APP_SECRET:-}" ]]; then
    read_config_interactive FEISHU_APP_SECRET "飞书 App Secret" ""
fi
if [[ -z "${GATEWAY_TOKEN:-}" ]]; then
    read_config_interactive GATEWAY_TOKEN "Gateway Token" ""
fi
if [[ -z "${TARGET_OPEN_ID:-}" ]]; then
    read_config_interactive TARGET_OPEN_ID "飞书通知目标 Open ID" "$DEFAULT_TARGET_OPEN_ID"
fi

# 写入配置文件
mkdir -p "$CONFIG_DIR"
generate_config_sample | sed "s|^WS_MODULE=.*|WS_MODULE=\"${WS_MODULE_PATH}\"|" > "$CONFIG_FILE"
chmod 600 "$CONFIG_FILE"
echo "✅ 配置文件已写入: $CONFIG_FILE"

# 写入 systemd service
mkdir -p "$SYSTEMD_DIR"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Infra Heartbeat Daemon
After=network.target openclaw-gateway.service

[Service]
Type=simple
ExecStart=${HEARTBEAT_SCRIPT} --config ${CONFIG_FILE}
Restart=always
RestartSec=10
StandardOutput=null
StandardError=null

[Install]
WantedBy=default.target
EOF
echo "✅ Systemd service 已写入: $SERVICE_FILE"

# 重载 systemd 并启用
systemctl --user daemon-reload
systemctl --user enable heartbeat-daemon.service
systemctl --user start heartbeat-daemon.service

echo ""
echo "=== 安装完成 ==="
systemctl --user status heartbeat-daemon.service --no-pager

echo ""
echo "日志: tail -f ~/.openclaw/workspace-infra/heartbeat.log"
echo ""
echo "常用命令:"
echo "  systemctl --user status heartbeat-daemon  # 查看状态"
echo "  systemctl --user restart heartbeat-daemon # 重启"
echo "  journalctl --user -u heartbeat-daemon -f  # 查看日志"


path: scripts/uninstall-heartbeat.sh
content: #!/bin/bash
# 卸载 infra-heartbeat
set -euo pipefail

SYSTEMD_DIR="${HOME}/.config/systemd/user"
SERVICE_FILE="${SYSTEMD_DIR}/heartbeat-daemon.service"
CONFIG_DIR="${HOME}/.config/infra-heartbeat"
LOG_DIR="${HOME}/.openclaw/workspace-infra"

echo "⚠️  即将卸载 infra-heartbeat"
echo ""
echo "将执行:"
echo "  1. 停止 systemd 服务"
echo "  2. 禁用服务"
echo "  3. 删除 service 文件"
echo "  4. 删除配置文件 (可选)"
echo ""
echo -n "是否删除配置文件和日志? [y/N]: "
read -r confirm_config

systemctl --user stop heartbeat-daemon.service 2>/dev/null || true
systemctl --user disable heartbeat-daemon.service 2>/dev/null || true
systemctl --user daemon-reload

rm -f "$SERVICE_FILE"
echo "✅ Service 文件已删除"

if [[ "$confirm_config" =~ ^[Yy]$ ]]; then
    rm -rf "$CONFIG_DIR"
    echo "✅ 配置目录已删除"
    rm -f "${LOG_DIR}/heartbeat.log" "${LOG_DIR}/alerts.log" "${LOG_DIR}/restart-fail-count"
    echo "✅ 日志文件已删除"
fi

echo ""
echo "✅ 卸载完成"
echo "注意: heartbeat-daemon.sh 仍在 skill 目录中，如需完全删除请手动移除"


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)