diff --git a/scripts/role/role_sleep.sh b/scripts/role/role_sleep.sh index 088c610..83417fe 100755 --- a/scripts/role/role_sleep.sh +++ b/scripts/role/role_sleep.sh @@ -7,7 +7,7 @@ # 另提供 --nap(CLI 閒置時的小睡整理)、--catchup(cron 未執行時的補跑)、 # --force(手動立即整理)、--export(匯出角色壓縮檔)、 # --install-cron/--remove-cron(排程安裝與移除)、--status(狀態)。 -# 更新時間:2026/07/28 16:02:10 +# 更新時間:2026/07/29 13:13:21 # 相依:bash、node、任一 headless CLI、crontab(僅排程安裝需要)、 # 同目錄的 role_lib.sh 與 memory.js。 # 退出碼:0 成功或無事可做;1 參數錯誤或整理失敗(cron 觸發時不影響使用者)。 @@ -256,14 +256,14 @@ cron_env_prefix() { cron_line() { # 組出 crontab 條目:睡眠時段內每小時檢查一次 printf '0 %s * * * %s %s --run >> %s 2>&1 %s\n' \ - "$(cron_hours)" "$(cron_env_prefix)" "$(cron_quote "${SCRIPT_DIR}/role_sleep.sh")" \ + "$(cron_hours)" "$(cron_env_prefix)" "$(cron_quote "$(launcher_path)")" \ "$(cron_quote "$(sleep_log_path)")" "$CRON_MARKER" } nap_cron_line() { # 組出小睡 crontab 條目:全天依間隔檢查閒置狀態 printf '*/%s * * * * %s %s --nap >> %s 2>&1 %s\n' \ - "$(nap_interval_minutes)" "$(cron_env_prefix)" "$(cron_quote "${SCRIPT_DIR}/role_sleep.sh")" \ + "$(nap_interval_minutes)" "$(cron_env_prefix)" "$(cron_quote "$(launcher_path)")" \ "$(cron_quote "$(sleep_log_path)")" "$NAP_CRON_MARKER" } @@ -297,7 +297,7 @@ brief_hour() { brief_cron_line() { # 組出晨間狀態檢查條目:每日睡眠結束時執行一次 printf '0 %s * * * %s %s --brief >> %s 2>&1 %s\n' \ - "$(brief_hour)" "$(cron_env_prefix)" "$(cron_quote "${SCRIPT_DIR}/role_sleep.sh")" \ + "$(brief_hour)" "$(cron_env_prefix)" "$(cron_quote "$(launcher_path)")" \ "$(cron_quote "$(sleep_log_path)")" "$BRIEF_CRON_MARKER" } @@ -390,10 +390,64 @@ sleep_log_path() { printf '%s/sleep.log' "$(role_home)" } +launcher_path() { + # 排程啟動器:路徑固定不含版本號,crontab 條目一律指向這裡 + printf '%s/bin/role_sleep_launcher.sh' "$(role_home)" +} + +shell_quote() { + # 包成單引號供 shell script 內文使用;與 cron_quote 的差別是不跳脫 % + printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" +} + +write_cron_launcher() { + # 產生排程啟動器:cron 條目指向它,真正要執行的 role_sleep.sh 在觸發當下才解析。 + # + # 為什麼要多這一層:若把安裝當下的版本目錄直接寫進 crontab,plugin 升版、 + # 舊版本目錄被清掉之後,排程就會指向不存在的路徑並**靜默失效** + # (同一類錯誤曾造成排程長期空轉,且因為 cron 不會回報而不易察覺)。 + local path dir + path="$(launcher_path)" + dir="$(dirname "$path")" + mkdir -p "$dir" 2>/dev/null || { role_log "ERR" "無法建立啟動器目錄:${dir}"; return 1; } + { + printf '#!/usr/bin/env bash\n' + printf '# 由 role_sleep.sh --install-cron 自動產生,請勿手動編輯(重跑 --install-cron 會覆蓋)。\n' + printf '# 用途:讓 crontab 條目指向固定路徑,實際執行的版本於觸發當下解析,plugin 升版後不必重裝排程。\n' + printf '# 更新時間:%s\n' "$(TZ='Asia/Taipei' date '+%Y/%m/%d %H:%M:%S')" + cat <<'EOF_LAUNCHER' +set -uo pipefail + +resolve_latest() { + # 同一個 cache 根目錄下可能留有多個版本目錄,取版本號最大者 + ls -d "$1"/*/jsc-generic/*/scripts/role/role_sleep.sh 2>/dev/null | sort -V | tail -n 1 +} + +# 以 Claude Code 端為優先,沒有才找 Codex 端;兩端腳本相同,差別只在安裝位置 +target="$(resolve_latest "${HOME}/.claude/plugins/cache")" +[ -n "$target" ] || target="$(resolve_latest "${HOME}/.codex/plugins/cache")" +EOF_LAUNCHER + printf '[ -n "$target" ] || target=%s # 後援:安裝當下的位置\n' "$(shell_quote "${SCRIPT_DIR}/role_sleep.sh")" + cat <<'EOF_LAUNCHER' + +if [ ! -r "$target" ]; then + printf '[role-sleep][ERR]: 找不到可用的 role_sleep.sh,本次排程略過\n' >&2 + exit 1 +fi + +exec bash "$target" "$@" +EOF_LAUNCHER + } > "$path" || { role_log "ERR" "寫入啟動器失敗:${path}"; return 1; } + chmod +x "$path" 2>/dev/null + return 0 +} + install_cron() { # 安裝或更新睡眠排程;以 marker 註解辨識自己的條目,不動使用者其他排程 command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab,無法安裝排程"; return 1; } mkdir -p "$(role_home)" 2>/dev/null + # 先產生啟動器:cron 條目只認這個固定路徑,實際版本留到觸發當下才解析 + write_cron_launcher || return 1 local current new current="$(crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER" | grep -v -F "$NAP_CRON_MARKER" | grep -v -F "$BRIEF_CRON_MARKER")" new="$(printf '%s\n%s' "$current" "$(cron_line)" | sed '/^$/d')" @@ -416,6 +470,7 @@ install_cron() { else role_log "DBG" "未安裝晨間狀態檢查排程(需建立 $(checks_dir) 並放入可執行的 *.sh)" fi + role_log "INF" "排程啟動器:$(launcher_path)(升版後不必重裝排程)" role_log "INF" "排程輸出:$(sleep_log_path)" if ! pgrep -x cron >/dev/null 2>&1 && ! pgrep -x crond >/dev/null 2>&1; then role_log "WRN" "系統 cron 服務未執行(WSL 常見),排程不會觸發;SessionStart 的背景補跑仍會運作" @@ -427,6 +482,13 @@ remove_cron() { # 移除本 skill 安裝的排程條目 command -v crontab >/dev/null 2>&1 || { role_log "ERR" "找不到 crontab"; return 1; } crontab -l 2>/dev/null | grep -v -F "$CRON_MARKER" | grep -v -F "$NAP_CRON_MARKER" | grep -v -F "$BRIEF_CRON_MARKER" | crontab - + # 啟動器只服務本 skill 的排程,排程移除後一併清掉;bin/ 若還有別的檔案則保留 + local launcher + launcher="$(launcher_path)" + if [ -f "$launcher" ]; then + rm -f "$launcher" && role_log "INF" "已移除排程啟動器:${launcher}" + fi + rmdir "$(dirname "$launcher")" 2>/dev/null || true role_log "INF" "已移除睡眠、小睡與晨間狀態檢查排程" return 0 } @@ -624,6 +686,25 @@ EOF_AGENT return 0 } +cron_target_state() { + # 檢查 crontab 條目實際指向的執行檔還在不在。 + # 舊條目若寫死版本目錄,plugin 升版清掉舊版本後就會指向不存在的路徑並靜默失效, + # cron 不會回報,只能在這裡主動點出來。 + local line target + line="$(crontab -l 2>/dev/null | grep -F "$CRON_MARKER" | head -n 1)" + [ -n "$line" ] || { printf '未安裝'; return 0; } + target="$(printf '%s' "$line" | sed -n "s/.*'\([^']*role_sleep[^']*\)'[[:space:]]*--.*/\1/p")" + if [ -z "$target" ]; then + printf '無法解析條目內容' + elif [ ! -r "$target" ]; then + printf '⚠ 指向不存在的路徑(%s),請重跑 --install-cron' "$target" + elif [ "$target" = "$(launcher_path)" ]; then + printf '正常(%s)' "$target" + else + printf '⚠ 舊式寫死版本路徑(%s),建議重跑 --install-cron' "$target" + fi +} + show_status() { # 以表格輸出目前角色與記憶狀態(供 skill 的 --status 使用) local cron_state="未安裝" nap_state="未安裝" brief_state="未安裝" cron_service="未執行" window="否" checks_state instance_state @@ -661,6 +742,7 @@ show_status() { printf '| cron 排程 | %s |\n' "$cron_state" printf '| 小睡排程 | %s |\n' "$nap_state" printf '| 晨間檢查排程 | %s |\n' "$brief_state" + printf '| 排程指向 | %s |\n' "$(cron_target_state)" printf '| 角色載入鎖 | %s |\n' "$instance_state" printf '| 檢查腳本目錄 | %s |\n' "$checks_state" printf '| 小睡啟用 | %s |\n' "$(nap_enabled && printf '是' || printf '否')" diff --git a/skills/role/SKILL.md b/skills/role/SKILL.md index aea25bf..9bb1dc5 100644 --- a/skills/role/SKILL.md +++ b/skills/role/SKILL.md @@ -395,6 +395,7 @@ node "${ROLE_DIR}/memory.js" forget --role "<角色 ID>" --dry-run | 時段 | 目前是否落在睡眠時段(睡眠時本來就不載入角色) | | 依賴 | `node` 與 `ROLE_CLI` 選到的 CLI 是否找得到 | | 排程 | cron 條目是否存在、cron 服務是否執行中(WSL 常未啟動 → 靠啟動時補跑) | +| 排程指向 | 條目指到的執行檔是否還存在(舊條目寫死版本目錄時會在升版後失效,見下節) | ### `--install-cron`/`--remove-cron` @@ -406,6 +407,25 @@ node "${ROLE_DIR}/memory.js" forget --role "<角色 ID>" --dry-run 安裝時會把精簡後的 `PATH`(系統基本路徑、`node` 與摘要 CLI 所在目錄)與 `ROLE_*` 變數固定寫進條目(cron 沒有互動 shell 的環境變數),並在 cron 服務未執行時警告。不得把互動 shell 的完整 `PATH` 原樣寫入,避免 crontab 因單行過長拒收。 +#### 排程啟動器(為什麼 crontab 不直接指向 `role_sleep.sh`) + +crontab 條目指向的是 `~/.roles/bin/role_sleep_launcher.sh`,這支啟動器由 `--install-cron` 自動產生(`--remove-cron` 會一併刪除),內容只做一件事:解析目前最新的 `role_sleep.sh` 後 `exec` 過去。 + +| 位置 | 是否含版本號 | +| --- | --- | +| crontab 條目 → 啟動器 | ❌ 固定路徑 | +| 啟動器 → 實際腳本 | ✅ 觸發當下才解析 | + +**理由**:plugin 每次升版都會產生新的版本目錄,舊目錄清掉後,寫死版本路徑的 crontab 條目就會指向不存在的檔案。cron 不會回報這種失敗,排程只是**靜默停擺**(此類錯誤曾造成排程長期空轉才被發現)。多這一層之後,升版不必重裝排程。 + +解析順序為 Claude Code 端 → Codex 端,各自取版本號最大者(`sort -V`),都找不到才退回安裝當下的路徑: + +```bash +ls -d "$HOME"/.claude/plugins/cache/*/jsc-generic/*/scripts/role/role_sleep.sh | sort -V | tail -n 1 +``` + +舊版安裝的排程仍是寫死路徑,`--status` 的「排程指向」欄位會標示出來,重跑一次 `--install-cron` 即可轉換。 + --- ## 角色檔標準格式