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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmuxclihook",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "交互式 TUI 安装器:环境侦测、AI CLI 侦测、tmux-ai-status hooks 装/卸/修、TPM 软链校验",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- # --- TMUX_PANE 解析 ---
73
- # hook 子进程不继承 $TMUX_PANE,通过进程树向上查找所属 pane
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
- if [ -z "$TMUX_PANE" ]; then
76
- if [ -n "$TMUX" ]; then
77
- :
78
- elif command -v tmux &>/dev/null; then
79
- :
80
- else
81
- return
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
- local check_pid=$$
84
- while [ "${check_pid:-0}" -gt 1 ]; do
85
- local found
86
- found=$(tmux list-panes -a -F "#{pane_id} #{pane_pid}" 2>/dev/null \
87
- | awk -v pid="$check_pid" '$2==pid{print $1; exit}')
88
- if [ -n "$found" ]; then TMUX_PANE="$found"; break; fi
89
- check_pid=$(ps -o ppid= -p "$check_pid" 2>/dev/null | tr -d '[:space:]')
90
- done
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 持久化(供 SessionEnd 回读)---
95
- # 注意: SessionStart/SessionEnd 事件不含 session_id,_save_pane_id 在这两事件中不会保存。
96
- # 映射由后续事件(PreToolUse、PermissionRequest 等)建立。
97
- # SessionEnd 回退链: resolve_tmux_pane → _load_pane_id → _cleanup_stale_panes。
98
- # 若 Claude 会话全程无工具调用,pane 状态可能残留最多 60s(直到下次 cleanup)。
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" ] && { _ensure_pane_dir; echo "$TMUX_PANE" > "$(_pane_dir)/pane-${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
- [ -z "$SESSION_ID" ] && return 1
104
- local f
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
- TMUX_PANE=$(cat "$f" 2>/dev/null) && [ -n "$TMUX_PANE" ] && return 0
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 "$SESSION_ID" ] && rm -f "$(_pane_dir)/pane-${SESSION_ID}"
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
- _pane_has_ai_process() {
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
- local pane_pid
459
- # 注意:list-panes -t "%N" 会返回该 pane 所在 window 的全部 pane,
460
- # 直接取 #{pane_pid} 会混入同 window 其他 pane 的 pid,导致 BFS 爬到
461
- # 邻近 pane 的 AI 进程而误判。必须用 -a 全局列 + 精确匹配 pane_id。
462
- pane_pid=$(tmux list-panes -a -F "#{pane_id} #{pane_pid}" 2>/dev/null \
463
- | awk -v id="$pane_id" '$1==id{print $2; exit}')
464
- [ -z "$pane_pid" ] && return 1
465
- local ai_names
466
- ai_names=$(_collect_ai_process_names)
467
- local pids="$pane_pid" next="" depth=0
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 $ai_names; do
474
- case "$base" in
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"
@@ -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
- _pane_current_command() {
38
- local pane_id="$1"
39
- tmux list-panes -a -F "#{pane_id}|#{pane_current_command}" 2>/dev/null \
40
- | awk -F'|' -v id="$pane_id" '$1==id{print $2; exit}'
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
- # $1=basename 命令 $2=工具进程名(如 codex/opencode)→ 是否前缀匹配
61
- _is_tool_command() {
62
- local base="$1" name="$2"
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" ] && _is_tool_command "$(basename "$current_command" 2>/dev/null)" "$name"; then
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
@@ -12,12 +12,20 @@ EVENT="$2"
12
12
 
13
13
  INPUT=$(cat)
14
14
 
15
- SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
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
- resolve_tmux_pane
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
- tmux set-option -g @ai_status "$STATUS" 2>/dev/null || true
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>