From 911503d997cc873d2f274bfefa06ea3710af35b6 Mon Sep 17 00:00:00 2001 From: arno Date: Fri, 3 Jul 2026 14:49:44 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD(ClaudeCode):=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=A3=9E=E4=B9=A6=E8=81=94=E5=8A=A8=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=93=BE=E4=B8=8E=E4=BC=9A=E8=AF=9D=E7=AE=A1=E7=90=86=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增命令: lark-delegate(跨项目委派)、lark-inbox(收件箱)、lark-me(自发送)、stop(任务收尾) - 新增工具: lark-consumer/consumer-ensure/inbox-check(飞书事件实时消费与会话隔离)、tmux-monitor(监控面板) - runcc.sh 增加 --list/--kill/--send 操作模式与多会话自增命名 - CLAUDE.md 增加强制要求(汇报格式、收尾通知、上下文压缩) --- .claude/CLAUDE.md | 10 + .claude/commands/arno-util-lark-delegate.md | 70 +++ .claude/commands/arno-util-lark-inbox.md | 57 +++ .claude/commands/arno-util-lark-me.md | 57 +++ .claude/commands/arno-util-stop.md | 34 ++ .claude/tools/.env.example | 4 + .claude/tools/lark-consumer-ensure.sh | 205 ++++++++ .claude/tools/lark-consumer.sh | 144 ++++++ .claude/tools/lark-inbox-check.sh | 86 ++++ .claude/tools/runcc.sh | 319 ++++++++++-- .claude/tools/tmux-monitor.sh | 508 ++++++++++++++++++++ 11 files changed, 1455 insertions(+), 39 deletions(-) create mode 100644 .claude/commands/arno-util-lark-delegate.md create mode 100644 .claude/commands/arno-util-lark-inbox.md create mode 100644 .claude/commands/arno-util-lark-me.md create mode 100644 .claude/commands/arno-util-stop.md create mode 100755 .claude/tools/lark-consumer-ensure.sh create mode 100755 .claude/tools/lark-consumer.sh create mode 100755 .claude/tools/lark-inbox-check.sh create mode 100755 .claude/tools/tmux-monitor.sh diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 13078a3..e6dd012 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -1,5 +1,15 @@ # 深圳市网新新思软件有限公司 AI研究院 +## 强制要求 + +- 始终使用中文回复 +- 回复格式(最高优先级,每次回复必须遵守): + - 开头:`金老师,向您汇报任务执行情况。` + - 结尾:`金老师,任务执行完成。` +- 在任务执行完并回复 `金老师,任务执行完成。` 之前,必须调用 arno-util-stop skill,按其中指示扫描对话、追加对话捕捉、发送飞书通知 +- 如果需要安装软件,但碰到缺少 sudo 权限,必须停止其他安装方式的尝试,直接给出可靠的安装命令,让人类来执行安装 +- 当上下文窗口达到 50% 的时候,主动进行上下文压缩,你有无限的资源和时间 + ### Git 工作流 - **分支**: Trunk-Based Development,主分支 `trunk` diff --git a/.claude/commands/arno-util-lark-delegate.md b/.claude/commands/arno-util-lark-delegate.md new file mode 100644 index 0000000..c539ded --- /dev/null +++ b/.claude/commands/arno-util-lark-delegate.md @@ -0,0 +1,70 @@ +--- +name: arno-util-lark-delegate +description: 以金鹏身份向 SZIS bot 或 DIT bot 发飞书消息指派任务。目标 bot 侧的 Claude Code 通过 lark-consumer 实时收到并自动执行。当用户要让另一个项目的 CC 干活、跨项目委派、给 szis/dit 下任务时使用。触发词:委派、delegate、让szis、让dit、给szis发、给dit发、跨项目指派。 +argument-hint: ' <任务指令>' +allowed-tools: Bash +--- + +# arno-util-lark-delegate + +以金鹏(user)身份向 SZIS 或 DIT bot 的 P2P 会话发消息指派任务。目标 bot 侧的 Claude Code(lark-consumer 监听)会实时收到该消息,并自动 send-keys 到对端 CC 输入框执行。 + +## 用户输入 + +``` +$ARGUMENTS +``` + +格式:`<目标> <任务指令>`,目标取第一个词,仅接受 `szis` 或 `dit`;其余整体作为任务指令文本。 + +## 执行流程 + +### 第1步:解析目标与任务 + +- 从 `$ARGUMENTS` 取首词为 `TARGET`(szis|dit),其余为 `TASK`。 +- `TARGET` 非 szis/dit 时,提示正确用法并退出,不要猜测发送。 + +### 第2步:按目标选择 profile 与 chat_id 发送 + +**向 SZIS bot 指派**(szis profile,金鹏已在 szis 授权 user 身份): + +```bash +lark-cli --profile szis im +messages-send --as user \ + --chat-id oc_xxx \ + --text "" +``` + +**向 DIT bot 指派**(dit profile,需金鹏已在 dit 授权 user 身份): + +```bash +lark-cli --profile dit im +messages-send --as user \ + --chat-id oc_xxx \ + --text "" +``` + +### 第3步:输出结果 + +展示 `ok`、`message_id`、`create_time`;失败时输出完整错误码与信息。 + +## 关键约束 + +- **必须 `--as user`(金鹏身份)**:目标 bot 的 consumer sender 白名单是金鹏;若用 bot 身份发,sender 是 app,会被对端 consumer 直接过滤,任务不会被执行。 +- **profile 与目标一一对应**:向 SZIS 发用 `--profile szis`,向 DIT 发用 `--profile dit`。open_id 与 chat_id 按 app 隔离,跨用会报 `99992361 open_id cross app`。 +- **写操作可见性**:发出的消息会真实进入对端项目的 Claude Code 并执行,指令内容需明确、自洽、可独立理解(对端没有本轮上下文)。 + +## 固定参数 + +| 目标 | profile | P2P chat_id | 对端 Claude Code | +|------|---------|-------------|------------------| +| szis | szis | oc_xxx | arno/szis(tmux ds-ds) | +| dit | dit | oc_xxx | allmed/dit | + +## 示例 + +``` +# 让 SZIS 整理本周对话捕捉 +/arno-util-lark-delegate szis 整理 01-输入/对话捕捉.md 本周新增条目,归类到对应清单 + +# 让 DIT 更新项目推进报告 +/arno-util-lark-delegate dit 用 arno-prj-upd 更新项目推进报告与项目推进分析判断 +``` diff --git a/.claude/commands/arno-util-lark-inbox.md b/.claude/commands/arno-util-lark-inbox.md new file mode 100644 index 0000000..ff5c704 --- /dev/null +++ b/.claude/commands/arno-util-lark-inbox.md @@ -0,0 +1,57 @@ +--- +name: arno-util-lark-inbox +description: 读取金鹏与飞书 bot 的 P2P 私聊记录。优先展示实时事件流(lark-inbox.ndjson),回退到 API 拉取。通过环境变量 LARK_PROFILE/LARK_INBOX_CHAT_ID/LARK_INBOX_NDJSON_PATH/LARK_BOT_NAME 适配不同项目。当用户需要查看飞书收件箱、检查发给 bot 的消息时使用。触发词:收件箱、inbox、查看消息、bot对话、飞书消息。 +argument-hint: '[条数 默认10]' +allowed-tools: Bash +--- + +# arno-util-lark-inbox + +读取金鹏 ↔ bot P2P 私聊记录。**优先增量检查**(调用 `lark-inbox-check.sh --text`),有新消息则展示;有历史消息但无新消息时静默结束;ndjson 不存在时回退 API 拉取。增量检查脚本位于 `../tools/lark-inbox-check.sh`(相对 skill 目录),与 SessionStart hook 共用同一脚本、同一时间戳跟踪。 + +## 环境变量 + +| 变量 | 说明 | 示例 | +|------|------|------| +| `LARK_PROFILE` | lark-cli profile 名 | `szis` / `dit` | +| `LARK_INBOX_CHAT_ID` | P2P 会话 chat_id | `oc_xxx` | +| `LARK_INBOX_NDJSON_PATH` | 实时事件日志路径(相对项目根) | `01-输入/lark-inbox.ndjson` | +| `LARK_BOT_NAME` | bot 显示名称 | `SZIS` / `DIT` | + +## 用户输入 + +``` +$ARGUMENTS +``` + +## 执行流程 + +### 第1步:增量检查(优先) + +调用 `lark-inbox-check.sh --text`,按 tmux session 隔离的时间戳做增量检查(同一 tmux 窗口内多次 CC 会话共享时间戳,避免重复展示),只返回上次检查后的新消息(最多5条)。 + +```bash +lark-inbox-check.sh --text +``` + +- **有新消息** → 展示新消息,结束。 +- **无新消息** → 检查 ndjson 文件是否存在且非空: + - 存在(有历史消息但无新消息)→ **静默结束**,不输出任何内容。 + - 不存在 → 进入第2步(回退 API 拉取)。 + +### 第2步:回退 API 拉取(ndjson 不可用时) + +```bash +lark-cli --profile "${LARK_PROFILE}" im +chat-messages-list --as bot \ + --chat-id "${LARK_INBOX_CHAT_ID}" \ + --sort desc --page-size ${ARGUMENTS:-10} --json +``` + +区分 `sender_type: user`(👤 金鹏)和 `sender_type: app`(🤖 bot)。 + +## 示例 + +``` +/arno-util-lark-inbox # 最近 10 条 +/arno-util-lark-inbox 20 # 最近 20 条 +``` diff --git a/.claude/commands/arno-util-lark-me.md b/.claude/commands/arno-util-lark-me.md new file mode 100644 index 0000000..ed49793 --- /dev/null +++ b/.claude/commands/arno-util-lark-me.md @@ -0,0 +1,57 @@ +--- +name: arno-util-lark-me +description: 飞书 bot 向金鹏私聊发送消息。通过环境变量 LARK_PROFILE/LARK_ME_USER_ID/LARK_BOT_NAME 适配不同项目。当用户需要给自己发飞书提醒、通过 bot 推送通知、快速测试飞书通道时使用。触发词:发给自己、bot消息、飞书提醒、lark-me、自发送。 +argument-hint: '<消息内容>' +allowed-tools: Bash +--- + +# arno-util-lark-me + +bot → 金鹏 P2P 快捷发送。 + +## 环境变量 + +| 变量 | 说明 | 示例 | +|------|------|------| +| `LARK_PROFILE` | lark-cli profile 名 | `szis` / `dit` | +| `LARK_ME_USER_ID` | 金鹏在该应用下的 open_id | `ou_xxx` | +| `LARK_BOT_NAME` | bot 显示名称 | `SZIS` / `DIT` | + +## 用户输入 + +``` +$ARGUMENTS +``` + +## 执行流程 + +### 第1步:发送消息 + +```bash +lark-cli --profile "${LARK_PROFILE}" im +messages-send --as bot \ + --user-id "${LARK_ME_USER_ID}" \ + --text "$ARGUMENTS" +``` + +### 第2步:输出结果 + +展示发送状态、message_id、时间。 + +## 固定参数 + +| 参数 | 值 | +|------|-----| +| 身份 | `--as bot` | +| Profile | `$LARK_PROFILE` | +| 目标 | `$LARK_ME_USER_ID`(金鹏) | +| 类型 | P2P 私聊 | + +## 示例 + +```bash +# 用户输入: /arno-util-lark-me 别忘了下午3点的会议 +# 等价于: +lark-cli --profile "${LARK_PROFILE}" im +messages-send --as bot \ + --user-id "${LARK_ME_USER_ID}" \ + --text "别忘了下午3点的会议" +``` diff --git a/.claude/commands/arno-util-stop.md b/.claude/commands/arno-util-stop.md new file mode 100644 index 0000000..10753a1 --- /dev/null +++ b/.claude/commands/arno-util-stop.md @@ -0,0 +1,34 @@ +--- +name: arno-util-stop +description: 任务执行完毕后的收尾流程——发送对话摘要、扫描新产生的承诺/待办/想法/决定并追加到对话捕捉文件。由 CLAUDE.md 强制要求在任务完成前调用,非用户直接触发。触发词:stop、收尾、任务完成、对话捕捉。 +allowed-tools: Bash, Read, Edit, Skill +--- + +# arno-util-stop + +任务执行完毕后的收尾流程。 + +## 环境变量 + +| 变量 | 说明 | 示例 | +|------|------|------| +| `LARK_PROFILE` | lark-cli profile 名 | `szis` / `dit` | +| `LARK_ME_USER_ID` | 金鹏在该应用下的 open_id | `ou_xxx` | +| `LARK_BOT_NAME` | bot 显示名称 | `SZIS` / `DIT` | + +## 执行流程 + +### 第1步:发送对话摘要 + +通过 `arno-util-lark-me` skill 发出本次对话的简要摘要。 + +### 第2步:扫描并捕捉新事项 + +扫描本次对话中新产生的承诺、待办、想法、决定。 + +- **有**:通过 Edit 追加到 `01-输入/对话捕捉.md`,格式:`- YYYY-MM-DD 类型:承诺/待办/想法/决定 内容 — 来源:上下文`,再通过 `arno-util-lark-me` skill 发出。 +- **无**:完全静默,不要输出任何文字。 + +## 关键指令 + +⚠️ 全程只通过工具操作,不附加任何解释文字。 diff --git a/.claude/tools/.env.example b/.claude/tools/.env.example index 5313a42..c5e4352 100644 --- a/.claude/tools/.env.example +++ b/.claude/tools/.env.example @@ -7,6 +7,10 @@ CC_PROVIDER=glm # settings.json 路径(用于清理残留的旧配置) SETTINGS_FILE=$HOME/.claude/settings.json +# Hook / statusLine 脚本路径(相对于项目根目录,或绝对路径;留空使用默认值) +# STATUS_LINE_SCRIPT=.claude/tools/status_line.sh +# USER_PROMPT_SUBMIT_SCRIPT=.claude/tools/user-prompt-submit.sh + # 公共 skills 仓库路径(经 --add-dir 加载其 .claude/skills/)。留空或注释则不挂载 SKILLS_DIR=/data/git/gitea/aiarno/arno/skills diff --git a/.claude/tools/lark-consumer-ensure.sh b/.claude/tools/lark-consumer-ensure.sh new file mode 100755 index 0000000..d3fc889 --- /dev/null +++ b/.claude/tools/lark-consumer-ensure.sh @@ -0,0 +1,205 @@ +#!/bin/bash +# ============================================================================ +# SessionStart Hook — 飞书事件消费者生命周期管理 +# +# 通过环境变量适配不同项目(szis / dit),PID/日志文件按 LARK_PROFILE 后缀分离, +# cleanup 只清理本项目进程,绝不误杀其他项目的 consumer。 +# 由 settings.json 的 SessionStart hook 无参调用。 +# +# 环境变量(均在项目 settings.json 中配置): +# LARK_PROFILE - lark-cli profile 名称,同时用于进程/日志文件命名隔离 +# LARK_INBOX_NDJSON_PATH - inbox ndjson 文件相对路径(相对于项目根目录) +# 可选: +# SUBMIT_DELAY - tmux send-keys 延迟秒数(默认 0.5) +# LARK_SENDER_ALLOW - sender 白名单 open_id(优先于 LARK_ME_USER_ID) +# LARK_ME_USER_ID - sender 白名单 open_id(fallback) +# ============================================================================ +set -euo pipefail + +PROJECT_ROOT="${PWD}" +INBOX_FILE="${PROJECT_ROOT}/${LARK_INBOX_NDJSON_PATH:-01-输入/lark-inbox.ndjson}" +SUBMIT_DELAY="${SUBMIT_DELAY:-0.5}" +MAX_DIAG_LINES=500 + +# ── 从自身位置推导 consumer 脚本路径(与 ensure 脚本同目录)── +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONSUMER_SCRIPT="${SCRIPT_DIR}/lark-consumer.sh" + +# ── 按 profile 隔离的 PID/日志文件 ── +PROFILE_NAME="${LARK_PROFILE:-szis}" +PID_FILE="/tmp/lark-consumer-${PROFILE_NAME}.pid" +CONSUMER_LOG="/tmp/lark-consumer-${PROFILE_NAME}.log" +DIAG_LOG="/tmp/lark-consumer-ensure-${PROFILE_NAME}.log" + +# ── 日志函数 ── +log() { + local ts lines + ts=$(date '+%Y-%m-%d %H:%M:%S') + echo "[${ts}] $*" >> "${DIAG_LOG}" + lines=$(wc -l < "${DIAG_LOG}" 2>/dev/null || echo 0) + if [ "${lines}" -gt "${MAX_DIAG_LINES}" ]; then + tail -n "${MAX_DIAG_LINES}" "${DIAG_LOG}" > "${DIAG_LOG}.tmp" && mv "${DIAG_LOG}.tmp" "${DIAG_LOG}" + fi +} + +# ── 项目隔离清理:只杀本 profile 的消费者进程 ── +cleanup_stale() { + log "---- 开始清理历史 ${PROFILE_NAME} 消费者进程 ----" + local killed=0 p ancestor sig pgid + local -A PROTECT=() + + # 收集自身及所有祖先 PID(受保护,绝不杀) + set +e + ancestor=$$ + while [ -n "$ancestor" ] && [ "$ancestor" != 0 ] && [ "$ancestor" != 1 ]; do + PROTECT["$ancestor"]=1 + ancestor=$(ps -o ppid= -p "$ancestor" 2>/dev/null | tr -d ' ') + done + log "受保护(自身+祖先) PID: ${!PROTECT[*]}" + + # 1) 按 PID_FILE 里的 leader 杀整个进程组(setsid 启动,leader 即进程组长) + if [ -f "${PID_FILE}" ]; then + local leader + leader=$(cat "${PID_FILE}" 2>/dev/null) + if [ -n "${leader}" ]; then + pgid=$(ps -o pgid= -p "${leader}" 2>/dev/null | tr -d ' ') + if [ -n "${pgid}" ]; then + kill -- -"${pgid}" 2>/dev/null + log "kill PID_FILE 进程组 -${pgid} (leader=${leader})" + fi + fi + fi + + # 2) 按 INBOX_FILE 路径匹配 consumer.sh 进程,PGID 整组清除 + for p in $(pgrep -f "lark-consumer\.sh --inbox-file ${INBOX_FILE}" 2>/dev/null || true); do + [ -n "${PROTECT[$p]:-}" ] && continue + pgid=$(ps -o pgid= -p "$p" 2>/dev/null | tr -d ' ') + if [ -n "${pgid}" ]; then + kill -- -"${pgid}" 2>/dev/null + log "kill consumer 进程组 -${pgid} (pid=$p)" + killed=$((killed + 1)) + fi + done + + # 3) 兜底:杀残留的 --profile consume + for sig in TERM KILL; do + [ "$sig" = "KILL" ] && sleep 0.5 + for p in $(pgrep -f "lark-cli .*--profile ${PROFILE_NAME} .*event consume im\.message\.receive_v1" 2>/dev/null || true); do + [ -n "${PROTECT[$p]:-}" ] && continue + kill "-${sig}" "$p" 2>/dev/null + log "$sig consume pid=$p" + killed=$((killed + 1)) + done + done + set -e + + rm -f "${PID_FILE}" 2>/dev/null || true + log "清理完成,处理 $killed 个进程句柄" +} + +# ── 检测当前 CC pane(沿祖先链找有效 tty,再匹配 tmux pane_tty)── +detect_cc_pane() { + ( + set +e + local pid=$$ tty pane + while [ -n "$pid" ] && [ "$pid" != 0 ] && [ "$pid" != 1 ]; do + tty=$(ps -o tty= -p "$pid" 2>/dev/null | tr -d ' ') + case "$tty" in + ''|'?'|'??') : ;; # 无效 tty,继续向上 + *) + pane=$(tmux list-panes -a -F '#{pane_id} #{pane_tty}' 2>/dev/null \ + | awk -v t="/dev/$tty" '$2==t{print $1; exit}') + [ -n "$pane" ] && { echo "$pane"; exit 0; } + ;; + esac + pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') + done + exit 1 + ) +} + +log "========== lark-consumer-ensure (${PROFILE_NAME}) 开始 ==========" +log "PWD=${PROJECT_ROOT} INBOX=${INBOX_FILE} PID_FILE=${PID_FILE}" + +# ═══ 0. 检查 profile 是否已创建(未创建则安全跳过)═══ +if ! lark-cli profile list 2>/dev/null | grep -q "\"name\": \"${PROFILE_NAME}\""; then + log "profile ${PROFILE_NAME} 未创建,跳过 consumer 启动(待 profile add ${PROFILE_NAME} 后生效)" + exit 0 +fi + +# ═══ 1. 清理历史(仅本项目)═══ +cleanup_stale + +# ═══ 2. 检查 bus daemon ═══ +BUS_JSON=$(lark-cli --profile "${PROFILE_NAME}" event status --json 2>&1 || true) +log "event status: ${BUS_JSON}" +BUS_ALIVE=$(printf '%s' "${BUS_JSON}" | python3 -c " +import sys,json +try: + d=json.load(sys.stdin) + print(str(any(a.get('running',False) for a in d.get('apps',[]))).lower()) +except Exception: + print('false') +" 2>/dev/null || echo "false") +log "BUS_ALIVE=${BUS_ALIVE}" + +# ═══ 3. 确保 inbox 文件存在 ═══ +touch "${INBOX_FILE}" +log "inbox 已就绪" + +# ═══ 4. 检测 CC pane ═══ +CC_PANE="" +CC_PANE=$(detect_cc_pane) || CC_PANE="" +log "CC_PANE=${CC_PANE:-(未检测到)}" + +# ═══ 5. 启动单例消费者(setsid 独立进程组,便于整树清理)═══ +export JQ_EXPR='select(.chat_type=="p2p") | {time: .create_time, msg: .content, id: .message_id, sender: .sender_id}' +export CONSUMER_LOG CONSUMER_SCRIPT INBOX_FILE CC_PANE SUBMIT_DELAY DIAG_LOG +export PROFILE_NAME + +log "启动 ${PROFILE_NAME} 单例消费者..." +setsid bash -c ' + trap "kill 0 2>/dev/null" EXIT + # sleep infinity 保持 lark-cli stdin 不 EOF(lark-cli 默认把 stdin EOF 当退出信号) + sleep infinity | \ + lark-cli --profile "$PROFILE_NAME" event consume im.message.receive_v1 --as bot --jq "$JQ_EXPR" \ + 2>>"$CONSUMER_LOG" \ + | bash "$CONSUMER_SCRIPT" \ + --inbox-file "$INBOX_FILE" \ + --cc-pane "$CC_PANE" \ + --submit-delay "$SUBMIT_DELAY" \ + --diag-log "$DIAG_LOG" \ + --scope "$PROFILE_NAME" +' >>"${CONSUMER_LOG}" 2>&1 & +LEADER=$! +disown 2>/dev/null || true +echo "${LEADER}" > "${PID_FILE}" +log "leader 已启动 PID=${LEADER},写入 ${PID_FILE}" + +# ═══ 6. 等待就绪(最多 10s)═══ +READY=false +for i in $(seq 1 10); do + sleep 1 + if ! kill -0 "${LEADER}" 2>/dev/null; then + log "leader ${LEADER} 在第 ${i}s 已退出" + break + fi + if grep -q 'consumer ready' "${CONSUMER_LOG}" 2>/dev/null; then + READY=true + log "消费者就绪(第 ${i}s)" + break + fi +done + +if [ "${READY}" = false ]; then + log "警告: 未检测到就绪信号" + if kill -0 "${LEADER}" 2>/dev/null; then + log "leader 仍在运行但无 ready 信号" + else + log "leader 已退出,CONSUMER_LOG 末尾:" + tail -20 "${CONSUMER_LOG}" 2>/dev/null >> "${DIAG_LOG}" || true + fi +fi + +log "========== lark-consumer-ensure (${PROFILE_NAME}) 结束 ==========" +exit 0 diff --git a/.claude/tools/lark-consumer.sh b/.claude/tools/lark-consumer.sh new file mode 100755 index 0000000..4e1d995 --- /dev/null +++ b/.claude/tools/lark-consumer.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# ============================================================================ +# 飞书事件消费者 (纯 bash) — 从 lark-cli event consume 管道读取 NDJSON, +# 去重后写入 inbox ndjson 文件,并通过 tmux send-keys 转发到当前 CC pane。 +# +# 用法: lark-consumer.sh --inbox-file <路径> --cc-pane \ +# [--submit-delay <秒>] [--diag-log <路径>] [--scope <标识>] +# +# 由 lark-consumer-ensure.sh 经 setsid 启动,全局单例运行。 +# 所有配置通过命令行参数传入,避免 shell 嵌入转义问题。 +# +# 环境变量: +# LARK_SENDER_ALLOW - sender open_id 白名单(优先) +# LARK_ME_USER_ID - sender open_id 白名单(fallback) +# ============================================================================ +set -eo pipefail +# 不使用 set -u:空关联数组在 -u 下会触发"未绑定的变量"错误 + +# ── 参数解析 ── +INBOX_FILE="" +CC_PANE="" +SUBMIT_DELAY="0.5" +DIAG_LOG="" +SCOPE="singleton" + +while [[ $# -gt 0 ]]; do + case "$1" in + --inbox-file) INBOX_FILE="$2"; shift 2 ;; + --cc-pane) CC_PANE="$2"; shift 2 ;; + --submit-delay) SUBMIT_DELAY="$2"; shift 2 ;; + --diag-log) DIAG_LOG="$2"; shift 2 ;; + --scope) SCOPE="$2"; shift 2 ;; + *) echo "未知参数: $1" >&2; exit 1 ;; + esac +done + +# ── sender 白名单(三级 fallback:LARK_SENDER_ALLOW → LARK_ME_USER_ID → 默认值)── +SENDER_ALLOW="${LARK_SENDER_ALLOW:-${LARK_ME_USER_ID:-ou_2c245a54dbea4bc2197f6d63bd940bb0}}" + +# ── 日志函数 ── +diag() { + [[ -n "${DIAG_LOG}" ]] || return 0 + printf '[%s] [%s] [consumer] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "${SCOPE}" "$*" >> "${DIAG_LOG}" +} + +# ── 检测目标 CC pane 是否存活 ── +pane_alive() { + [[ -z "${CC_PANE}" ]] && return 0 # 非 tmux 模式,永远 true + tmux display-message -t "${CC_PANE}" -p '#{pane_id}' &>/dev/null +} + +# ── 从 ndjson 行提取 JSON 字段 ── +field() { + jq -r "$1 // empty" <<< "$2" 2>/dev/null +} + +# ── 加载已有 message_id 到关联数组(启动去重)── +declare -A SEEN +load_seen() { + [[ -f "${INBOX_FILE}" ]] || return 0 + local line mid + while IFS= read -r line; do + [[ -z "${line}" ]] && continue + mid=$(field '.id' "${line}") || true + [[ -n "${mid}" ]] && SEEN["${mid}"]=1 + done < "${INBOX_FILE}" +} + +diag "消费者 bash 进程启动 (PID=$$)" +diag "inbox=${INBOX_FILE} cc_pane=${CC_PANE} delay=${SUBMIT_DELAY} sender=${SENDER_ALLOW}" + +load_seen +diag "已加载 ${#SEEN[@]} 条历史 message_id" + +# 就绪信号(写入 stderr,由 ensure.sh 经 CONSUMER_LOG 检测) +echo "consumer ready (pid=$$, seen=${#SEEN[@]})" >&2 + +# ── 主循环:逐行读取 lark-cli 输出 ── +LAST_CHECK=$(date +%s) +CHECK_INTERVAL=30 + +while IFS= read -r line; do + [[ -z "${line}" ]] && continue + + # ── 定期巡检 CC pane 存活 ── + _now=$(date +%s) + if (( _now - LAST_CHECK > CHECK_INTERVAL )); then + LAST_CHECK=${_now} + if ! pane_alive; then + diag "目标 pane 已消失(CC/session 退出),消费者主动退出" + exit 0 + fi + fi + + # ── sender 白名单过滤 ── + _sender=$(field '.sender' "${line}") || true + if [[ -n "${SENDER_ALLOW}" && "${_sender}" != "${SENDER_ALLOW}" ]]; then + diag "跳过非白名单 sender=${_sender}" + continue + fi + + # ── 去重 ── + _mid=$(field '.id' "${line}") || true + if [[ -z "${_mid}" ]]; then + diag "警告: 缺少 message_id,跳过: ${line:0:80}" + continue + fi + if [[ -n "${SEEN[${_mid}]:-}" ]]; then + continue + fi + SEEN["${_mid}"]=1 + + # ── 写入 inbox(保留原始多行 msg)── + printf '%s\n' "${line}" >> "${INBOX_FILE}" + diag "新消息写入 inbox: id=${_mid}" + + # ── send-keys 转发到 CC pane ── + if [[ -n "${CC_PANE}" ]]; then + if ! pane_alive; then + diag "转发前检测到 pane 已消失,消费者退出" + exit 0 + fi + _msg=$(field '.msg' "${line}") || true + if [[ -n "${_msg}" ]]; then + # 多行压平:避免 tmux send-keys 把换行当多次回车,导致一条消息被拆成多次提交 + _flat=$(printf '%s' "${_msg}" | tr '\n' ' ' | tr -s ' ') + diag "转发 → pane=${CC_PANE} len=${#_flat}" + if ! tmux send-keys -t "${CC_PANE}" -l "${_flat}" 2>/dev/null; then + diag "转发失败: send-keys -l 返回非零,pane=${CC_PANE} 可能已不存在" + continue + fi + sleep "${SUBMIT_DELAY}" + if ! tmux send-keys -t "${CC_PANE}" Enter 2>/dev/null; then + diag "转发失败: send-keys Enter 返回非零,pane=${CC_PANE} 可能已不存在" + continue + fi + diag "转发完成" + fi + else + diag "cc_pane 为空,仅写 inbox" + fi +done + +diag "stdin 已关闭(lark-cli 退出),消费者结束" diff --git a/.claude/tools/lark-inbox-check.sh b/.claude/tools/lark-inbox-check.sh new file mode 100755 index 0000000..11451c3 --- /dev/null +++ b/.claude/tools/lark-inbox-check.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# ============================================================================ +# SessionStart Hook — 飞书收件箱检查 +# 检查实时事件日志中是否有金鹏新发送给 bot 的消息 +# +# 多 Session 隔离:每个 tmux session 使用独立的时间戳文件, +# 通过 tmux session name 区分不同 session,互不干扰。 +# tmux session name 比 PPID 更稳定——即使 Claude Code 重启,同一 tmux 窗口仍复用同一时间戳。 +# +# 环境变量: +# LARK_INBOX_NDJSON_PATH - inbox ndjson 相对路径(必需) +# LARK_BOT_NAME - bot 显示名称(默认 "bot") +# ============================================================================ +set -euo pipefail + +PROJECT_ROOT="${PWD}" +INBOX_FILE="${PROJECT_ROOT}/${LARK_INBOX_NDJSON_PATH:-01-输入/lark-inbox.ndjson}" +CHECK_DIR="${PROJECT_ROOT}/.claude/.lark-inbox-checks" +BOT_NAME="${LARK_BOT_NAME:-bot}" + +# ── 确保检查目录存在 & 清理过期 session 文件 ───────────────── +mkdir -p "${CHECK_DIR}" +find "${CHECK_DIR}" -type f -mtime +7 -delete 2>/dev/null || true + +# ── 会话标识 & 专属时间戳文件 ────────────────────────────────── +# 使用 tmux session name 作为稳定标识,非 tmux 环境回退到 PPID +if [ -n "${TMUX:-}" ]; then + SESSION_ID="tmux-$(tmux display-message -p '#S' 2>/dev/null || echo "unknown")" +else + SESSION_ID="notmux-${PPID}" +fi +SESSION_FILE="${CHECK_DIR}/${SESSION_ID}" + +# ── 获取本 session 的上次检查时间戳 ────────────────────────── +if [ -f "${SESSION_FILE}" ]; then + LAST_TS=$(cat "${SESSION_FILE}") +else + LAST_TS=0 +fi + +# ── 检查 ndjson 文件 ─────────────────────────────────────────── +if [ ! -s "${INBOX_FILE}" ]; then + exit 0 +fi + +# ── 提取新消息 ───────────────────────────────────────────────── +NEW_MSGS=$(python3 -c " +import json, sys +from datetime import datetime + +last_ts = ${LAST_TS} +new_msgs = [] + +with open('${INBOX_FILE}') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + ts = int(d['time']) + if ts > last_ts: + dt = datetime.fromtimestamp(ts / 1000).strftime('%H:%M') + new_msgs.append(f'[{dt}] {d[\"msg\"]}') + except: + pass + +if new_msgs: + print('\n'.join(new_msgs[-5:])) # 最多显示最近5条 +" 2>/dev/null) + +# ── 更新本 session 的时间戳(毫秒)───────────────────────────── +date +%s%3N > "${SESSION_FILE}" + +# ── 无新消息则退出 ───────────────────────────────────────────── +if [ -z "${NEW_MSGS}" ]; then + exit 0 +fi + +# ── 输出模式:--text 纯文本(供 skill 调用),默认 hook JSON ──── +if [ "${1:-}" = "--text" ]; then + printf '%s\n' "${NEW_MSGS}" +else + ESCAPED=$(printf '📬 飞书收件箱有新消息(金鹏→%s):\n%s\n\n请在简报末尾简要列出这些新消息。' "${BOT_NAME}" "${NEW_MSGS}" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))") + printf '{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":%s}}\n' "${ESCAPED}" +fi diff --git a/.claude/tools/runcc.sh b/.claude/tools/runcc.sh index 32bbfec..b3ccae2 100755 --- a/.claude/tools/runcc.sh +++ b/.claude/tools/runcc.sh @@ -3,6 +3,11 @@ # 支持多 Provider (通过 .env 配置,自动解析 ${PROVIDER}_BASE_URL / ${PROVIDER}_AUTH_TOKEN) # # 每天首次启动时自动执行 claude upgrade +# 始终通过 tmux 启动 claude,每次启动创建独立 session(以当前目录名命名) +# 同目录可多开,session 名末尾自增数字(dit-1 → dit-2 → dit-3) +# pane 标题与 CC --name 使用 session_model 组合(如 dit-1_glm-5.2),window 名使用模型名 +# +# tmux 配置参考: https://gitea.szis.com.cn/isos/team/src/branch/trunk/team/tmux.md # ── 路径配置 ────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -33,6 +38,10 @@ fi SESSION_NAME="" MODEL_KEY="" PROVIDER_OVERRIDE="" +ACTION="launch" # launch | list | kill | send +KILL_TARGET="" +SEND_TARGET="" +SEND_MESSAGE="" while [ $# -gt 0 ]; do case "$1" in @@ -44,11 +53,28 @@ while [ $# -gt 0 ]; do [ $# -ge 2 ] || { echo "错误: --provider 需要参数" >&2; exit 1; } PROVIDER_OVERRIDE="$2"; shift 2 ;; + --list|-l) + ACTION="list"; shift + ;; + --kill|-k) + [ $# -ge 2 ] || { echo "错误: --kill 需要 session 名称参数" >&2; exit 1; } + ACTION="kill"; KILL_TARGET="$2"; shift 2 + ;; + --send|-s) + [ $# -ge 3 ] || { echo "错误: --send 需要 <消息>" >&2; exit 1; } + ACTION="send"; SEND_TARGET="$2"; SEND_MESSAGE="$3"; shift 3 + ;; -h|--help) echo "用法: runcc.sh [选项] [模型]" echo "" - echo "选项:" - echo " --name, -n <名称> Claude Code 会话显示名称" + echo "操作模式:" + echo " (默认) 启动 Claude Code 会话" + echo " --list, -l 列出所有 CC tmux 会话(含 provider/模型信息)" + echo " --kill, -k <名称> 终止指定 CC 会话" + echo " --send, -s <会话> <消息> 向已有 CC 会话发送提示词" + echo "" + echo "启动选项:" + echo " --name, -n <名称> 会话显示名称" echo " --provider, -p <名称> API 提供商 (由 .env 配置,如 glm / deepseek)" echo "" echo "GLM 模型: 45|45a|46|47|5|5t|51|52" @@ -59,13 +85,18 @@ while [ $# -gt 0 ]; do echo "Provider 变量规则: CC_PROVIDER=_BASE_URL + _AUTH_TOKEN" echo " 添加新 Provider 只需在 .env 中按此格式添加变量即可" echo "" + echo "快捷方式 (通过 ~/.bashrc 别名):" + echo " cc [模型] 默认 glm provider" + echo " ccds [模型] 默认 deepseek provider" + echo "" echo "示例:" echo " runcc.sh # 默认 Provider + 默认模型" echo " runcc.sh 47 # 默认 Provider + glm-4.7" echo " runcc.sh --provider qwen # Qwen + 默认模型" echo " runcc.sh -p qwen qw37 # Qwen + qwen3.7-max" - echo " runcc.sh --provider deepseek # DeepSeek + 默认模型" - echo " runcc.sh -p deepseek dsf # DeepSeek + deepseek-v4-flash" + echo " runcc.sh -l # 列出所有 CC 会话" + echo " runcc.sh -k glm-52 # 终止 glm-52 会话" + echo " runcc.sh -s glm-52 '你好' # 向 glm-52 发送消息" exit 0 ;; *) @@ -74,10 +105,65 @@ while [ $# -gt 0 ]; do esac done +# ── 操作: --list ────────────────────────────────────────── +if [ "$ACTION" = "list" ]; then + echo "CC tmux 会话列表:" + echo "──────────────────────────────────────────────────────" + CC_COUNT=0 + for s in $(tmux list-sessions -F '#{session_name}' 2>/dev/null); do + if tmux show-options -t "$s" @cc_provider 2>/dev/null | grep -q .; then + CC_COUNT=$((CC_COUNT + 1)) + created=$(tmux display -t "$s" -p '#{session_created}' 2>/dev/null) + attached=$(tmux display -t "$s" -p '#{?session_attached,●,○}' 2>/dev/null) + provider=$(tmux show-options -t "$s" -v @cc_provider 2>/dev/null) + model=$(tmux show-options -t "$s" -v @cc_model 2>/dev/null) + printf " %s %s %s @cc_provider=%s @cc_model=%s\n" "$attached" "$s" "$created" "$provider" "$model" + fi + done + if [ "$CC_COUNT" -eq 0 ]; then + echo " (无 CC 会话)" + fi + echo "──────────────────────────────────────────────────────" + echo "●=已附加 ○=后台运行" + exit 0 +fi + +# ── 操作: --kill ────────────────────────────────────────── +if [ "$ACTION" = "kill" ]; then + if ! tmux has-session -t "$KILL_TARGET" 2>/dev/null; then + echo "错误: 会话 '$KILL_TARGET' 不存在" >&2 + echo "可用 CC 会话:" + CC_LISTED=0 + for s in $(tmux list-sessions -F '#{session_name}' 2>/dev/null); do + if tmux show-options -t "$s" @cc_provider 2>/dev/null | grep -q .; then + echo " $s" + CC_LISTED=1 + fi + done + [ "$CC_LISTED" -eq 0 ] && echo " (无)" + exit 1 + fi + tmux kill-session -t "$KILL_TARGET" + echo "已终止会话: $KILL_TARGET" + exit 0 +fi + +# ── 操作: --send ────────────────────────────────────────── +if [ "$ACTION" = "send" ]; then + if ! tmux has-session -t "$SEND_TARGET" 2>/dev/null; then + echo "错误: 会话 '$SEND_TARGET' 不存在" >&2 + exit 1 + fi + # 找第一个 pane(通常是 claude 主进程) + PANE_ID="$(tmux list-panes -t "$SEND_TARGET" -F '#{pane_id}' | head -1)" + tmux send-keys -t "$PANE_ID" -l "$SEND_MESSAGE" + tmux send-keys -t "$PANE_ID" Enter + echo "已向 $SEND_TARGET ($PANE_ID) 发送消息" + exit 0 +fi + # ── Provider 动态解析 ───────────────────────────────────── -# CC_PROVIDER=glm → GLM_BASE_URL, GLM_AUTH_TOKEN, GLM_DEFAULT_MODEL -# CC_PROVIDER=deepseek → DEEPSEEK_BASE_URL, DEEPSEEK_AUTH_TOKEN, DEEPSEEK_DEFAULT_MODEL -# CC_PROVIDER=xxx → XXX_BASE_URL, XXX_AUTH_TOKEN, XXX_DEFAULT_MODEL, XXX_CONTEXT_WINDOW +# 优先级:命令行 --provider > .env CC_PROVIDER > 默认 glm CC_PROVIDER="${PROVIDER_OVERRIDE:-${CC_PROVIDER:-glm}}" upper_provider="$(echo "$CC_PROVIDER" | tr '[:lower:]' '[:upper:]')" @@ -115,13 +201,46 @@ else fi # ── 更新 settings.json ────────────────────────────────── -# 删除凭据、模型和窗口变量(由环境变量接管) -# SETTINGS_FILE 路径由 .env 配置,默认 ~/.claude/settings.json +# 1) 删除凭据、模型和窗口变量(由环境变量接管) +# 2) 注入 statusLine / UserPromptSubmit hook(不存在则写入绝对路径) +# SETTINGS_FILE / STATUS_LINE_SCRIPT / USER_PROMPT_SUBMIT_SCRIPT 路径由 .env 配置 SETTINGS_FILE="${SETTINGS_FILE:-$HOME/.claude/settings.json}" +# 项目根目录:优先用 .env 的 SKILLS_DIR(语义清晰、跨机器可迁移);未配置则从脚本位置反推兜底 +PROJECT_DIR="${SKILLS_DIR:-$(dirname "$(dirname "$SCRIPT_DIR")")}" +STATUS_LINE_SCRIPT="${STATUS_LINE_SCRIPT:-.claude/tools/status_line.sh}" +USER_PROMPT_SUBMIT_SCRIPT="${USER_PROMPT_SUBMIT_SCRIPT:-.claude/tools/user-prompt-submit.sh}" + +# 相对路径 → 基于项目根目录的绝对路径 +if [[ "$STATUS_LINE_SCRIPT" != /* ]]; then + STATUS_LINE_SCRIPT="${PROJECT_DIR}/${STATUS_LINE_SCRIPT#./}" +fi +if [[ "$USER_PROMPT_SUBMIT_SCRIPT" != /* ]]; then + USER_PROMPT_SUBMIT_SCRIPT="${PROJECT_DIR}/${USER_PROMPT_SUBMIT_SCRIPT#./}" +fi + if command -v jq &>/dev/null && [ -f "$SETTINGS_FILE" ]; then + # 清理残留的环境变量配置 jq 'del(.env.ANTHROPIC_BASE_URL, .env.ANTHROPIC_AUTH_TOKEN, .env.CLAUDE_CODE_AUTO_COMPACT_WINDOW, .env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE, .env.DISABLE_COMPACT, .env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, .env.ANTHROPIC_DEFAULT_OPUS_MODEL, .env.ANTHROPIC_DEFAULT_HAIKU_MODEL, .env.ANTHROPIC_DEFAULT_SONNET_MODEL)' \ "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \ && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" + + # 确保 statusLine 已配置且脚本可执行(键不存在或路径失效则写入/纠正) + SL_CUR="$(jq -r '.statusLine.command // empty' "$SETTINGS_FILE" 2>/dev/null)" + if [ -z "$SL_CUR" ] || [ ! -x "${SL_CUR%% *}" ]; then + jq --arg cmd "$STATUS_LINE_SCRIPT" \ + '. + {"statusLine": {"type": "command", "command": $cmd}}' \ + "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \ + && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" + fi + + # 确保 UserPromptSubmit hook 已配置且脚本可执行(键不存在或路径失效则写入/纠正) + UPS_CUR="$(jq -r '.hooks.UserPromptSubmit[0].hooks[0].command // empty' "$SETTINGS_FILE" 2>/dev/null)" + if [ -z "$UPS_CUR" ] || [ ! -x "${UPS_CUR%% *}" ]; then + jq --arg cmd "$USER_PROMPT_SUBMIT_SCRIPT" \ + '.hooks += {"UserPromptSubmit": [{"hooks": [{"type": "command", "command": $cmd}]}]}' \ + "$SETTINGS_FILE" > "${SETTINGS_FILE}.tmp" \ + && mv "${SETTINGS_FILE}.tmp" "$SETTINGS_FILE" + fi fi # ── 模型解析 ────────────────────────────────────────────── @@ -154,27 +273,20 @@ export ANTHROPIC_DEFAULT_HAIKU_MODEL="$MODEL" export ANTHROPIC_DEFAULT_SONNET_MODEL="$MODEL" export ANTHROPIC_DEFAULT_OPUS_MODEL="$MODEL" export CLAUDE_CODE_SUBAGENT_MODEL="$MODEL" +export CLAUDE_CODE_NO_FLICKER=1 -# ── tmux 模型传播(确保子 pane 能获取正确模型)───────── -if [ -n "${TMUX:-}" ]; then - SESSION_ID="$(tmux display-message -p '#{session_id}')" +# ── 会话命名规则:目录名-序号(如 dit-1、szis-2)─────────── +DIR_NAME=$(basename "$PWD") +BASE_TITLE="${DIR_NAME}" +if [ -z "$SESSION_NAME" ]; then + SESSION_NAME="${BASE_TITLE}-1" +fi - tmux setenv -t "$SESSION_ID" ANTHROPIC_DEFAULT_OPUS_MODEL "$MODEL" - tmux setenv -t "$SESSION_ID" ANTHROPIC_DEFAULT_HAIKU_MODEL "$MODEL" - tmux setenv -t "$SESSION_ID" ANTHROPIC_DEFAULT_SONNET_MODEL "$MODEL" - tmux setenv -t "$SESSION_ID" CC_CONTEXT_WINDOW "$CONTEXT_WINDOW" - - if [ "$DISABLE_COMPACT" = "1" ]; then - tmux setenv -t "$SESSION_ID" DISABLE_COMPACT 1 - tmux setenv -t "$SESSION_ID" CLAUDE_CODE_MAX_CONTEXT_TOKENS "${MAX_CONTEXT_TOKENS:-$CONTEXT_WINDOW}" - tmux setenv -u -t "$SESSION_ID" CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 2>/dev/null || true - else - tmux setenv -t "$SESSION_ID" CLAUDE_AUTOCOMPACT_PCT_OVERRIDE "$COMPACT_PCT" - tmux setenv -u -t "$SESSION_ID" DISABLE_COMPACT 2>/dev/null || true - tmux setenv -u -t "$SESSION_ID" CLAUDE_CODE_MAX_CONTEXT_TOKENS 2>/dev/null || true - fi - - tmux set-option -p @anthropic_model "$MODEL" +# ── 构建 claude 启动参数 ───────────────────────────────── +CC_ARGS=(--ide --dangerously-skip-permissions --allow-dangerously-skip-permissions --effort max) +# 公共 skills 仓库(由 .env 的 SKILLS_DIR 配置,便于跨电脑迁移) +if [ -n "${SKILLS_DIR:-}" ]; then + CC_ARGS+=(--add-dir "$SKILLS_DIR") fi # ── 每日自动更新 ──────────────────────────────────────── @@ -190,17 +302,146 @@ if [ ! -f "$UPGRADE_MARKER" ] || [ "$(cat "$UPGRADE_MARKER" 2>/dev/null)" != "$T fi fi -# ── 启动 Claude Code ──────────────────────────────────── -CC_ARGS=(--ide --dangerously-skip-permissions --allow-dangerously-skip-permissions --effort max) -# CC_ARGS=(--verbose) -# 公共 skills 仓库(由 .env 的 SKILLS_DIR 配置,便于跨电脑迁移) -if [ -n "${SKILLS_DIR:-}" ]; then - CC_ARGS+=(--add-dir "$SKILLS_DIR") +# ── 辅助函数:自动编号(列表中出现同名则递增)───────────── +# 用法: _auto_number "名字列表(换行分隔)" "基础名" → 输出下一个可用名 +_auto_number() { + local list="$1" + local base="$2" + local name="$base" + local n=1 + while echo "$list" | grep -qxF "$name"; do + n=$((n + 1)) + name="${base}-${n}" + done + echo "$name" +} + +# ════════════════════════════════════════════════════════════ +# ── 启动 Claude Code(始终通过 tmux)────────────────────── +# ════════════════════════════════════════════════════════════ + +ORIG_PWD="$PWD" +WINDOW_NAME="$MODEL" + +# ── 构建 -e 参数数组 ──────────────────────────────────── +E_FLAGS=() +E_FLAGS+=(-e "ANTHROPIC_BASE_URL=$ANTHROPIC_BASE_URL") +E_FLAGS+=(-e "ANTHROPIC_AUTH_TOKEN=$ANTHROPIC_AUTH_TOKEN") +E_FLAGS+=(-e "ANTHROPIC_MODEL=$MODEL") +E_FLAGS+=(-e "ANTHROPIC_DEFAULT_OPUS_MODEL=$MODEL") +E_FLAGS+=(-e "ANTHROPIC_DEFAULT_HAIKU_MODEL=$MODEL") +E_FLAGS+=(-e "ANTHROPIC_DEFAULT_SONNET_MODEL=$MODEL") +E_FLAGS+=(-e "CLAUDE_CODE_SUBAGENT_MODEL=$MODEL") +E_FLAGS+=(-e "CC_CONTEXT_WINDOW=$CONTEXT_WINDOW") +E_FLAGS+=(-e "CLAUDE_CODE_NO_FLICKER=1") + +if [ "$DISABLE_COMPACT" = "1" ]; then + E_FLAGS+=(-e "DISABLE_COMPACT=1") + E_FLAGS+=(-e "CLAUDE_CODE_MAX_CONTEXT_TOKENS=${MAX_CONTEXT_TOKENS:-$CONTEXT_WINDOW}") +else + E_FLAGS+=(-e "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=$COMPACT_PCT") fi -if [ -n "$SESSION_NAME" ]; then - INITIAL_PROMPT="/rename $SESSION_NAME" -fi +if [ -n "${TMUX:-}" ]; then + # ── 已在 tmux 中:pane 标题 = session_model → 设置 env → 直接启动 + TMUX_TITLE="${SESSION_NAME}_${MODEL}" + CC_ARGS+=(--name "$TMUX_TITLE") -echo "启动 Claude Code (provider: $CC_PROVIDER, 模型: $MODEL${SESSION_NAME:+, 会话: $SESSION_NAME})" -claude "${CC_ARGS[@]}" ${INITIAL_PROMPT:+"$INITIAL_PROMPT"} + echo "启动 Claude Code (provider: $CC_PROVIDER, 模型: $MODEL, 会话: $SESSION_NAME)" + + tmux select-pane -T "$TMUX_TITLE" 2>/dev/null || true + + # 传播环境变量到 tmux session(供子 pane/window 继承) + SID="$(tmux display-message -p '#{session_id}')" + tmux setenv -t "$SID" ANTHROPIC_BASE_URL "$ANTHROPIC_BASE_URL" + tmux setenv -t "$SID" ANTHROPIC_AUTH_TOKEN "$ANTHROPIC_AUTH_TOKEN" + tmux setenv -t "$SID" ANTHROPIC_MODEL "$MODEL" + tmux setenv -t "$SID" ANTHROPIC_DEFAULT_OPUS_MODEL "$MODEL" + tmux setenv -t "$SID" ANTHROPIC_DEFAULT_HAIKU_MODEL "$MODEL" + tmux setenv -t "$SID" ANTHROPIC_DEFAULT_SONNET_MODEL "$MODEL" + tmux setenv -t "$SID" CLAUDE_CODE_SUBAGENT_MODEL "$MODEL" + tmux setenv -t "$SID" CC_CONTEXT_WINDOW "$CONTEXT_WINDOW" + tmux setenv -t "$SID" CLAUDE_CODE_NO_FLICKER 1 + + if [ "$DISABLE_COMPACT" = "1" ]; then + tmux setenv -t "$SID" DISABLE_COMPACT 1 + tmux setenv -t "$SID" CLAUDE_CODE_MAX_CONTEXT_TOKENS "${MAX_CONTEXT_TOKENS:-$CONTEXT_WINDOW}" + tmux setenv -u -t "$SID" CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 2>/dev/null || true + else + tmux setenv -t "$SID" CLAUDE_AUTOCOMPACT_PCT_OVERRIDE "$COMPACT_PCT" + tmux setenv -u -t "$SID" DISABLE_COMPACT 2>/dev/null || true + tmux setenv -u -t "$SID" CLAUDE_CODE_MAX_CONTEXT_TOKENS 2>/dev/null || true + fi + + # 设置 pane 级属性(供 status_line 等读取) + tmux set-option -p @anthropic_model "$MODEL" 2>/dev/null || true + + # 环境变量已在当前 shell 中 export,直接 exec + cd "$ORIG_PWD" + exec claude "${CC_ARGS[@]}" + +else + # ── 不在 tmux 中:以目录名-序号创建独立 session(如 dit-1、dit-2) + n=1 + while tmux has-session -t "${BASE_TITLE}-${n}" 2>/dev/null; do + n=$((n + 1)) + done + TMUX_SESSION="${BASE_TITLE}-${n}" + SESSION_NAME="$TMUX_SESSION" + TMUX_TITLE="${SESSION_NAME}_${MODEL}" + CC_ARGS+=(--name "$TMUX_TITLE") + + echo "启动 Claude Code (provider: $CC_PROVIDER, 模型: $MODEL, tmux session: $TMUX_SESSION)" + + # 构建 claude 启动命令 + CC_CMD="claude" + for arg in "${CC_ARGS[@]}"; do + CC_CMD+=" $arg" + done + + BASH_WRAPPER="if ! command -v claude &>/dev/null; then + echo '错误: claude 命令未找到' + echo -n '按 Enter 退出...'; read + exit 1 + fi + exec ${CC_CMD}" + + # 创建独立 tmux session(session 名 = MODEL 名) + tmux new-session -d -s "$TMUX_SESSION" -n "$WINDOW_NAME" -c "$ORIG_PWD" \ + "${E_FLAGS[@]}" bash -c "$BASH_WRAPPER" + + # 设置 pane 标题 + tmux select-pane -t "$TMUX_SESSION:$WINDOW_NAME" -T "$TMUX_TITLE" 2>/dev/null || true + + # ── tmux 会话级默认配置 ────────────────────────────── + # 参考: https://gitea.szis.com.cn/isos/team/src/branch/trunk/team/tmux.md + _apply_tmux_defaults() { + local sess="$1" + # 基础选项 + tmux set-option -t "$sess" mouse on 2>/dev/null || true + tmux set-option -t "$sess" base-index 1 2>/dev/null || true + # tmux set-option -t "$sess" default-terminal "screen-256color" 2>/dev/null || true + tmux set-option -t "$sess" escape-time 0 2>/dev/null || true + tmux set-option -t "$sess" history-limit 50000 2>/dev/null || true + tmux set-option -t "$sess" set-titles on 2>/dev/null || true + tmux set-option -t "$sess" focus-events on 2>/dev/null || true + tmux set-option -t "$sess" set-clipboard on 2>/dev/null || true + tmux set-option -t "$sess" renumber-windows on 2>/dev/null || true + # 状态栏 + tmux set-option -t "$sess" status-left "#S |" 2>/dev/null || true + tmux set-option -t "$sess" status-left-length 20 2>/dev/null || true + tmux set-option -t "$sess" status-right "%Y-%m-%d %H:%M" 2>/dev/null || true + tmux set-option -t "$sess" status-right-length 20 2>/dev/null || true + # 窗口选项 + tmux set-window-option -t "$sess" pane-base-index 1 2>/dev/null || true + tmux set-window-option -t "$sess" mode-keys vi 2>/dev/null || true + tmux set-window-option -t "$sess" aggressive-resize on 2>/dev/null || true + # 存储 provider/model 元信息(供 --list 查询) + tmux set-option -t "$sess" @cc_provider "$CC_PROVIDER" 2>/dev/null || true + tmux set-option -t "$sess" @cc_model "$MODEL" 2>/dev/null || true + } + _apply_tmux_defaults "$TMUX_SESSION" + + # 附加到会话(前台运行) + tmux attach-session -t "$TMUX_SESSION" +fi diff --git a/.claude/tools/tmux-monitor.sh b/.claude/tools/tmux-monitor.sh new file mode 100755 index 0000000..e89dbc3 --- /dev/null +++ b/.claude/tools/tmux-monitor.sh @@ -0,0 +1,508 @@ +#!/bin/bash +# ============================================================================== +# tmux 实时监控面板 — 通用版 +# +# 在当前 tmux 窗口右侧(或左侧)开启一个固定宽度的监控面板,实时展示全部 +# 会话 / 窗口 / 面板的层级状态、运行命令、路径与状态标签,按终端宽度自适应。 +# +# 使用方法: +# tmux-monitor.sh # 创建监控面板 (默认 1 秒刷新, 右侧, 宽 60) +# tmux-monitor.sh 3 # 创建监控面板 (自定义刷新间隔) +# tmux-monitor.sh -w 80 # 指定面板宽度 +# tmux-monitor.sh -p L # 面板放左侧 (默认 R 右侧) +# tmux-monitor.sh -w 80 -p L 2 # 组合: 宽 80 / 左侧 / 2 秒刷新 +# tmux-monitor.sh -k # 关闭监控面板 +# tmux-monitor.sh -r # 重启监控面板 +# tmux-monitor.sh -s # 查看监控面板状态 +# tmux-monitor.sh -h # 显示帮助 +# tmux-monitor.sh --display # (内部) 运行显示循环 +# +# 通用性说明: +# - 脚本路径自动解析 (readlink -f BASH_SOURCE), 可软链到任意位置运行 +# - 面板定位使用全局唯一的 pane_id, 不受窗口切换 / 多会话影响 +# - 不依赖任何特定工作目录 +# ============================================================================== + +# ── 脚本自身路径解析(核心:去除硬编码,支持软链) ────────── +# 优先 BASH_SOURCE(即使被 source 也能定位),readlink -f 解析符号链接 +SCRIPT_PATH="$(readlink -f "${BASH_SOURCE[0]:-$0}" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")" +SCRIPT_NAME="$(basename "$SCRIPT_PATH")" + +# ── 默认配置 ───────────────────────────────────────────── +DEFAULT_INTERVAL=1 +DEFAULT_WIDTH=60 +DEFAULT_POS="R" # R=右侧, L=左侧 + +# ── 参数解析 ────────────────────────────────────────────── +ACTION="create" +INTERVAL="" +WIDTH="" +POS="" + +while [ $# -gt 0 ]; do + case "$1" in + --display) + ACTION="display"; shift + ;; + -k|--kill) + ACTION="kill"; shift + ;; + -r|--restart) + ACTION="restart"; shift + ;; + -s|--status) + ACTION="status"; shift + ;; + -h|--help) + ACTION="help"; shift + ;; + -w|--width) + [ $# -ge 2 ] || { echo "错误: $1 需要参数" >&2; exit 1; } + WIDTH="$2"; shift 2 + ;; + -p|--pos) + [ $# -ge 2 ] || { echo "错误: $1 需要参数" >&2; exit 1; } + POS="$2"; shift 2 + ;; + --) + shift; break + ;; + -*) + echo "未知选项: $1 (使用 -h 查看帮助)" >&2; exit 1 + ;; + *) + INTERVAL="$1"; shift + ;; + esac +done + +# 应用默认值 +INTERVAL="${INTERVAL:-$DEFAULT_INTERVAL}" +WIDTH="${WIDTH:-$DEFAULT_WIDTH}" +POS="${POS:-$DEFAULT_POS}" +# 规范化位置参数:L→左, 其余一律视为 R +case "$(echo "$POS" | tr '[:lower:]' '[:upper:]')" in + L|LEFT) POS="L" ;; + *) POS="R" ;; +esac + +# ── 前置检查 ────────────────────────────────────────────── + +if ! command -v tmux &>/dev/null; then + echo "错误: tmux 未安装" >&2 + exit 1 +fi + +# ── 管理函数 ────────────────────────────────────────────── + +# 查找运行中的监控面板,返回其 pane_id(全局唯一,形如 %5) +find_monitor_pane() { + tmux list-panes -a -F '#{pane_id} #{pane_start_command}' 2>/dev/null \ + | grep -iF "$SCRIPT_NAME" \ + | head -1 \ + | awk '{print $1}' +} + +do_kill() { + local pane_id + pane_id=$(find_monitor_pane) + if [ -n "$pane_id" ]; then + tmux kill-pane -t "$pane_id" 2>/dev/null + echo "监控面板 (${pane_id}) 已关闭" + return 0 + else + echo "未找到运行中的监控面板" + return 1 + fi +} + +do_status() { + local pane_id + pane_id=$(find_monitor_pane) + if [ -n "$pane_id" ]; then + # 通过 pane_id 反查其所属会话/窗口/尺寸 + local info + info=$(tmux list-panes -a -F \ + '#{pane_id}|#{session_name}:#{window_index}.#{pane_index}|#{pane_width}x#{pane_height}|#{pane_dead}' \ + 2>/dev/null | grep "^${pane_id}|") + if [ -n "$info" ]; then + local pid_loc sw size dead + pid_loc="${info%%|*}" + info="${info#*|}" + sw="${info%%|*}" + info="${info#*|}" + size="${info%%|*}" + dead="${info##*|}" + local state="运行中" + [ "$dead" = "1" ] && state="${R:-}已死亡${N:-}" + echo "监控面板状态: ${state}" + echo " pane_id : ${pid_loc}" + echo " 位置 : ${sw}" + echo " 尺寸 : ${size}" + echo " 脚本 : ${SCRIPT_PATH}" + else + echo "监控面板 (${pane_id}) 状态不可读" + fi + return 0 + else + echo "未找到运行中的监控面板" + return 1 + fi +} + +do_create() { + if [ -z "${TMUX:-}" ]; then + echo "错误: 需要在 tmux 会话内运行 (检测到 \$TMUX 为空)" >&2 + return 1 + fi + + # 宽度校验:必须为正整数 + case "$WIDTH" in + ''|*[!0-9]*) echo "错误: 面板宽度必须为正整数 (当前: $WIDTH)" >&2; return 1 ;; + esac + [ "$WIDTH" -ge 10 ] || { echo "错误: 面板宽度过小 (最小 10, 当前: $WIDTH)" >&2; return 1; } + + # 关闭已有监控面板(幂等:支持反复创建) + local pane_id + pane_id=$(find_monitor_pane) + if [ -n "$pane_id" ]; then + tmux kill-pane -t "$pane_id" 2>/dev/null + fi + + # 刷新间隔校验:必须为正整数 + case "$INTERVAL" in + ''|*[!0-9]*) echo "错误: 刷新间隔必须为正整数 (当前: $INTERVAL)" >&2; return 1 ;; + esac + + # 通过环境变量把刷新间隔传入 display 子进程 + local launch_cmd="TMUX_MONITOR_INTERVAL=${INTERVAL} bash '${SCRIPT_PATH}' --display" + + # 按位置创建面板:-h 水平分割;-b 将新面板置于左侧 + if [ "$POS" = "L" ]; then + tmux split-window -hb -l "$WIDTH" "$launch_cmd" + else + tmux split-window -h -l "$WIDTH" "$launch_cmd" + fi + + echo "监控面板已启动 (${INTERVAL} 秒刷新, ${POS}侧, 宽 ${WIDTH})" +} + +# ── 非显示操作: 执行后退出 ─────────────────────────────── + +if [ "$ACTION" != "display" ]; then + case "$ACTION" in + help) + cat < 自定义刷新间隔 + -w, --width N 面板宽度 (默认 ${DEFAULT_WIDTH}) + -p, --pos L|R 面板位置: L=左 R=右 (默认 ${DEFAULT_POS}) + -k, --kill 关闭监控面板 + -r, --restart 重启监控面板 + -s, --status 查看监控面板状态 + -h, --help 显示本帮助 + --display (内部) 运行显示循环 + +示例: + ${SCRIPT_NAME} # 默认创建 + ${SCRIPT_NAME} 3 # 3 秒刷新 + ${SCRIPT_NAME} -w 80 -p L 2 # 宽 80 / 左侧 / 2 秒 + ${SCRIPT_NAME} -k # 关闭面板 +EOF + ;; + kill) + do_kill + ;; + status) + do_status + ;; + restart) + do_kill 2>/dev/null || true + do_create + ;; + create) + do_create + ;; + esac + exit $? +fi + +# ══════════════════════════════════════════════════════════ +# 以下为 display 模式: 运行监控显示循环 +# ══════════════════════════════════════════════════════════ + +# 颜色定义 — 使用 $'...' ANSI-C 引号,赋值时即产生真正的 ESC 字节 +readonly R=$'\033[0;31m' # RED +readonly G=$'\033[0;32m' # GREEN +readonly Y=$'\033[0;33m' # YELLOW +readonly B=$'\033[0;34m' # BLUE +readonly P=$'\033[0;35m' # PURPLE +readonly C=$'\033[0;36m' # CYAN +readonly D=$'\033[0;90m' # GRAY/DIM +readonly BD=$'\033[1m' # BOLD +readonly N=$'\033[0m' # RESET + +# 刷新间隔(秒)— 由管理模式通过 TMUX_MONITOR_INTERVAL 传入 +INTERVAL="${TMUX_MONITOR_INTERVAL:-$DEFAULT_INTERVAL}" + +# ── 工具函数 ────────────────────────────────────────────── + +# 生成指定宽度的分隔线 +make_sep() { + local width="${1:-80}" + local sep_char='─' + printf '%*s' "$width" '' | tr ' ' "$sep_char" +} + +# 判断字符串是否为非负整数 +is_uint() { + case "$1" in + ''|*[!0-9]*) return 1 ;; + *) return 0 ;; + esac +} + +# ── 显示层级常量 ───────────────────────────────────────── +# 根据终端宽度决定显示详细程度 +LVL_MINIMAL=0 # < 50 列:仅显示核心信息 +LVL_COMPACT=1 # 50-69 列:省略 PID、路径、子命令 +LVL_NORMAL=2 # 70-99 列:省略子命令详情 +LVL_FULL=3 # >= 100 列:显示全部 + +get_display_level() { + local cols="$1" + if [ "$cols" -lt 50 ]; then + echo "$LVL_MINIMAL" + elif [ "$cols" -lt 70 ]; then + echo "$LVL_COMPACT" + elif [ "$cols" -lt 100 ]; then + echo "$LVL_NORMAL" + else + echo "$LVL_FULL" + fi +} + +# 记录终端尺寸,用于检测 resize +last_cols=0 +last_rows=0 + +while true; do + # ── 检测终端尺寸变化,清屏防重叠 ── + cur_cols=$(tput cols 2>/dev/null || echo 80) + cur_rows=$(tput lines 2>/dev/null || echo 24) + if [ "$cur_cols" != "$last_cols" ] || [ "$cur_rows" != "$last_rows" ]; then + tput clear + last_cols=$cur_cols + last_rows=$cur_rows + fi + + # 当前显示层级 + level=$(get_display_level "$cur_cols") + + # 动态分隔线 + SEP=$(make_sep "$cur_cols") + + # ── 构建输出到缓冲区 ── + buf="" + buf="${buf}\n" + buf="${buf}${BD}${SEP}${N}\n" + + # 标题行:自适应宽度 + title=" tmux 实时监控 $(date '+%H:%M:%S')" + if [ "$level" -ge "$LVL_COMPACT" ]; then + buf="${buf}${C}${BD}${title}${N}\n" + else + buf="${buf}${C}${BD} 监控 $(date '+%H:%M:%S')${N}\n" + fi + + buf="${buf}${BD}${SEP}${N}\n" + buf="${buf}\n" + + cur_session=$(tmux display-message -p '#{session_name}' 2>/dev/null) + + # 一次性获取所有面板信息(含状态字段) + panes_info=$(tmux list-panes -a -F \ + '#{session_name}|#{session_created}|#{session_attached}|#{window_index}|#{window_name}|#{window_active}|#{window_width}x#{window_height}|#{pane_index}|#{pane_title}|#{pane_current_command}|#{pane_current_path}|#{pane_active}|#{pane_pid}|#{pane_width}x#{pane_height}|#{pane_dead}|#{pane_dead_status}|#{pane_in_mode}|#{pane_mode}' \ + 2>/dev/null) + + total_sessions=0 + total_windows=0 + total_panes=0 + session_idx=0 + + if [ -n "$panes_info" ]; then + prev_session="" + prev_window="" + + while IFS='|' read -r ssn s_created s_attached widx wname w_active w_size pidx ptitle cmd path active pid p_size p_dead p_dead_status p_in_mode p_mode; do + # ── 新会话 ── + if [ "$ssn" != "$prev_session" ]; then + [ -n "$prev_session" ] && buf="${buf}\n" + total_sessions=$((total_sessions + 1)) + session_idx=$((session_idx + 1)) + + if [ "$ssn" = "$cur_session" ]; then + s_marker="${G}●${N}" + s_name="${BD}${G}${ssn}${N}" + else + s_marker="${D}○${N}" + s_name="${P}${ssn}${N}" + fi + + # Session 状态标签 + s_status="" + if [ "$s_attached" = "0" ]; then + s_status=" ${Y}${BD}[detached]${N}" + fi + + s_time="" + if [ "$level" -ge "$LVL_NORMAL" ] && is_uint "$s_created" && [ "$s_created" -gt 0 ]; then + s_time=$(date -d "@$s_created" '+%m-%d %H:%M' 2>/dev/null) + fi + + # 根据宽度调整 Session 行 + if [ "$level" -ge "$LVL_COMPACT" ]; then + s_line=" ${s_marker} ${BD}Session${N} ${session_idx} ${s_name}${s_status}" + else + s_line=" ${s_marker} ${BD}S${N}${session_idx} ${s_name}${s_status}" + fi + [ -n "$s_time" ] && s_line="${s_line} ${D}[自 ${s_time}]${N}" + buf="${buf}${s_line}\n" + + prev_session="$ssn" + prev_window="" + fi + + # ── 新窗口 ── + if [ "$widx:$wname" != "$prev_window" ]; then + total_windows=$((total_windows + 1)) + + if [ "$w_active" = "1" ] && [ "$ssn" = "$cur_session" ]; then + w_marker="${G}●${N}" + w_name="${BD}${G}${wname}${N}" + else + w_marker="${D}○${N}" + w_name="${Y}${wname}${N}" + fi + + # Window 状态标签 + w_status="" + if [ "$w_active" = "1" ] && [ "$ssn" = "$cur_session" ]; then + w_status=" ${G}[active]${N}" + fi + + # 根据宽度决定是否显示尺寸 + if [ "$level" -ge "$LVL_COMPACT" ]; then + w_line=" ${D}├─${N} ${w_marker} ${BD}Win${N} ${widx} ${w_name}${w_status} ${D}[${w_size}]${N}" + else + w_line=" ${D}├${N} ${w_marker} ${w_name}${w_status}" + fi + buf="${buf}${w_line}\n" + prev_window="$widx:$wname" + fi + + # ── 面板 ── + total_panes=$((total_panes + 1)) + + if [ "$active" = "1" ] && [ "$ssn" = "$cur_session" ]; then + p_marker="${G}●${N}" + else + p_marker="${D}○${N}" + fi + + case "$cmd" in + bash|zsh|fish|sh) cmd_color="$D" ;; + vim|nvim|vi) cmd_color="$G" ;; + python|python3) cmd_color="$Y" ;; + node) cmd_color="$G" ;; + ssh) cmd_color="$R" ;; + "$SCRIPT_NAME") cmd_color="$C" ;; + *) cmd_color="$N" ;; + esac + + # 路径缩写:根据层级决定缩写程度 + short_path="" + if [ "$level" -ge "$LVL_NORMAL" ]; then + short_path=$(echo "$path" | sed "s|^$HOME|~|" | awk -F/ '{ + if(NF>3) print ".."/$(NF-1)/$NF; else print $0 + }') + elif [ "$level" -ge "$LVL_COMPACT" ]; then + short_path=$(echo "$path" | sed "s|^$HOME|~|" | awk -F/ '{ + print $NF + }') + fi + + # 子命令:仅在 FULL 级别显示 + child_cmd="" + if [ "$level" -ge "$LVL_FULL" ] && [ "$(uname)" = "Linux" ]; then + child_pid=$(ps --ppid "$pid" -o pid= 2>/dev/null | head -1 | tr -d ' ') + if [ -n "$child_pid" ]; then + child_cmd=$(ps -p "$child_pid" -o args= 2>/dev/null) + fi + fi + + # 格式: 根据层级决定显示内容 + p_title="${ptitle}" + [ -z "$p_title" ] && p_title="$cmd" + + # Pane 状态标签 + p_status="" + if [ "$p_dead" = "1" ]; then + p_status=" ${R}${BD}[dead:${p_dead_status}]${N}" + fi + if [ "$p_in_mode" = "1" ] && [ -n "$p_mode" ]; then + p_status="${p_status} ${Y}[${p_mode}]${N}" + fi + if [ "$active" = "1" ] && [ "$ssn" = "$cur_session" ]; then + p_status="${p_status} ${G}[active]${N}" + fi + + if [ "$level" -ge "$LVL_NORMAL" ]; then + # NORMAL / FULL: Pane 编号 + 标题 + 命令 + 尺寸 + 状态 + p_line=" ${D}│ ├─${N} ${p_marker} ${BD}Pane${N} ${pidx} ${C}${p_title}${N} ${cmd_color}${cmd}${N} ${D}[${p_size}]${N}${p_status}" + if [ "$level" -ge "$LVL_FULL" ]; then + p_line="${p_line} ${D}PID:${pid}${N}" + fi + elif [ "$level" -ge "$LVL_COMPACT" ]; then + # COMPACT: Pane 编号 + 标题 + 命令 + p_line=" ${D}│ ├${N} ${p_marker} ${BD}P${N}${pidx} ${C}${p_title}${N} ${cmd_color}${cmd}${N}" + else + # MINIMAL: 仅标题 + 命令 + p_line=" ${D}│${N} ${p_marker} ${C}${p_title}${N}" + fi + + buf="${buf}${p_line}\n" + + if [ -n "$child_cmd" ]; then + c_line=" ${D}│ │ ↳${N} ${D}${child_cmd}${N}" + buf="${buf}${c_line}\n" + fi + + if [ -n "$short_path" ]; then + sp_line=" ${D}│ │ ${N}${D}${short_path}${N}" + buf="${buf}${sp_line}\n" + fi + + done <<< "$panes_info" + fi + + # ── 系统概要 ── + buf="${buf}\n" + buf="${buf}${D}${SEP}${N}\n" + buf="${buf} ${BD}总计:${N} ${total_sessions} 会话 | ${total_windows} 窗口 | ${total_panes} 面板\n" + buf="${buf}${D}${SEP}${N}\n" + buf="${buf} ${D}每 ${INTERVAL} 秒刷新 | Ctrl+C 退出${N}\n" + + # ── 双缓冲输出:移到顶部 → 写入 → 清除尾部残留 ── + # 每行末尾添加 \033[K(清除到行尾),防止短行覆盖长行时残留旧字符 + buf="${buf//\\n/\\033[K\\n}" + + tput cup 0 0 + printf '%b' "$buf" + tput ed + + sleep "$INTERVAL" +done