tmuxclihook 0.5.5 → 0.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/scripts/adapters/codex.sh +43 -0
- package/scripts/lib-tmux-ai-status.sh +289 -45
- package/scripts/tmux-ai-monitor +36 -48
- package/scripts/tmux-ai-status +14 -3
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ hooks 安装、tmux 集成写入的路径由「source」决定:环境变量 `T
|
|
|
43
43
|
|
|
44
44
|
升级成功,或版本已是最新且显式传入 `--prune` 时,update 会检查另一套安装(volta packages 镜像或当前 npm 全局 prefix)是否还留有副本。发现副本时:TTY 下询问是否删除(默认不删);非交互环境只打印警告;`--prune` 跳过询问直接删除。删除前会校验该副本未被任何 hooks 与 `~/.tmux.conf` 引用——任一检查无法确认就保留并提示先重装 hooks。**生效入口永远不删**,清理失败不影响升级结果。也可以手动收敛:卸载不再使用的那份(`npm uninstall -g tmuxclihook` 或 `volta uninstall tmuxclihook`),再从生效入口重新安装 hooks。
|
|
45
45
|
|
|
46
|
+
升级后无需手动重载 tmux 插件:运行中的 `tmux-ai-monitor` 每 60 tick 对比脚本目录内容指纹,发现文件被替换会自动 `exec` 自身重载新代码(保留同一 PID 与 generation)。`TMUX_AI_MONITOR_RELOAD_DISABLE=1` 可关闭该行为。
|
|
47
|
+
|
|
46
48
|
## 功能
|
|
47
49
|
|
|
48
50
|
| 菜单项 | 说明 |
|
package/package.json
CHANGED
|
@@ -113,6 +113,49 @@ EOF
|
|
|
113
113
|
' <<<"$rows"
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
# 可选:TMUX_PANE 无法由祖先链确定时,提示 thread 所在 pane(供核心收窄候选)。
|
|
117
|
+
# 参数: $1=session_id $2=hook_cwd;只读、不调用 tmux(复用核心 _PANE_SNAPSHOT)。
|
|
118
|
+
# codex 实例配置 terminal_title=["thread"],pane title 形如 "✳ <thread_name>";
|
|
119
|
+
# ${CODEX_HOME}/session_index.jsonl 每行 {id, thread_name, updated_at},
|
|
120
|
+
# hook 输入的 session_id 与其中的 id 一致。用归一化后的精确标题匹配,
|
|
121
|
+
# 不依赖 ✳/spinner 前缀;结果再与进程树候选取交集,降低误配。
|
|
122
|
+
_codex_session_index_files() {
|
|
123
|
+
local f seen=""
|
|
124
|
+
if [ -n "${CODEX_HOME:-}" ] && [ -f "${CODEX_HOME}/session_index.jsonl" ]; then
|
|
125
|
+
printf '%s\n' "${CODEX_HOME}/session_index.jsonl"
|
|
126
|
+
seen="${CODEX_HOME}/session_index.jsonl"
|
|
127
|
+
fi
|
|
128
|
+
f="${HOME}/.codex/session_index.jsonl"
|
|
129
|
+
if [ -f "$f" ] && [ "$f" != "$seen" ]; then printf '%s\n' "$f"; fi
|
|
130
|
+
for f in "${HOME}"/.cc-switch/instances/codex/*/session_index.jsonl; do
|
|
131
|
+
[ -f "$f" ] || continue
|
|
132
|
+
[ "$f" = "$seen" ] || printf '%s\n' "$f"
|
|
133
|
+
done
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
adapter_pane_hints() {
|
|
137
|
+
local sid="${1:-}" name="" idx target pane path cmd pid server attached title out=""
|
|
138
|
+
[ -n "$sid" ] || return 1
|
|
139
|
+
while IFS= read -r idx; do
|
|
140
|
+
[ -f "$idx" ] || continue
|
|
141
|
+
name=$(jq -r --arg id "$sid" 'select(.id == $id) | .thread_name // empty' \
|
|
142
|
+
"$idx" 2>/dev/null | tail -n 1)
|
|
143
|
+
[ -n "$name" ] && break
|
|
144
|
+
done < <(_codex_session_index_files)
|
|
145
|
+
[ -n "$name" ] || return 1
|
|
146
|
+
target=$(_normalize_title "$name")
|
|
147
|
+
[ -n "$target" ] || return 1
|
|
148
|
+
[ "$_PANE_SNAPSHOT_OK" = "1" ] || return 1
|
|
149
|
+
while IFS='|' read -r pane path cmd pid server attached title; do
|
|
150
|
+
[ -n "$pane" ] || continue
|
|
151
|
+
[ "$(_normalize_title "$title")" = "$target" ] && out="$out $pane"
|
|
152
|
+
done <<EOF
|
|
153
|
+
$_PANE_SNAPSHOT
|
|
154
|
+
EOF
|
|
155
|
+
[ -n "$out" ] || return 1
|
|
156
|
+
printf '%s\n' "${out# }" | tr ' ' '\n'
|
|
157
|
+
}
|
|
158
|
+
|
|
116
159
|
# 从每个事件数组剔除本插件 group(command 含 tmux-ai-status),事件/hooks 清空后删键。
|
|
117
160
|
_codex_strip_managed() {
|
|
118
161
|
jq '
|
|
@@ -61,55 +61,265 @@ _pane_sanitized() {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
_pane_dir() {
|
|
64
|
+
[ -n "$TMUX_PANE" ] || return 1
|
|
64
65
|
echo "${_STATUS_DIR}/$(_pane_sanitized)"
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
_ensure_pane_dir() {
|
|
68
|
-
local dir; dir=$(_pane_dir)
|
|
69
|
+
local dir; dir=$(_pane_dir) || return 1
|
|
69
70
|
[ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
# ---
|
|
73
|
-
#
|
|
73
|
+
# --- Pane 解析 ---
|
|
74
|
+
# 继承的 $TMUX_PANE 可能来自长期存活的进程(如 codex managed app-server daemon)。
|
|
75
|
+
# 关键:daemon 的继承值甚至可能指向当前 server 中真实存在、但与本会话无关的 pane,
|
|
76
|
+
# 所以「server pid 匹配 + pane 存在」不足以信任它。判定顺序:
|
|
77
|
+
# 1) 祖先链:hook 进程向上能否命中某个 pane 的 pane_pid(权威)
|
|
78
|
+
# 2) 祖先链不通 = daemon/脱离上下文 → 继承值一律丢弃
|
|
79
|
+
# 3) session 映射(带 server/pane_pid/tool/cwd 元数据校验)
|
|
80
|
+
# 4) 同工具进程树候选 → adapter 提示 → hook cwd 逐级收敛
|
|
81
|
+
# 只有收敛到唯一候选才采用;多候选不猜(保持空,由 monitor 显示 idle `-`)。
|
|
82
|
+
|
|
83
|
+
HOOK_CWD="${HOOK_CWD:-}"
|
|
84
|
+
_PANE_SNAPSHOT=""
|
|
85
|
+
_PANE_SNAPSHOT_OK=0
|
|
86
|
+
_CURRENT_SERVER_PID=""
|
|
87
|
+
_TMUX_PANE_SOURCE=""
|
|
88
|
+
|
|
89
|
+
# 一次性快照:pane_id|path|current_command|pane_pid|server_pid|attached|title
|
|
90
|
+
# title 放最后:read 会把剩余内容(含 '|')并入最后一个变量。
|
|
91
|
+
_load_pane_snapshot() {
|
|
92
|
+
_PANE_SNAPSHOT=""
|
|
93
|
+
_PANE_SNAPSHOT_OK=0
|
|
94
|
+
_CURRENT_SERVER_PID=""
|
|
95
|
+
command -v tmux >/dev/null 2>&1 || return 1
|
|
96
|
+
_PANE_SNAPSHOT=$(tmux list-panes -a -F \
|
|
97
|
+
'#{pane_id}|#{pane_current_path}|#{pane_current_command}|#{pane_pid}|#{pid}|#{session_attached}|#{pane_title}' \
|
|
98
|
+
2>/dev/null)
|
|
99
|
+
[ -n "$_PANE_SNAPSHOT" ] || return 1
|
|
100
|
+
_PANE_SNAPSHOT_OK=1
|
|
101
|
+
_CURRENT_SERVER_PID=$(printf '%s\n' "$_PANE_SNAPSHOT" | awk -F'|' 'NR==1{print $5}')
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
_snapshot_field() { # $1=pane_id $2=字段号
|
|
106
|
+
[ "$_PANE_SNAPSHOT_OK" = "1" ] || return 1
|
|
107
|
+
printf '%s\n' "$_PANE_SNAPSHOT" | awk -F'|' -v id="$1" -v n="$2" '$1==id{print $n; exit}'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
_snapshot_has_pane() { # $1=pane_id
|
|
111
|
+
[ "$_PANE_SNAPSHOT_OK" = "1" ] || return 1
|
|
112
|
+
printf '%s\n' "$_PANE_SNAPSHOT" | awk -F'|' -v id="$1" '$1==id{found=1} END{exit !found}'
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
# 从当前进程沿父链向上匹配 pane_pid;命中即返回该 pane(权威信号)。
|
|
116
|
+
_pane_from_ancestry() {
|
|
117
|
+
local pid=$$ depth=0 found
|
|
118
|
+
while [ "${pid:-0}" -gt 1 ] && [ "$depth" -lt 24 ]; do
|
|
119
|
+
found=$(printf '%s\n' "$_PANE_SNAPSHOT" | awk -F'|' -v p="$pid" '$4==p{print $1; exit}')
|
|
120
|
+
if [ -n "$found" ]; then printf '%s' "$found"; return 0; fi
|
|
121
|
+
pid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d '[:space:]')
|
|
122
|
+
depth=$((depth + 1))
|
|
123
|
+
done
|
|
124
|
+
return 1
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# 当前工具在 attached pane 中的候选集合(进程树含 ADAPTER_PROCESS_NAMES)。
|
|
128
|
+
_pane_process_candidates() {
|
|
129
|
+
local names="${ADAPTER_PROCESS_NAMES:-}" id path cmd pid server attached title
|
|
130
|
+
[ -n "$names" ] || return 1
|
|
131
|
+
[ "$_PANE_SNAPSHOT_OK" = "1" ] || return 1
|
|
132
|
+
while IFS='|' read -r id path cmd pid server attached title; do
|
|
133
|
+
[ -n "$id" ] || continue
|
|
134
|
+
[ "${attached:-0}" -gt 0 ] || continue
|
|
135
|
+
if _pid_tree_has_names "$pid" "$names"; then printf '%s\n' "$id"; fi
|
|
136
|
+
done <<EOF
|
|
137
|
+
$_PANE_SNAPSHOT
|
|
138
|
+
EOF
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
# 取交集(按 $1 的顺序);过滤结果为空时保持原集合。
|
|
142
|
+
_intersect_panes() {
|
|
143
|
+
local base="$1" filter="$2" out="" p
|
|
144
|
+
[ -n "$filter" ] || { printf '%s\n' "$base"; return; }
|
|
145
|
+
filter=$(printf '%s' "$filter" | tr '\n' ' ')
|
|
146
|
+
for p in $base; do
|
|
147
|
+
case " $filter " in *" $p "*) out="$out $p" ;; esac
|
|
148
|
+
done
|
|
149
|
+
[ -n "$out" ] && printf '%s\n' "${out# }" || printf '%s\n' "$base"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
_narrow_panes_by_adapter() {
|
|
153
|
+
local base="$1" hints=""
|
|
154
|
+
declare -f adapter_pane_hints >/dev/null 2>&1 || { printf '%s\n' "$base"; return; }
|
|
155
|
+
hints=$(adapter_pane_hints "${SESSION_ID:-}" "$HOOK_CWD" 2>/dev/null)
|
|
156
|
+
_intersect_panes "$base" "$hints"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_narrow_panes_by_cwd() {
|
|
160
|
+
local base="$1" want="" matches="" id path cmd pid server attached title
|
|
161
|
+
[ -n "${HOOK_CWD:-}" ] || { printf '%s\n' "$base"; return; }
|
|
162
|
+
want=$(_normalize_path "$HOOK_CWD") || want="${HOOK_CWD%/}"
|
|
163
|
+
while IFS='|' read -r id path cmd pid server attached title; do
|
|
164
|
+
[ -n "$id" ] || continue
|
|
165
|
+
[ "$(_normalize_path "$path")" = "$want" ] && matches="$matches $id"
|
|
166
|
+
done <<EOF
|
|
167
|
+
$_PANE_SNAPSHOT
|
|
168
|
+
EOF
|
|
169
|
+
_intersect_panes "$base" "$matches"
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
# 路径归一化:去尾斜杠 + 解析符号链接(解析失败时保留原路径)。
|
|
173
|
+
_normalize_path() {
|
|
174
|
+
local p="$1" phys
|
|
175
|
+
[ -n "$p" ] || return 1
|
|
176
|
+
while [ "${p%/}" != "$p" ] && [ "$p" != "/" ]; do p="${p%/}"; done
|
|
177
|
+
phys=$(cd "$p" 2>/dev/null && pwd -P) && p="$phys"
|
|
178
|
+
printf '%s' "$p"
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# 标题归一化:仅剥已知前缀(✳ / *)与首尾空白;不删非字母字符,避免破坏中文。
|
|
182
|
+
_normalize_title() {
|
|
183
|
+
local t="$1"
|
|
184
|
+
t=$(printf '%s' "$t" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
|
185
|
+
case "$t" in
|
|
186
|
+
"✳"*) t="${t#✳}" ;;
|
|
187
|
+
"*"*) t="${t#\*}" ;;
|
|
188
|
+
esac
|
|
189
|
+
printf '%s' "$t" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# 解析失败的负缓存:避免 daemon 高频事件反复枚举 pane + BFS。
|
|
193
|
+
_resolve_negative_path() { printf '%s/.resolve-ts-%s' "$_STATUS_DIR" "${SESSION_ID:-unknown}"; }
|
|
194
|
+
_resolve_recently_failed() {
|
|
195
|
+
local f ts age
|
|
196
|
+
f=$(_resolve_negative_path)
|
|
197
|
+
[ -f "$f" ] || return 1
|
|
198
|
+
ts=$(cat "$f" 2>/dev/null)
|
|
199
|
+
[ -n "$ts" ] || return 1
|
|
200
|
+
age=$(( $(date +%s) - ts ))
|
|
201
|
+
[ "$age" -ge 0 ] && [ "$age" -lt 5 ]
|
|
202
|
+
}
|
|
203
|
+
_mark_resolve_failed() {
|
|
204
|
+
mkdir -p "$_STATUS_DIR" 2>/dev/null || true
|
|
205
|
+
date +%s > "$(_resolve_negative_path)" 2>/dev/null || true
|
|
206
|
+
}
|
|
207
|
+
_clear_resolve_failed() { rm -f "$(_resolve_negative_path)" 2>/dev/null || true; }
|
|
208
|
+
|
|
74
209
|
resolve_tmux_pane() {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
210
|
+
local inherited="${TMUX_PANE:-}" pane candidates n
|
|
211
|
+
|
|
212
|
+
TMUX_PANE=""
|
|
213
|
+
_TMUX_PANE_SOURCE=""
|
|
214
|
+
|
|
215
|
+
# 1) 祖先链是权威信号(普通启动 / 旧 cc-switch / Claude 的 pane 内 hook)
|
|
216
|
+
_load_pane_snapshot || return 1
|
|
217
|
+
pane=$(_pane_from_ancestry)
|
|
218
|
+
if [ -n "$pane" ]; then
|
|
219
|
+
TMUX_PANE="$pane"
|
|
220
|
+
_TMUX_PANE_SOURCE="ancestry"
|
|
221
|
+
if [ -n "$inherited" ] && [ "$inherited" != "$pane" ]; then
|
|
222
|
+
_ai_log "PANE: inherited '$inherited' overridden by ancestry '$pane' (tool=$TOOL_ID)"
|
|
82
223
|
fi
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
224
|
+
_clear_resolve_failed
|
|
225
|
+
return 0
|
|
226
|
+
fi
|
|
227
|
+
|
|
228
|
+
# 2) 祖先链没有 pane = daemon / 脱离上下文:继承值可能指向当前 server 中
|
|
229
|
+
# 真实存在但无关的 pane,一律丢弃,走映射与多信号收敛。
|
|
230
|
+
if [ -n "${SESSION_ID:-}" ] && _load_pane_id; then
|
|
231
|
+
_TMUX_PANE_SOURCE="mapping"
|
|
232
|
+
_clear_resolve_failed
|
|
233
|
+
return 0
|
|
91
234
|
fi
|
|
235
|
+
|
|
236
|
+
if _resolve_recently_failed; then
|
|
237
|
+
return 1
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
# 3) 同工具进程树候选 → adapter 提示 → hook cwd
|
|
241
|
+
candidates=$(_pane_process_candidates)
|
|
242
|
+
if [ -n "$candidates" ]; then
|
|
243
|
+
n=$(printf '%s\n' "$candidates" | grep -c .)
|
|
244
|
+
if [ "$n" -gt 1 ]; then
|
|
245
|
+
candidates=$(_narrow_panes_by_adapter "$candidates")
|
|
246
|
+
n=$(printf '%s\n' "$candidates" | grep -c .)
|
|
247
|
+
fi
|
|
248
|
+
if [ "$n" -gt 1 ] && [ -n "${HOOK_CWD:-}" ]; then
|
|
249
|
+
candidates=$(_narrow_panes_by_cwd "$candidates")
|
|
250
|
+
n=$(printf '%s\n' "$candidates" | grep -c .)
|
|
251
|
+
fi
|
|
252
|
+
if [ "$n" -eq 1 ]; then
|
|
253
|
+
TMUX_PANE="${candidates%%$'\n'*}"
|
|
254
|
+
_TMUX_PANE_SOURCE="process"
|
|
255
|
+
_clear_resolve_failed
|
|
256
|
+
_ai_log "PANE: '$inherited' → '$TMUX_PANE' (tool=$TOOL_ID, detached, resolved)"
|
|
257
|
+
return 0
|
|
258
|
+
fi
|
|
259
|
+
fi
|
|
260
|
+
|
|
261
|
+
_mark_resolve_failed
|
|
262
|
+
_ai_log "PANE: unresolved (inherited='${inherited}' tool=$TOOL_ID session=${SESSION_ID:-?} cwd=${HOOK_CWD:-?} candidates='${candidates:-}')"
|
|
263
|
+
return 1
|
|
92
264
|
}
|
|
93
265
|
|
|
94
|
-
# --- Pane ID
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
266
|
+
# --- Pane ID 持久化(供后续事件/SessionEnd 回读)---
|
|
267
|
+
# 映射文件 pane-${SESSION_ID} 保持单行 pane id(既有清理逻辑依赖);
|
|
268
|
+
# 旁路 .pane-meta-${SESSION_ID} 记录 tool/server_pid/pane_pid/cwd,用于识别
|
|
269
|
+
# 「server 重启后 pane id 被复用」等陈旧映射。只有成功解析(_TMUX_PANE_SOURCE
|
|
270
|
+
# 非空)才落盘,避免把猜测持久化。
|
|
99
271
|
_save_pane_id() {
|
|
100
|
-
[ -n "$TMUX_PANE" ] && [ -n "$SESSION_ID" ]
|
|
272
|
+
[ -n "$TMUX_PANE" ] && [ -n "${SESSION_ID:-}" ] || return 0
|
|
273
|
+
[ -n "${_TMUX_PANE_SOURCE:-}" ] || return 0
|
|
274
|
+
_ensure_pane_dir || return 0
|
|
275
|
+
local dir
|
|
276
|
+
dir=$(_pane_dir) || return 0
|
|
277
|
+
printf '%s\n' "$TMUX_PANE" > "$dir/pane-${SESSION_ID}"
|
|
278
|
+
if [ "$_PANE_SNAPSHOT_OK" = "1" ]; then
|
|
279
|
+
printf '1\t%s\t%s\t%s\t%s\n' \
|
|
280
|
+
"${TOOL_ID:-}" "${_CURRENT_SERVER_PID:-}" \
|
|
281
|
+
"$(_snapshot_field "$TMUX_PANE" 4)" "$(_snapshot_field "$TMUX_PANE" 2)" \
|
|
282
|
+
> "$dir/.pane-meta-${SESSION_ID}" 2>/dev/null || true
|
|
283
|
+
fi
|
|
101
284
|
}
|
|
102
285
|
_load_pane_id() {
|
|
103
|
-
[ -
|
|
104
|
-
|
|
286
|
+
[ -n "${SESSION_ID:-}" ] || return 1
|
|
287
|
+
[ "$_PANE_SNAPSHOT_OK" = "1" ] || return 1
|
|
288
|
+
local f pane dir meta ver mtool mserver mpid mpath cpid
|
|
105
289
|
for f in "${_STATUS_DIR}"/*/pane-${SESSION_ID}; do
|
|
106
290
|
[ -f "$f" ] || continue
|
|
107
|
-
|
|
291
|
+
pane=$(head -n 1 "$f" 2>/dev/null)
|
|
292
|
+
[ -n "$pane" ] || continue
|
|
293
|
+
dir="${f%/*}"
|
|
294
|
+
meta="$dir/.pane-meta-${SESSION_ID}"
|
|
295
|
+
|
|
296
|
+
# pane 已不存在:确定失效,连元数据一起删除
|
|
297
|
+
if ! _snapshot_has_pane "$pane"; then
|
|
298
|
+
rm -f "$f" "$meta" 2>/dev/null || true
|
|
299
|
+
continue
|
|
300
|
+
fi
|
|
301
|
+
# 旧版映射(无 meta)不可信:忽略,让本次重新解析覆盖
|
|
302
|
+
[ -f "$meta" ] || continue
|
|
303
|
+
IFS=$'\t' read -r ver mtool mserver mpid mpath < "$meta"
|
|
304
|
+
[ "$ver" = "1" ] || continue
|
|
305
|
+
[ "$mtool" = "${TOOL_ID:-}" ] || continue
|
|
306
|
+
[ -n "$_CURRENT_SERVER_PID" ] && [ "$mserver" = "$_CURRENT_SERVER_PID" ] || continue
|
|
307
|
+
cpid=$(_snapshot_field "$pane" 4)
|
|
308
|
+
[ "$mpid" = "$cpid" ] || continue
|
|
309
|
+
# hook cwd 与映射时的 pane 路径不一致 → 会话已迁移,不采用
|
|
310
|
+
if [ -n "${HOOK_CWD:-}" ] && [ -n "$mpath" ]; then
|
|
311
|
+
[ "$(_normalize_path "$mpath")" = "$(_normalize_path "$HOOK_CWD")" ] || continue
|
|
312
|
+
fi
|
|
313
|
+
|
|
314
|
+
TMUX_PANE="$pane"
|
|
315
|
+
return 0
|
|
108
316
|
done
|
|
109
317
|
return 1
|
|
110
318
|
}
|
|
111
319
|
_clear_pane_id() {
|
|
112
|
-
[ -n "$
|
|
320
|
+
[ -n "$TMUX_PANE" ] && [ -n "${SESSION_ID:-}" ] || return 0
|
|
321
|
+
local dir; dir=$(_pane_dir) || return 0
|
|
322
|
+
rm -f "$dir/pane-${SESSION_ID}" "$dir/.pane-meta-${SESSION_ID}" 2>/dev/null
|
|
113
323
|
}
|
|
114
324
|
|
|
115
325
|
# --- cc-switch provider 标签 ---
|
|
@@ -453,29 +663,40 @@ _new_session_owns_pane() {
|
|
|
453
663
|
# BFS 遍历 pane 的进程树,检查是否有 AI CLI 进程。
|
|
454
664
|
# 进程名集合从所有 adapter 的 ADAPTER_PROCESS_NAMES 汇总(_collect_ai_process_names),
|
|
455
665
|
# 前缀匹配(codex 二进制常为 codex-aarch64-* 等带后缀名)。
|
|
456
|
-
|
|
666
|
+
# $1=basename 命令 $2=工具进程名(如 codex/opencode)→ 是否前缀匹配
|
|
667
|
+
_is_tool_process_name() {
|
|
668
|
+
case "${1:-}" in
|
|
669
|
+
"$2"|"$2"-*|"$2"_*) return 0 ;;
|
|
670
|
+
esac
|
|
671
|
+
return 1
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
_pane_current_command() { # $1=pane_id → 前台命令
|
|
457
675
|
local pane_id="$1"
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
676
|
+
tmux list-panes -a -F "#{pane_id}|#{pane_current_command}" 2>/dev/null \
|
|
677
|
+
| awk -F'|' -v id="$pane_id" '$1==id{print $2; exit}'
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
_pane_command_is_tool() { # $1=pane_id $2=工具名
|
|
681
|
+
local current_command
|
|
682
|
+
current_command=$(_pane_current_command "$1")
|
|
683
|
+
[ -n "$current_command" ] || return 1
|
|
684
|
+
_is_tool_process_name "$(basename "$current_command" 2>/dev/null)" "$2"
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
# 从给定 pid 出发 BFS,匹配进程名集合(空格分隔,前缀匹配)。
|
|
688
|
+
_pid_tree_has_names() { # $1=pane_pid $2=names
|
|
689
|
+
local pane_pid="$1" names="$2"
|
|
690
|
+
local pids next depth p cmd base name children
|
|
691
|
+
[ -n "$pane_pid" ] || return 1
|
|
692
|
+
pids="$pane_pid"; next=""; depth=0
|
|
468
693
|
while [ $depth -lt 10 ] && [ -n "$pids" ]; do
|
|
469
694
|
for p in $pids; do
|
|
470
|
-
local cmd base name
|
|
471
695
|
cmd=$(ps -o comm= -p "$p" 2>/dev/null)
|
|
472
696
|
base=$(basename "$cmd" 2>/dev/null)
|
|
473
|
-
for name in $
|
|
474
|
-
|
|
475
|
-
"$name"|"$name"-*|"$name"_*) return 0 ;;
|
|
476
|
-
esac
|
|
697
|
+
for name in $names; do
|
|
698
|
+
_is_tool_process_name "$base" "$name" && return 0
|
|
477
699
|
done
|
|
478
|
-
local children
|
|
479
700
|
children=$(pgrep -P "$p" 2>/dev/null)
|
|
480
701
|
[ -n "$children" ] && next="$next $children"
|
|
481
702
|
done
|
|
@@ -486,6 +707,29 @@ _pane_has_ai_process() {
|
|
|
486
707
|
return 1
|
|
487
708
|
}
|
|
488
709
|
|
|
710
|
+
# BFS pane 进程树,匹配给定进程名集合(空格分隔,前缀匹配)。
|
|
711
|
+
_pane_has_process_names() { # $1=pane_id $2=names
|
|
712
|
+
local pane_id="$1" pane_pid
|
|
713
|
+
# 注意:list-panes -t "%N" 会返回该 pane 所在 window 的全部 pane,
|
|
714
|
+
# 直接取 #{pane_pid} 会混入同 window 其他 pane 的 pid,导致 BFS 爬到
|
|
715
|
+
# 邻近 pane 的 AI 进程而误判。必须用 -a 全局列 + 精确匹配 pane_id。
|
|
716
|
+
pane_pid=$(tmux list-panes -a -F "#{pane_id} #{pane_pid}" 2>/dev/null \
|
|
717
|
+
| awk -v id="$pane_id" '$1==id{print $2; exit}')
|
|
718
|
+
[ -z "$pane_pid" ] && return 1
|
|
719
|
+
_pid_tree_has_names "$pane_pid" "$2"
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
# 判定 pane 进程树内是否仍有该工具进程(前台命令优先,再 BFS 遍历进程树)
|
|
723
|
+
_pane_has_tool_process() { # $1=pane_id $2=工具名
|
|
724
|
+
_pane_command_is_tool "$1" "$2" && return 0
|
|
725
|
+
_pane_has_process_names "$1" "$2"
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
# 所有 adapter 汇总的 AI 进程名
|
|
729
|
+
_pane_has_ai_process() { # $1=pane_id
|
|
730
|
+
_pane_has_process_names "$1" "$(_collect_ai_process_names)"
|
|
731
|
+
}
|
|
732
|
+
|
|
489
733
|
# 检查 pane 的所有 pane-id 文件是否过期(> 5 min),再验证进程树
|
|
490
734
|
_is_pane_session_dead() {
|
|
491
735
|
local pane_id="$1"
|
package/scripts/tmux-ai-monitor
CHANGED
|
@@ -20,6 +20,8 @@ MONITOR_INTERVAL="${TMUX_AI_MONITOR_INTERVAL:-1}"
|
|
|
20
20
|
# 进程树探测节流:launcher pane 每 N 个 tick 做一次 BFS(run 循环内 _TICK 持续累加)。
|
|
21
21
|
_MONITOR_PROBE_EVERY=5
|
|
22
22
|
_MONITOR_TICK=0
|
|
23
|
+
# 升级自检:每 N tick 对比脚本内容指纹,变化则 exec 自身重载新代码。
|
|
24
|
+
_MONITOR_RECHECK_EVERY=60
|
|
23
25
|
|
|
24
26
|
# 常见 launcher:switch/volta/npm 系脚本在前台时,目标 CLI 位于其子进程树中,
|
|
25
27
|
# pane_current_command 不会直接等于 codex/opencode(cc-switch 的 switch 启动即如此)。
|
|
@@ -34,10 +36,11 @@ _tmux_server_pid() {
|
|
|
34
36
|
tmux display-message -p '#{pid}' 2>/dev/null
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
# 脚本内容指纹:npm 升级可能保留 tarball 旧 mtime,用内容而不是 mtime 判升级。
|
|
40
|
+
# 文件顺序固定(显式文件 + 排序后的 adapters/*.sh),cksum 跨平台可用。
|
|
41
|
+
_scripts_fingerprint() {
|
|
42
|
+
( cd "$SCRIPT_DIR" 2>/dev/null || return 1
|
|
43
|
+
cat tmux-ai-monitor lib-tmux-ai-status.sh adapters/*.sh 2>/dev/null | cksum )
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
# 无 SessionEnd 的工具名集合(惰性求值缓存)。扫 adapters/*.sh 提取
|
|
@@ -57,48 +60,9 @@ _collect_no_session_end_tools() {
|
|
|
57
60
|
echo "$_NO_SESSION_END_TOOLS"
|
|
58
61
|
}
|
|
59
62
|
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
case "$base" in
|
|
64
|
-
"$name"|"$name"-*|"$name"_*) return 0 ;;
|
|
65
|
-
esac
|
|
66
|
-
return 1
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
# 判定 pane 前台命令是否属于给定工具名
|
|
70
|
-
_pane_command_is_tool() {
|
|
71
|
-
local pane_id="$1" name="$2" current_command
|
|
72
|
-
current_command=$(_pane_current_command "$pane_id")
|
|
73
|
-
[ -n "$current_command" ] || return 1
|
|
74
|
-
_is_tool_command "$(basename "$current_command" 2>/dev/null)" "$name"
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
# 判定 pane 进程树内是否仍有该工具进程(前台命令优先,再 BFS 遍历进程树)
|
|
78
|
-
_pane_has_tool_process() {
|
|
79
|
-
local pane_id="$1" name="$2"
|
|
80
|
-
_pane_command_is_tool "$pane_id" "$name" && return 0
|
|
81
|
-
|
|
82
|
-
local pane_pid
|
|
83
|
-
pane_pid=$(tmux list-panes -a -F "#{pane_id} #{pane_pid}" 2>/dev/null \
|
|
84
|
-
| awk -v id="$pane_id" '$1==id{print $2; exit}')
|
|
85
|
-
[ -n "$pane_pid" ] || return 1
|
|
86
|
-
|
|
87
|
-
local pids="$pane_pid" next="" depth=0 p cmd base children
|
|
88
|
-
while [ "$depth" -lt 10 ] && [ -n "$pids" ]; do
|
|
89
|
-
for p in $pids; do
|
|
90
|
-
cmd=$(ps -o comm= -p "$p" 2>/dev/null)
|
|
91
|
-
base=$(basename "$cmd" 2>/dev/null)
|
|
92
|
-
_is_tool_command "$base" "$name" && return 0
|
|
93
|
-
children=$(pgrep -P "$p" 2>/dev/null)
|
|
94
|
-
[ -n "$children" ] && next="$next $children"
|
|
95
|
-
done
|
|
96
|
-
pids="${next# }"
|
|
97
|
-
next=""
|
|
98
|
-
depth=$((depth + 1))
|
|
99
|
-
done
|
|
100
|
-
return 1
|
|
101
|
-
}
|
|
63
|
+
# 进程树 / 前台命令判定 helper(_is_tool_process_name、_pane_command_is_tool、
|
|
64
|
+
# _pane_has_tool_process、_pane_current_command)已统一放在 lib-tmux-ai-status.sh,
|
|
65
|
+
# run/tick 模式 source 后可直接使用,避免两份实现漂移。
|
|
102
66
|
|
|
103
67
|
# 对每个无 SessionEnd 的工具名做生命周期检测:前台命令匹配或 launcher 进程树命中
|
|
104
68
|
# → 标记见过;曾标记但前台已切走且进程树也无该工具 → 清空 pane 状态(该工具已退出)。
|
|
@@ -110,7 +74,7 @@ _monitor_no_session_end_lifecycle() {
|
|
|
110
74
|
[ -n "$tools" ] || return 0
|
|
111
75
|
for name in $tools; do
|
|
112
76
|
while IFS='|' read -r pane_id current_command seen attached; do
|
|
113
|
-
if [ -n "$current_command" ] &&
|
|
77
|
+
if [ -n "$current_command" ] && _is_tool_process_name "$(basename "$current_command" 2>/dev/null)" "$name"; then
|
|
114
78
|
[ "$seen" = "1" ] || tmux set-option -pt "$pane_id" "@ai_monitor_seen_${name}" 1 2>/dev/null || true
|
|
115
79
|
elif [ "$seen" = "1" ] && ! _pane_has_tool_process "$pane_id" "$name"; then
|
|
116
80
|
tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
|
|
@@ -171,6 +135,10 @@ case "$MODE" in
|
|
|
171
135
|
EVENT="_monitor"
|
|
172
136
|
source "${SCRIPT_DIR}/lib-tmux-ai-status.sh"
|
|
173
137
|
|
|
138
|
+
# 升级自检基线;exec 失败时继续运行(execfail),避免 npm 替换文件瞬间导致 monitor 死亡。
|
|
139
|
+
_MONITOR_FP=$(_scripts_fingerprint)
|
|
140
|
+
shopt -s execfail 2>/dev/null || true
|
|
141
|
+
|
|
174
142
|
cleanup_monitor_option() {
|
|
175
143
|
local registered_pid
|
|
176
144
|
registered_pid=$(tmux show-option -gv @ai_monitor_pid 2>/dev/null || true)
|
|
@@ -189,6 +157,17 @@ case "$MODE" in
|
|
|
189
157
|
current_generation=$(tmux show-option -gv @ai_monitor_generation 2>/dev/null || true)
|
|
190
158
|
[ "$current_generation" = "$generation" ] || break
|
|
191
159
|
|
|
160
|
+
# 脚本内容变化(npm 升级/重装)→ 同 PID exec 自重启,重载新代码。
|
|
161
|
+
# TMUX_AI_MONITOR_RELOAD_DISABLE=1 可关闭(运维逃生阀)。
|
|
162
|
+
if [ $((_MONITOR_TICK % _MONITOR_RECHECK_EVERY)) -eq 0 ] \
|
|
163
|
+
&& [ "${TMUX_AI_MONITOR_RELOAD_DISABLE:-}" != "1" ]; then
|
|
164
|
+
_now_fp=$(_scripts_fingerprint)
|
|
165
|
+
if [ -n "$_now_fp" ] && [ "$_now_fp" != "$_MONITOR_FP" ] \
|
|
166
|
+
&& [ -x "$SCRIPT_DIR/tmux-ai-monitor" ]; then
|
|
167
|
+
exec "$SCRIPT_DIR/tmux-ai-monitor" run "$expected_server_pid" "$generation"
|
|
168
|
+
fi
|
|
169
|
+
fi
|
|
170
|
+
|
|
192
171
|
# 单次 tmux 查询失败不应永久杀死 monitor;server/generation 检查负责终止。
|
|
193
172
|
_monitor_tick || true
|
|
194
173
|
sleep "$MONITOR_INTERVAL"
|
|
@@ -208,8 +187,17 @@ case "$MODE" in
|
|
|
208
187
|
_monitor_tick
|
|
209
188
|
;;
|
|
210
189
|
|
|
190
|
+
selfcheck)
|
|
191
|
+
# 诊断/测试用:当前脚本目录指纹与 $2 不同 → exit 0(需要重载)。
|
|
192
|
+
expected_fp="${2:-}"
|
|
193
|
+
[ -n "$expected_fp" ] || exit 1
|
|
194
|
+
current_fp=$(_scripts_fingerprint)
|
|
195
|
+
[ -n "$current_fp" ] && [ "$current_fp" != "$expected_fp" ] && exit 0
|
|
196
|
+
exit 1
|
|
197
|
+
;;
|
|
198
|
+
|
|
211
199
|
*)
|
|
212
|
-
echo "Usage: $0 [start|run|stop|tick]" >&2
|
|
200
|
+
echo "Usage: $0 [start|run|stop|tick|selfcheck <fingerprint>]" >&2
|
|
213
201
|
exit 2
|
|
214
202
|
;;
|
|
215
203
|
esac
|
package/scripts/tmux-ai-status
CHANGED
|
@@ -12,12 +12,20 @@ EVENT="$2"
|
|
|
12
12
|
|
|
13
13
|
INPUT=$(cat)
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# 一次 jq 提取:session_id + hook cwd(codex/claude 均提供 cwd;
|
|
16
|
+
# TMUX_PANE 无法解析时 cwd 用于 pane 消歧)
|
|
17
|
+
_hook_fields=$(printf '%s' "$INPUT" | jq -r '[(.session_id // ""), (.cwd // "")] | @tsv' 2>/dev/null)
|
|
18
|
+
IFS=$'\t' read -r SESSION_ID HOOK_CWD <<< "$_hook_fields"
|
|
16
19
|
|
|
17
20
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
21
|
source "${SCRIPT_DIR}/lib-tmux-ai-status.sh"
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
# tmux 生命周期伪事件(_refresh / _session-switched)只重建聚合,
|
|
24
|
+
# 不写 per-pane 状态,无需解析 pane——避免每次 refresh 都做快照/候选 BFS。
|
|
25
|
+
case "$EVENT" in
|
|
26
|
+
_refresh|_session-switched) ;;
|
|
27
|
+
*) resolve_tmux_pane ;;
|
|
28
|
+
esac
|
|
21
29
|
# 同 pane 的并发 hooks 必须完整串行化,否则一个事件可能用过期派生结果覆盖另一个
|
|
22
30
|
# PermissionRequest 已写入的审批态。
|
|
23
31
|
if [ -n "$TMUX_PANE" ]; then
|
|
@@ -252,7 +260,10 @@ case "$EVENT" in
|
|
|
252
260
|
;;
|
|
253
261
|
esac
|
|
254
262
|
|
|
255
|
-
|
|
263
|
+
# pane 解析失败时不写全局状态(避免把无归属事件当成有效状态)
|
|
264
|
+
if [ -n "$TMUX_PANE" ]; then
|
|
265
|
+
tmux set-option -g @ai_status "$STATUS" 2>/dev/null || true
|
|
266
|
+
fi
|
|
256
267
|
|
|
257
268
|
# 构造事件块详情行:
|
|
258
269
|
# tool: <tool_name> id=<short tool_use_id>
|