Files
shared/scripts/role/role_lib.sh
T
JefferyandClaude Opus 5 3c0214a927 feat(role): SessionEnd 立即釋放角色鎖,不必等閒置逾時
關掉 CLI 後立刻重開新階段時,角色會被自己上一個階段的殘留鎖擋住:
原本只有「持有者 transcript 閒置超過 30 分鐘」與手動 --unlock 兩條釋放
路徑,而剛關閉的 transcript mtime 還很新,機制無法區分「已關閉」與
「正在別的視窗打字」,因此最久要等 30 分鐘才叫得回角色。

新增 SessionEnd hook(role_unload.sh)作為快速路徑,工作階段正常結束時
立即刪鎖。mtime 閒置逾時後援保留不動 —— SessionEnd 不保證觸發
(kill -9、直接關終端機、WSL 關機、當機都不會跑),少了後援會在異常
結束時把角色鎖死到下次手動解鎖,兩條路徑缺一不可。

只在鎖檔登記的 transcript 等於自己時才釋放:被鎖擋下的第二個階段結束時
同樣會觸發 SessionEnd,若無條件刪鎖會把仍在使用中的第一個階段的鎖一起
刪掉,等於讓單一實例限制形同虛設。無法取得 transcript、sub agent
(ROLE_SKIP_INSTANCE_LOCK=1)、限制已停用(ROLE_SINGLE_INSTANCE=0)
一律不動鎖。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 12:45:58 +08:00

427 lines
17 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ==============================================================================
# 用途:角色(role)系統的共用函式庫。提供統一 log、啟用判斷、角色解析、
# 睡眠時段判斷、AI 行程偵測、摘要 CLI 選擇與呼叫、記憶目錄鎖。
# 本檔僅供 source,不可直接執行。
# 更新時間:2026/07/29 12:55:00
# 相依:bash;摘要路徑需 README 定義的任一 headless CLI。
# 機密:不 echo 任何 token;角色與記憶內容僅在程序記憶體與檔案間傳遞。
# ==============================================================================
ROLE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROLE_STAGE="${ROLE_STAGE:-role}"
ROLE_SUPPORTED_CLIS="claude codex agy opencode copilot"
ROLE_FALLBACK_MODEL="claude-haiku-4-5-20251001"
# ------------------------------------------------------------------------------
# 共用輸出
# ------------------------------------------------------------------------------
role_now() {
# 取得台灣時區的 yyyy/MM/dd HH:mm:ss 時間字串
TZ='Asia/Taipei' date +'%Y/%m/%d %H:%M:%S'
}
role_log() {
# 輸出統一格式訊息([時間][階段][等級]: 訊息,一行一則),一律走 stderr
local level="$1" message="$2" stamp
stamp="$(role_now)"
printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >&2
if [ -n "${ROLE_ERRLOG:-}" ] && [ "$level" = "ERR" ]; then
printf '[%s][%s][%s]: %s\n' "$stamp" "$ROLE_STAGE" "$level" "$message" >> "${ROLE_ERRLOG}" 2>/dev/null
fi
}
role_quit() {
# 記錄原因後以 0 結束:hook 絕不可阻斷使用者流程
role_log "${2:-DBG}" "$1"
exit 0
}
# ------------------------------------------------------------------------------
# 路徑與啟用判斷
# ------------------------------------------------------------------------------
role_home() {
# 角色定義目錄(預設 ~/.roles)
printf '%s' "${ROLE_HOME:-${HOME}/.roles}"
}
role_memory_home() {
# 記憶根目錄(預設 ~/.memory),實際記憶放在 <root>/<角色>/
printf '%s' "${ROLE_MEMORY_HOME:-${HOME}/.memory}"
}
role_is_child() {
# 判斷本次執行是否來自摘要用的子 CLI 行程,避免 hook 遞迴
[ -n "${ROLE_CHILD:-}" ] || [ -n "${WORKLOG_CHILD:-}" ]
}
role_resolve_name() {
# 角色決定順序:ROLE_NAME 環境變數 → <角色目錄>/.active;皆無則輸出空字串
local name="" active
if [ -n "${ROLE_NAME:-}" ]; then
name="${ROLE_NAME}"
else
active="$(role_home)/.active"
[ -f "$active" ] && name="$(head -n 1 "$active" 2>/dev/null | tr -d '[:space:]')"
fi
printf '%s' "$name"
}
# ------------------------------------------------------------------------------
# 角色定義檔:身分(IDENTITY)與人格(SOUL)分離
#
# 新格式把「我是誰」與「我怎麼想」拆開,避免身分設定(來源作品、關係定位)與
# 性格語氣擠在同一段裡:
# <角色目錄>/<ID>.identity.md 角色 ID、顯示名稱、來源、關係定位、簽名 emoji
# <角色目錄>/<ID>.soul.md 本質(nature)、氛圍(vibe
#
# 舊格式為單一 <ID>.md,仍完整支援:解析時新格式優先,找不到才退回舊檔,
# 既有角色不會因升級而失效。可用 role_sleep.sh --migrate <ID> 拆成新格式。
# ------------------------------------------------------------------------------
role_identity_file() { printf '%s/%s.identity.md' "$(role_home)" "$1"; }
role_soul_file() { printf '%s/%s.soul.md' "$(role_home)" "$1"; }
role_legacy_file() { printf '%s/%s.md' "$(role_home)" "$1"; }
role_is_new_format() {
# 只要有 identity 檔就視為新格式(soul 缺失時由呼叫端各自處理)
[ -f "$(role_identity_file "$1")" ]
}
role_file() {
# 角色「主定義檔」路徑:新格式回傳 identity,否則回傳舊的單一檔。
# 保留此函式是為了不動既有「檔案存在即代表角色存在」的判斷邏輯。
local id="$1"
if [ -f "$(role_identity_file "$id")" ]; then
role_identity_file "$id"
else
role_legacy_file "$id"
fi
}
role_enabled() {
# 總開關:ROLE_ENABLED=0 強制停用;=1 強制啟用;未設定時「有可解析且存在的角色」才啟用
case "${ROLE_ENABLED:-}" in
0|false|no) return 1 ;;
1|true|yes) return 0 ;;
esac
local name
name="$(role_resolve_name)"
[ -n "$name" ] && [ -f "$(role_file "$name")" ]
}
role_in_scope() {
# ROLE_SCOPE 為冒號分隔的路徑前綴,未設定則所有目錄都適用
local cwd="$1" scope
[ -n "${ROLE_SCOPE:-}" ] || return 0
IFS=':' read -r -a scopes <<< "${ROLE_SCOPE}"
for scope in "${scopes[@]}"; do
[ -n "$scope" ] || continue
case "$cwd" in "${scope%/}"*) return 0 ;; esac
done
return 1
}
# ------------------------------------------------------------------------------
# 睡眠時段
# ------------------------------------------------------------------------------
role_time_to_minutes() {
# 把 HH:MM 轉成當日分鐘數;格式不合法時回傳空字串
local value="$1" hour minute
case "$value" in
[0-9][0-9]:[0-9][0-9]) ;;
*) return 1 ;;
esac
hour="${value%%:*}"
minute="${value##*:}"
printf '%s' "$((10#${hour} * 60 + 10#${minute}))"
}
role_sleep_start() { printf '%s' "${ROLE_SLEEP_START:-22:00}"; }
role_sleep_end() { printf '%s' "${ROLE_SLEEP_END:-06:00}"; }
role_in_sleep_window() {
# 判斷現在是否落在睡眠時段(預設 22:00 至隔日 06:00,跨午夜)
local start end now
start="$(role_time_to_minutes "$(role_sleep_start)")" || return 1
end="$(role_time_to_minutes "$(role_sleep_end)")" || return 1
now="$(role_time_to_minutes "$(TZ='Asia/Taipei' date +'%H:%M')")" || return 1
if [ "$start" -lt "$end" ]; then
[ "$now" -ge "$start" ] && [ "$now" -lt "$end" ]
else
[ "$now" -ge "$start" ] || [ "$now" -lt "$end" ]
fi
}
# ------------------------------------------------------------------------------
# AI 行程偵測(睡眠排程的前置檢查)
# ------------------------------------------------------------------------------
role_ai_running() {
# 偵測是否有 AI CLI 正在執行;偵測到任何一個即回傳成功(代表「還不能睡」)
local cli pid cmd self="$$"
for cli in $ROLE_SUPPORTED_CLIS; do
for pid in $(pgrep -x "$cli" 2>/dev/null); do
[ "$pid" = "$self" ] && continue
return 0
done
done
for pid in $(pgrep -f '(^|/)(claude|codex|agy|opencode|copilot)([[:space:]]|$)' 2>/dev/null); do
if [ "$pid" = "$self" ] || [ "$pid" = "$PPID" ]; then
continue
fi
cmd="$(ps -o args= -p "$pid" 2>/dev/null)"
case "$cmd" in
*role_sleep.sh*|*role_capture.sh*|*role_load.sh*|*pgrep*) continue ;;
esac
return 0
done
return 1
}
# ------------------------------------------------------------------------------
# 摘要 CLI 選擇與呼叫
# ------------------------------------------------------------------------------
role_detect_current_cli() {
# 判斷實際觸發本次執行的助理環境,避免 auto 因 PATH 順序誤選其他 CLI
if [ -n "${CODEX_THREAD_ID:-}" ] || [ -n "${CODEX_CI:-}" ] || [ -n "${CODEX_MANAGED_PACKAGE_ROOT:-}" ]; then
printf 'codex'; return 0
fi
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDE_CODE_SSE_PORT:-}" ]; then
printf 'claude'; return 0
fi
if [ -n "${AGY_SESSION_ID:-}" ] || [ -n "${AGY_WORKSPACE_ID:-}" ]; then
printf 'agy'; return 0
fi
if [ -n "${OPENCODE_SESSION_ID:-}" ] || [ -n "${OPENCODE_CONFIG:-}" ]; then
printf 'opencode'; return 0
fi
if [ -n "${COPILOT_AGENT_ID:-}" ] || [ -n "${GITHUB_COPILOT_TOKEN:-}" ]; then
printf 'copilot'; return 0
fi
return 0
}
role_select_cli() {
# 選擇摘要/整理用的 headless CLI;可用 ROLE_CLI 強制指定,預設 auto
local requested="${ROLE_CLI:-auto}" cli current
if [ "$requested" != "auto" ]; then
case " ${ROLE_SUPPORTED_CLIS} " in
*" ${requested} "*) ;;
*) role_log "WRN" "ROLE_CLI 不支援:${requested}(可用:auto ${ROLE_SUPPORTED_CLIS}"; return 1 ;;
esac
command -v "$requested" >/dev/null 2>&1 || { role_log "WRN" "找不到 ${requested} CLI"; return 1; }
printf '%s' "$requested"; return 0
fi
current="$(role_detect_current_cli)"
if [ -n "$current" ] && command -v "$current" >/dev/null 2>&1; then
printf '%s' "$current"; return 0
fi
for cli in $ROLE_SUPPORTED_CLIS; do
if command -v "$cli" >/dev/null 2>&1; then
printf '%s' "$cli"; return 0
fi
done
role_log "WRN" "找不到可用 CLI(需要其一:${ROLE_SUPPORTED_CLIS}"
return 1
}
role_run_cli() {
# 呼叫選定 CLI 執行提示詞;子行程一律帶 ROLE_CHILD=1 阻斷 hook 遞迴
local cli="$1" prompt="$2" seconds="${3:-45}" model="${ROLE_MODEL:-}"
[ "$cli" = "claude" ] && [ -z "$model" ] && model="$ROLE_FALLBACK_MODEL"
case "$cli" in
claude) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" claude -p "$prompt" --model "$model" 2>/dev/null ;;
codex) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" codex exec "$prompt" 2>/dev/null ;;
agy) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" agy -p "$prompt" 2>/dev/null ;;
opencode) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" opencode run "$prompt" 2>/dev/null ;;
copilot) ROLE_CHILD=1 WORKLOG_CHILD=1 timeout "$seconds" copilot -p "$prompt" 2>/dev/null ;;
esac
}
# ------------------------------------------------------------------------------
# 記憶目錄鎖:避免睡眠整理與對話寫入同時改動同一份記憶
# ------------------------------------------------------------------------------
role_lock_acquire() {
# 以 mkdir 取得鎖(原子操作);逾時視為前次殘留鎖並強制接手
local role="$1" lock="$(role_memory_home)/$1/.lock" age
mkdir -p "$(dirname "$lock")" 2>/dev/null
if mkdir "$lock" 2>/dev/null; then
printf '%s' "$$" > "$lock/pid" 2>/dev/null
return 0
fi
age="$(find "$lock" -maxdepth 0 -mmin +30 2>/dev/null)"
if [ -n "$age" ]; then
role_log "WRN" "偵測到超過 30 分鐘的殘留鎖,強制接手:${lock}"
rm -rf "$lock" 2>/dev/null
mkdir "$lock" 2>/dev/null && { printf '%s' "$$" > "$lock/pid" 2>/dev/null; return 0; }
fi
return 1
}
role_lock_release() {
# 釋放記憶目錄鎖
rm -rf "$(role_memory_home)/$1/.lock" 2>/dev/null
}
# ------------------------------------------------------------------------------
# 角色單一載入實例
#
# 目的:同一角色同時只被一個工作階段載入,避免使用者同時與兩個相同人格對話。
#
# 釋放分兩條路,兩者缺一不可:
# 1. 快速路徑:SessionEnd hookrole_unload.sh)在工作階段結束時刪掉自己的鎖,讓使用者
# 關掉 CLI 後可以立刻重開新階段叫回角色。
# 2. 後援:以下的 mtime 閒置逾時接手。SessionEnd **不保證觸發**kill -9、直接關終端機
# 視窗、WSL 關機、當機都不會跑),少了它會在異常結束時把角色鎖死到下次手動解鎖。
#
# 為什麼後援以 transcript 檔的 mtime 判斷而非 pidSessionStart hook 無法可靠取得 CLI 主
# 行程的 pid。活躍的工作階段會持續寫入 transcript,因此「該檔多久沒被寫入」是最貼近真實
# 狀態、也不需要清理程序的判斷依據。
#
# 已知取捨:正常關閉才有快速路徑;異常結束仍需等 ROLE_INSTANCE_IDLE_MINUTES(預設 30 分鐘)
# 過期,或手動 role_sleep.sh --unlock。
#
# 設計原則:**寧可誤放行也不要誤鎖** —— 誤鎖的後果是使用者叫不出角色,比偶爾重複載入嚴重。
# 因此無法識別工作階段(例如 hook 未提供 transcript 路徑)時一律放行。
# ------------------------------------------------------------------------------
role_single_instance_enabled() {
case "${ROLE_SINGLE_INSTANCE:-1}" in
0|false|no|off) return 1 ;;
*) return 0 ;;
esac
}
# sub agent 等「非對話」情境要跳過鎖。
#
# 鎖的目的是避免**使用者同時與兩個相同人格對話**;被其他角色派去做事的 sub agent
# 並不是在跟使用者對話,因此不該因為使用者剛好在另一個視窗開著同一個角色而被擋下來
# —— 那會讓「爸爸正在跟西莉卡聊天時,結衣就不能請西莉卡幫忙」這種本該成立的情境失效。
role_skip_instance_lock() {
case "${ROLE_SKIP_INSTANCE_LOCK:-0}" in
1|true|yes|on) return 0 ;;
*) return 1 ;;
esac
}
role_instance_idle_minutes() { printf '%s' "${ROLE_INSTANCE_IDLE_MINUTES:-30}"; }
role_instance_lock_path() { printf '%s/%s.lock' "$(role_home)" "$1"; }
role_instance_lock_field() {
# 從鎖檔取出指定欄位
local lock="$1" key="$2"
[ -f "$lock" ] || return 1
sed -n "s/^${key}=//p" "$lock" 2>/dev/null | head -n 1
}
role_instance_write_lock() {
local role="$1" transcript="$2" cwd="$3" lock
lock="$(role_instance_lock_path "$role")"
mkdir -p "$(dirname "$lock")" 2>/dev/null
{
printf 'transcript=%s\n' "$transcript"
printf 'loaded=%s\n' "$(role_now)"
printf 'cwd=%s\n' "$cwd"
} > "$lock" 2>/dev/null
}
# 回傳 0=可載入(已取得或接手鎖);1=已被其他仍活躍的工作階段持有
role_instance_acquire() {
local role="$1" transcript="$2" cwd="$3" lock holder idle
role_single_instance_enabled || return 0
# 非對話情境(sub agent 等)一律放行且不寫鎖,避免佔用互動式對話的名額
role_skip_instance_lock && return 0
# 無法識別工作階段就放行,不寫鎖:寧可重複也不要把角色鎖死
[ -n "$transcript" ] || return 0
lock="$(role_instance_lock_path "$role")"
if [ ! -f "$lock" ]; then
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
holder="$(role_instance_lock_field "$lock" transcript)"
if [ -z "$holder" ] || [ "$holder" = "$transcript" ]; then
# 同一個工作階段(含 resume 後重新載入)或鎖檔損壞:更新後放行
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
if [ ! -f "$holder" ]; then
role_log "INF" "前一個工作階段的 transcript 已不存在,接手角色鎖"
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
idle="$(find "$holder" -maxdepth 0 -mmin "+$(role_instance_idle_minutes)" 2>/dev/null)"
if [ -n "$idle" ]; then
role_log "INF" "前一個工作階段已閒置超過 $(role_instance_idle_minutes) 分鐘,接手角色鎖"
role_instance_write_lock "$role" "$transcript" "$cwd"
return 0
fi
return 1
}
# 列出可協作的其他角色(排除自己),每行「ID<TAB>顯示名稱<TAB>本質摘要」。
# 角色若不知道有哪些同伴存在,就不會想到派他們協助 —— 這是多人協作能運作的前提。
role_list_peers() {
local self="$1" home file id name nature soul seen_ids=""
home="$(role_home)"
[ -d "$home" ] || return 0
for file in "$home"/*.identity.md "$home"/*.md; do
[ -f "$file" ] || continue
case "$file" in
*.soul.md) continue ;; # soul 不是主定義檔
*.identity.md) id="$(basename "$file" .identity.md)" ;;
*) id="$(basename "$file" .md)"
# 舊檔若已有對應的新格式,避免同一角色列兩次
[ -f "$(role_identity_file "$id")" ] && continue ;;
esac
[ "$id" = "$self" ] && continue
case " ${seen_ids} " in *" ${id} "*) continue ;; esac
seen_ids="${seen_ids} ${id}"
name="$(sed -n 's/^name:[[:space:]]*//p' "$file" 2>/dev/null | head -n 1)"
nature="$(sed -n 's/^nature:[[:space:]]*//p' "$file" 2>/dev/null | head -n 1)"
# 新格式的性格在 soul 檔;frontmatter 無 nature 時退回讀「## 本質」段落首句
soul="$(role_soul_file "$id")"
if [ -z "$nature" ] && [ -f "$soul" ]; then
nature="$(sed -n 's/^nature:[[:space:]]*//p' "$soul" 2>/dev/null | head -n 1)"
[ -n "$nature" ] || nature="$(sed -n '/^## 本質/,/^## /p' "$soul" 2>/dev/null | sed '1d;/^##/d;/^[[:space:]]*$/d' | head -n 1 | cut -c1-60)"
fi
if [ -z "$nature" ]; then
nature="$(sed -n '/^## 本質/,/^## /p' "$file" 2>/dev/null | sed '1d;/^##/d;/^[[:space:]]*$/d' | head -n 1 | cut -c1-60)"
fi
printf '%s\t%s\t%s\n' "$id" "${name:-$id}" "${nature:-(未設定)}"
done
}
role_instance_release() {
rm -f "$(role_instance_lock_path "$1")" 2>/dev/null
}
role_project_name() {
# 專案判定:git remote 的 <owner>/<repo> 優先,其次目錄名
local cwd="$1" origin cleaned owner_repo
[ -d "$cwd" ] || { printf '-'; return 0; }
local project
project="$(basename "$cwd")"
if git -C "$cwd" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
origin="$(git -C "$cwd" remote get-url origin 2>/dev/null)"
if [ -n "$origin" ]; then
cleaned="${origin%.git}"
cleaned="${cleaned##*://}"
cleaned="${cleaned#*@}"
owner_repo="$(printf '%s' "$cleaned" | awk -F/ 'NF>=2 {print $(NF-1)"/"$NF}')"
[ -n "$owner_repo" ] && project="$owner_repo"
fi
fi
printf '%s' "$project"
}