Executable
+157
@@ -0,0 +1,157 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# agent-pane-track.sh — Agent Team tmux Pane 追踪 Hook
|
||||
#
|
||||
# 用于 PreToolUse(Agent) 和 PostToolUse(Agent) 两个事件
|
||||
# - PreToolUse: 快照当前所有 tmux pane(仅首次),记录 PM pane
|
||||
# - PostToolUse: 对比快照,将新增 pane 记录到 .claude/team-panes.json
|
||||
#
|
||||
# 仅当 tool_input.team_name 非空时(Agent Team 场景)才执行
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# 项目根目录
|
||||
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
STATE_FILE="${PROJECT_ROOT}/.claude/team-panes.json"
|
||||
LOG_FILE="${PROJECT_ROOT}/.claude/team-panes.log"
|
||||
|
||||
# 日志函数
|
||||
log() {
|
||||
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] agent-pane-track: $*"
|
||||
echo "${msg}" >&2
|
||||
echo "${msg}" >> "${LOG_FILE}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# 从 stdin 读取 JSON(3 秒超时防止阻塞)
|
||||
INPUT_JSON=$(timeout 3 cat 2>/dev/null) || exit 0
|
||||
|
||||
if [ -z "${INPUT_JSON}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 提取关键字段
|
||||
HOOK_EVENT=$(echo "${INPUT_JSON}" | jq -r '.hook_event_name // empty' 2>/dev/null)
|
||||
SESSION_ID=$(echo "${INPUT_JSON}" | jq -r '.session_id // empty' 2>/dev/null)
|
||||
TEAM_NAME=$(echo "${INPUT_JSON}" | jq -r '.tool_input.team_name // empty' 2>/dev/null)
|
||||
|
||||
# 非 team agent → 跳过
|
||||
if [ -z "${TEAM_NAME}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "event=${HOOK_EVENT} team=${TEAM_NAME} session=${SESSION_ID}"
|
||||
|
||||
# 快照文件路径(使用 team_name 确保同一 team 共享一个快照)
|
||||
SNAPSHOT_FILE="/tmp/claude-pane-snapshot-${SESSION_ID}-${TEAM_NAME}"
|
||||
|
||||
# 临时目录确保存在
|
||||
mkdir -p "${PROJECT_ROOT}/.claude"
|
||||
|
||||
# ── PreToolUse: 快照当前 pane 列表 ──────────────────────────
|
||||
if [ "${HOOK_EVENT}" == "PreToolUse" ]; then
|
||||
# 记录 PM pane(当前 pane)
|
||||
PM_PANE_ID=""
|
||||
if [ -n "${TMUX_PANE:-}" ]; then
|
||||
PM_PANE_ID="${TMUX_PANE}"
|
||||
else
|
||||
PM_PANE_ID=$(tmux display-message -p '#{pane_id}' 2>/dev/null || echo "")
|
||||
fi
|
||||
|
||||
# 仅在快照不存在时创建(防止并发 Agent 调用覆盖快照)
|
||||
if [ ! -f "${SNAPSHOT_FILE}" ]; then
|
||||
tmux list-panes -a -F '#{pane_id}' 2>/dev/null | sort > "${SNAPSHOT_FILE}" || true
|
||||
log "快照已创建: $(wc -l < "${SNAPSHOT_FILE}") panes, PM=${PM_PANE_ID}"
|
||||
else
|
||||
log "快照已存在,跳过覆盖"
|
||||
fi
|
||||
|
||||
# 初始化状态文件(如果不存在)
|
||||
if [ ! -f "${STATE_FILE}" ]; then
|
||||
TIMESTAMP=$(date -u '+%Y-%m-%dT%H:%M:%S')
|
||||
jq -n \
|
||||
--arg team "${TEAM_NAME}" \
|
||||
--arg pm "${PM_PANE_ID}" \
|
||||
--arg ts "${TIMESTAMP}" \
|
||||
'{
|
||||
team_name: $team,
|
||||
pm_pane_id: $pm,
|
||||
agent_pane_ids: [],
|
||||
created_at: $ts
|
||||
}' > "${STATE_FILE}"
|
||||
log "状态文件已初始化: team=${TEAM_NAME}, pm=${PM_PANE_ID}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── PostToolUse: 对比快照,记录新增 pane ────────────────────
|
||||
if [ "${HOOK_EVENT}" == "PostToolUse" ]; then
|
||||
# 读取快照
|
||||
if [ ! -f "${SNAPSHOT_FILE}" ]; then
|
||||
log "快照文件不存在,跳过 diff"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 获取当前 pane 列表
|
||||
CURRENT_PANES=$(tmux list-panes -a -F '#{pane_id}' 2>/dev/null | sort || true)
|
||||
if [ -z "${CURRENT_PANES}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Diff: 找出新增 pane
|
||||
NEW_PANES=$(comm -13 "${SNAPSHOT_FILE}" <(echo "${CURRENT_PANES}") 2>/dev/null || true)
|
||||
|
||||
if [ -z "${NEW_PANES}" ]; then
|
||||
log "无新增 pane"
|
||||
# 注意: 不删除快照,因为后续 Agent 调用可能还需要它
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "发现新增 pane: $(echo "${NEW_PANES}" | tr '\n' ' ')"
|
||||
|
||||
# 读取 PM pane ID
|
||||
PM_PANE_ID=$(jq -r '.pm_pane_id // empty' "${STATE_FILE}" 2>/dev/null || echo "")
|
||||
|
||||
# ── 将 PM pane 的模型 pane option 复制到新增 pane ──
|
||||
# 确保子 agent pane 继承正确的模型配置(同 session 不同 pane 隔离)
|
||||
if [ -n "${PM_PANE_ID}" ]; then
|
||||
opt_val="$(tmux show-option -p -t "${PM_PANE_ID}" @anthropic_model 2>/dev/null | sed 's/^[^ ]* //' || true)"
|
||||
if [ -n "${opt_val}" ]; then
|
||||
while IFS= read -r pane_id; do
|
||||
[ -z "${pane_id}" ] && continue
|
||||
tmux set-option -p -t "${pane_id}" @anthropic_model "${opt_val}" 2>/dev/null || true
|
||||
done <<< "${NEW_PANES}"
|
||||
log "已复制 PM pane 模型配置 (@anthropic_model=${opt_val}) 到新增 pane"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 读取已有 agent pane IDs
|
||||
EXISTING_IDS=$(jq -r '.agent_pane_ids[]?' "${STATE_FILE}" 2>/dev/null || true)
|
||||
|
||||
# 构建更新后的 agent_pane_ids(去重,排除 PM pane)
|
||||
UPDATED_IDS="${EXISTING_IDS}"
|
||||
while IFS= read -r pane_id; do
|
||||
[ -z "${pane_id}" ] && continue
|
||||
# 排除 PM pane
|
||||
[ "${pane_id}" == "${PM_PANE_ID}" ] && continue
|
||||
# 去重检查
|
||||
if ! echo "${UPDATED_IDS}" | grep -qx "${pane_id}"; then
|
||||
UPDATED_IDS="${UPDATED_IDS}"$'\n'"${pane_id}"
|
||||
fi
|
||||
done <<< "${NEW_PANES}"
|
||||
|
||||
# 转换为 JSON 数组并更新状态文件
|
||||
JSON_ARRAY=$(echo "${UPDATED_IDS}" | grep -v '^$' | sort -u | jq -R . | jq -s .)
|
||||
jq --argjson ids "${JSON_ARRAY}" '.agent_pane_ids = $ids' "${STATE_FILE}" > "${STATE_FILE}.tmp" \
|
||||
&& mv "${STATE_FILE}.tmp" "${STATE_FILE}"
|
||||
|
||||
log "状态文件已更新: agent_panes=$(echo "${UPDATED_IDS}" | grep -v '^$' | sort -u | tr '\n' ' ')"
|
||||
|
||||
# 注意: 不删除快照文件,让所有 Agent 调用共享同一初始快照
|
||||
# 快照将在 TeamDelete 清理时统一删除
|
||||
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# 后台脚本: /clear 后通过 tmux 自动恢复会话标题
|
||||
#
|
||||
# 被 save-title-on-clear.sh 以 nohup 方式启动,独立于 hook 进程运行。
|
||||
# 等待新 session 就绪后发送 /rename <title>。
|
||||
# ============================================================================
|
||||
|
||||
PANE_ID="${1:?用法: restore-title-via-tmux.sh <pane_id> <title_file>}"
|
||||
TITLE_FILE="${2:?需要标题文件路径}"
|
||||
|
||||
# 清理函数
|
||||
cleanup() {
|
||||
rm -f "${TITLE_FILE}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
[ ! -f "${TITLE_FILE}" ] && exit 1
|
||||
TITLE=$(cat "${TITLE_FILE}")
|
||||
|
||||
# Phase 1: 等待 ❯ 消失(当前 session 正在被清除)
|
||||
sleep 2
|
||||
for i in $(seq 1 15); do
|
||||
if ! tmux capture-pane -t "${PANE_ID}" -p | tail -5 | grep -q '❯'; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Phase 2: 等待 ❯ 重新出现(新 session 就绪)
|
||||
for i in $(seq 1 30); do
|
||||
if tmux capture-pane -t "${PANE_ID}" -p | tail -5 | grep -q '❯'; then
|
||||
sleep 1
|
||||
# 发送 /rename 命令
|
||||
tmux send-keys -t "${PANE_ID}" "/rename ${TITLE}"
|
||||
sleep 1
|
||||
tmux send-keys -t "${PANE_ID}" Enter
|
||||
sleep 2
|
||||
# 验证: 检查 ❯ 是否消失(命令已被接受)
|
||||
for j in $(seq 1 5); do
|
||||
if ! tmux capture-pane -t "${PANE_ID}" -p | tail -5 | grep -q '❯'; then
|
||||
exit 0
|
||||
fi
|
||||
tmux send-keys -t "${PANE_ID}" Enter
|
||||
sleep 2
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 超时退出
|
||||
exit 1
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# SessionEnd Hook: /clear 时保存会话标题并启动后台 tmux 恢复
|
||||
#
|
||||
# 流程:
|
||||
# 1. 从 transcript JSONL 提取 customTitle
|
||||
# 2. 保存到临时文件
|
||||
# 3. 启动后台脚本通过 tmux send-keys 发送 /rename
|
||||
# ============================================================================
|
||||
|
||||
# 从 stdin 读取 JSON(3 秒超时防止阻塞)
|
||||
INPUT=$(timeout 3 cat 2>/dev/null) || exit 0
|
||||
[ -z "${INPUT}" ] && exit 0
|
||||
|
||||
# 只在 /clear 时执行
|
||||
REASON=$(echo "${INPUT}" | jq -r '.reason // empty')
|
||||
[ "${REASON}" != "clear" ] && exit 0
|
||||
|
||||
SESSION_ID=$(echo "${INPUT}" | jq -r '.session_id // empty')
|
||||
TRANSCRIPT=$(echo "${INPUT}" | jq -r '.transcript_path // empty')
|
||||
|
||||
# 从 transcript JSONL 提取最后的 customTitle
|
||||
TITLE=""
|
||||
if [ -n "${TRANSCRIPT}" ] && [ -f "${TRANSCRIPT}" ]; then
|
||||
TITLE=$(grep '"custom-title"' "${TRANSCRIPT}" | tail -1 | jq -r '.customTitle // empty' 2>/dev/null)
|
||||
fi
|
||||
|
||||
# 没有标题需要保留
|
||||
[ -z "${TITLE}" ] && exit 0
|
||||
|
||||
# 保存标题到临时文件
|
||||
TMP_FILE="/tmp/claude-saved-title-${SESSION_ID}"
|
||||
printf '%s' "${TITLE}" > "${TMP_FILE}"
|
||||
|
||||
# 获取当前 tmux pane ID
|
||||
TMUX_PANE_ID="${TMUX_PANE:-}"
|
||||
[ -z "${TMUX_PANE_ID}" ] && exit 0
|
||||
|
||||
# 启动后台恢复脚本
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
nohup "${SCRIPT_DIR}/restore-title-via-tmux.sh" "${TMUX_PANE_ID}" "${TMP_FILE}" > /dev/null 2>&1 &
|
||||
disown
|
||||
|
||||
exit 0
|
||||
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# team-pane-cleanup.sh — Agent Team tmux Pane 清理 Hook
|
||||
#
|
||||
# 用于 PostToolUse(TeamDelete) 事件
|
||||
# 读取 .claude/team-panes.json,关闭所有记录的 agent pane
|
||||
# 跳过 PM pane,仅关闭 agent pane
|
||||
#
|
||||
# 清理策略:
|
||||
# 1. Ctrl+C 终止前台进程
|
||||
# 2. 等待 3 秒让 Claude Code 优雅退出
|
||||
# 3. 无条件 kill-pane 强制关闭
|
||||
# 4. 验证 pane 确实已关闭
|
||||
# ============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# 项目根目录
|
||||
PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
|
||||
STATE_FILE="${PROJECT_ROOT}/.claude/team-panes.json"
|
||||
LOG_FILE="${PROJECT_ROOT}/.claude/team-panes.log"
|
||||
|
||||
# 日志函数
|
||||
log() {
|
||||
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] team-pane-cleanup: $*"
|
||||
echo "${msg}" >&2
|
||||
echo "${msg}" >> "${LOG_FILE}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# 读取 stdin(必须读取,但不使用)
|
||||
timeout 3 cat >/dev/null 2>&1 || true
|
||||
|
||||
# 状态文件不存在 → 无需清理
|
||||
if [ ! -f "${STATE_FILE}" ]; then
|
||||
log "状态文件不存在,无需清理"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "开始清理流程"
|
||||
|
||||
# 读取 agent_pane_ids
|
||||
AGENT_PANES=$(jq -r '.agent_pane_ids[]?' "${STATE_FILE}" 2>/dev/null || true)
|
||||
PM_PANE=$(jq -r '.pm_pane_id // empty' "${STATE_FILE}" 2>/dev/null || true)
|
||||
TEAM_NAME=$(jq -r '.team_name // empty' "${STATE_FILE}" 2>/dev/null || true)
|
||||
|
||||
log "team=${TEAM_NAME}, PM=${PM_PANE}, agent_panes=[${AGENT_PANES//$'\n'/, }]"
|
||||
|
||||
if [ -z "${AGENT_PANES}" ]; then
|
||||
log "无 agent pane 需要清理"
|
||||
rm -f "${STATE_FILE}"
|
||||
rm -f /tmp/claude-pane-snapshot-* 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 关闭单个 pane 的函数
|
||||
# 参数: $1 = pane_id
|
||||
# 返回: 0=成功关闭, 1=关闭失败
|
||||
close_pane() {
|
||||
local pane_id="$1"
|
||||
|
||||
# 安全检查:确保不是 PM pane
|
||||
if [ "${pane_id}" == "${PM_PANE}" ]; then
|
||||
log "跳过 PM pane ${pane_id}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 检查 pane 是否仍存在
|
||||
if ! tmux list-panes -t "${pane_id}" 2>/dev/null | head -1 | grep -q .; then
|
||||
log "pane ${pane_id} 已不存在"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "关闭 pane ${pane_id} ..."
|
||||
|
||||
# 第 1 步: 发送 Ctrl+C 终止运行中的进程
|
||||
tmux send-keys -t "${pane_id}" C-c 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# 第 2 步: 再次发送 Ctrl+C(Claude Code 可能需要两次中断才退出)
|
||||
tmux send-keys -t "${pane_id}" C-c 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
# 第 3 步: 如果进程已退出到 bash,发送 exit
|
||||
tmux send-keys -t "${pane_id}" 'exit' Enter 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
# 第 4 步: 无条件 kill-pane(不管 pane 是否仍在)
|
||||
tmux kill-pane -t "${pane_id}" 2>/dev/null || true
|
||||
|
||||
# 第 5 步: 验证 pane 确实已关闭
|
||||
sleep 0.5
|
||||
if tmux list-panes -t "${pane_id}" 2>/dev/null | head -1 | grep -q .; then
|
||||
log "警告: pane ${pane_id} 仍在,尝试二次 kill-pane"
|
||||
tmux kill-pane -t "${pane_id}" 2>/dev/null || true
|
||||
sleep 0.5
|
||||
if tmux list-panes -t "${pane_id}" 2>/dev/null | head -1 | grep -q .; then
|
||||
log "错误: pane ${pane_id} 无法关闭"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
log "pane ${pane_id} 已成功关闭"
|
||||
return 0
|
||||
}
|
||||
|
||||
# 关闭每个 agent pane
|
||||
CLOSED_COUNT=0
|
||||
FAILED_COUNT=0
|
||||
while IFS= read -r pane_id; do
|
||||
[ -z "${pane_id}" ] && continue
|
||||
|
||||
if close_pane "${pane_id}"; then
|
||||
CLOSED_COUNT=$((CLOSED_COUNT + 1))
|
||||
else
|
||||
FAILED_COUNT=$((FAILED_COUNT + 1))
|
||||
fi
|
||||
done <<< "${AGENT_PANES}"
|
||||
|
||||
log "清理完成: 成功=${CLOSED_COUNT}, 失败=${FAILED_COUNT}"
|
||||
|
||||
# 清理状态文件
|
||||
rm -f "${STATE_FILE}"
|
||||
|
||||
# 清理所有关联的快照文件
|
||||
rm -f /tmp/claude-pane-snapshot-* 2>/dev/null || true
|
||||
|
||||
exit 0
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# UserPromptSubmit Hook
|
||||
# 记录用户在 Claude Code 中的输入到 member/{username}.md 文件
|
||||
# ============================================================================
|
||||
|
||||
# 获取当前用户名
|
||||
USER_NAME=$(whoami)
|
||||
|
||||
# 项目根目录(脚本所在位置的上级目录)
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
MEMBER_DIR="${PROJECT_ROOT}/member"
|
||||
|
||||
# 确保 member 目录存在
|
||||
mkdir -p "${MEMBER_DIR}"
|
||||
|
||||
# 用户日志文件
|
||||
USER_LOG="${MEMBER_DIR}/${USER_NAME}.chat.md"
|
||||
|
||||
# 从 stdin 读取 JSON 输入(3 秒超时防止阻塞)
|
||||
INPUT_JSON=$(timeout 3 cat 2>/dev/null) || exit 0
|
||||
|
||||
# 如果输入为空,退出
|
||||
if [ -z "${INPUT_JSON}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 提取用户输入内容
|
||||
# Claude Code UserPromptSubmit hook 传递的 JSON 格式: {"prompt": "用户输入"}
|
||||
USER_PROMPT=$(echo "${INPUT_JSON}" | jq -r '.prompt // empty' 2>/dev/null)
|
||||
|
||||
# 如果无法提取内容,尝试直接使用输入
|
||||
if [ -z "${USER_PROMPT}" ]; then
|
||||
USER_PROMPT="${INPUT_JSON}"
|
||||
fi
|
||||
|
||||
# 如果输入为空,退出
|
||||
if [ -z "${USER_PROMPT}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 排除 .claude/commands/ 中的命令(/isos-pr 等)
|
||||
COMMANDS_DIR="${PROJECT_ROOT}/.claude/commands"
|
||||
if [ -d "${COMMANDS_DIR}" ]; then
|
||||
# 提取命令名(去掉参数部分),如 "/isos-pr xxx" -> "isos-pr"
|
||||
CMD_NAME=$(echo "${USER_PROMPT}" | sed 's|^/\([^ ]*\).*$|\1|')
|
||||
if [ -n "${CMD_NAME}" ] && [ -f "${COMMANDS_DIR}/${CMD_NAME}.md" ]; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# 获取当前时间戳
|
||||
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 如果文件不存在,创建并添加文件头
|
||||
if [ ! -f "${USER_LOG}" ]; then
|
||||
printf '# %s 的对话记录\n\n' "${USER_NAME}" > "${USER_LOG}"
|
||||
fi
|
||||
|
||||
# 追加用户输入到文件(使用 printf 避免内容中的特殊字符导致 heredoc 展开问题)
|
||||
printf '\n## %s\n\n%s\n\n' "${TIMESTAMP}" "${USER_PROMPT}" >> "${USER_LOG}"
|
||||
|
||||
exit 0
|
||||
Reference in New Issue
Block a user