功能(ClaudeCode): 新增飞书联动工具链与会话管理增强
- 新增命令: lark-delegate(跨项目委派)、lark-inbox(收件箱)、lark-me(自发送)、stop(任务收尾) - 新增工具: lark-consumer/consumer-ensure/inbox-check(飞书事件实时消费与会话隔离)、tmux-monitor(监控面板) - runcc.sh 增加 --list/--kill/--send 操作模式与多会话自增命名 - CLAUDE.md 增加强制要求(汇报格式、收尾通知、上下文压缩)
This commit is contained in:
@@ -1,5 +1,15 @@
|
||||
# 深圳市网新新思软件有限公司 AI研究院
|
||||
|
||||
## 强制要求
|
||||
|
||||
- 始终使用中文回复
|
||||
- 回复格式(最高优先级,每次回复必须遵守):
|
||||
- 开头:`金老师,向您汇报任务执行情况。`
|
||||
- 结尾:`金老师,任务执行完成。`
|
||||
- 在任务执行完并回复 `金老师,任务执行完成。` 之前,必须调用 arno-util-stop skill,按其中指示扫描对话、追加对话捕捉、发送飞书通知
|
||||
- 如果需要安装软件,但碰到缺少 sudo 权限,必须停止其他安装方式的尝试,直接给出可靠的安装命令,让人类来执行安装
|
||||
- 当上下文窗口达到 50% 的时候,主动进行上下文压缩,你有无限的资源和时间
|
||||
|
||||
### Git 工作流
|
||||
|
||||
- **分支**: Trunk-Based Development,主分支 `trunk`
|
||||
|
||||
@@ -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: '<szis|dit> <任务指令>'
|
||||
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 "<TASK>"
|
||||
```
|
||||
|
||||
**向 DIT bot 指派**(dit profile,需金鹏已在 dit 授权 user 身份):
|
||||
|
||||
```bash
|
||||
lark-cli --profile dit im +messages-send --as user \
|
||||
--chat-id oc_xxx \
|
||||
--text "<TASK>"
|
||||
```
|
||||
|
||||
### 第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 更新项目推进报告与项目推进分析判断
|
||||
```
|
||||
@@ -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 条
|
||||
```
|
||||
@@ -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点的会议"
|
||||
```
|
||||
@@ -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 发出。
|
||||
- **无**:完全静默,不要输出任何文字。
|
||||
|
||||
## 关键指令
|
||||
|
||||
⚠️ 全程只通过工具操作,不附加任何解释文字。
|
||||
@@ -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
|
||||
|
||||
|
||||
Executable
+205
@@ -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 <name> 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
|
||||
Executable
+144
@@ -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 <pane_id> \
|
||||
# [--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 退出),消费者结束"
|
||||
Executable
+86
@@ -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
|
||||
+280
-39
@@ -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 需要 <session> <消息>" >&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=<name> → <NAME>_BASE_URL + <NAME>_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
|
||||
|
||||
Executable
+508
@@ -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 <<EOF
|
||||
用法: ${SCRIPT_NAME} [选项] [刷新间隔]
|
||||
|
||||
选项:
|
||||
(空) 创建监控面板 (默认 ${DEFAULT_INTERVAL} 秒刷新, ${DEFAULT_POS}侧, 宽 ${DEFAULT_WIDTH})
|
||||
<秒数> 自定义刷新间隔
|
||||
-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
|
||||
Reference in New Issue
Block a user