tmuxclihook 0.5.6 → 0.5.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmuxclihook",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "交互式 TUI 安装器:环境侦测、AI CLI 侦测、tmux-ai-status hooks 装/卸/修、TPM 软链校验",
5
5
  "type": "module",
6
6
  "bin": {
@@ -684,31 +684,95 @@ _pane_command_is_tool() { # $1=pane_id $2=工具名
684
684
  _is_tool_process_name "$(basename "$current_command" 2>/dev/null)" "$2"
685
685
  }
686
686
 
687
+ # 全量进程快照(一次 ps):每行 "pid ppid comm"。供多次进程树 BFS 复用。
688
+ # 旧的逐 pid `ps -o comm=` + `pgrep -P` 实现在 pane 多的 tick 内会放大到秒级
689
+ # (monitor 每 tick 要对每个 launcher pane × 每个工具做一次),改为一次快照 + 内存 BFS。
690
+ _ps_process_snapshot() {
691
+ ps -axo pid=,ppid=,comm= 2>/dev/null
692
+ }
693
+
687
694
  # 从给定 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
695
+ # 名称前缀匹配语义同 _is_tool_process_name(codex 匹配 codex-aarch64-*)。
696
+ _pid_tree_has_names() { # $1=pane_pid $2=names [$3=进程快照]
697
+ local pane_pid="$1" names="$2" snapshot="${3:-}"
691
698
  [ -n "$pane_pid" ] || return 1
692
- pids="$pane_pid"; next=""; depth=0
693
- while [ $depth -lt 10 ] && [ -n "$pids" ]; do
694
- for p in $pids; do
695
- cmd=$(ps -o comm= -p "$p" 2>/dev/null)
696
- base=$(basename "$cmd" 2>/dev/null)
697
- for name in $names; do
698
- _is_tool_process_name "$base" "$name" && return 0
699
- done
700
- children=$(pgrep -P "$p" 2>/dev/null)
701
- [ -n "$children" ] && next="$next $children"
702
- done
703
- pids="${next# }"
704
- next=""
705
- depth=$((depth + 1))
706
- done
707
- return 1
699
+ [ -n "$snapshot" ] || snapshot=$(_ps_process_snapshot)
700
+ [ -n "$snapshot" ] || return 1
701
+ printf '%s\n' "$snapshot" | awk -v root="$pane_pid" -v names="$names" '
702
+ { cmd[$1] = $3; if ($2 != "") children[$2] = children[$2] " " $1 }
703
+ END {
704
+ n = split(names, want, " ")
705
+ frontier = root
706
+ depth = 0
707
+ while (frontier != "" && depth < 10) {
708
+ m = split(frontier, pids, " ")
709
+ nxt = ""
710
+ for (i = 1; i <= m; i++) {
711
+ base = cmd[pids[i]]
712
+ sub(/.*\//, "", base)
713
+ for (j = 1; j <= n; j++) {
714
+ nm = want[j]
715
+ if (base == nm \
716
+ || substr(base, 1, length(nm) + 1) == nm "-" \
717
+ || substr(base, 1, length(nm) + 1) == nm "_") exit 0
718
+ }
719
+ c = split(children[pids[i]], kids, " ")
720
+ for (k = 1; k <= c; k++) {
721
+ if (kids[k] == "") continue
722
+ nxt = nxt (nxt == "" ? "" : " ") kids[k]
723
+ }
724
+ }
725
+ frontier = nxt
726
+ depth++
727
+ }
728
+ exit 1
729
+ }'
730
+ }
731
+
732
+ # 批量版:一次快照 + 一次 awk,判定每个 root × 每个 name。
733
+ # 输出命中行 "<pid><TAB><name>";monitor 用它把多个 pane 的存活探测合并成一次
734
+ # awk(逐 pane 调用 _pid_tree_has_names 会重复解析同一份快照)。
735
+ _pid_trees_hits() { # $1=起点 pid 列表 $2=进程名列表 [$3=进程快照]
736
+ local roots="$1" names="$2" snapshot="${3:-}"
737
+ [ -n "$roots" ] && [ -n "$names" ] || return 0
738
+ [ -n "$snapshot" ] || snapshot=$(_ps_process_snapshot)
739
+ [ -n "$snapshot" ] || return 0
740
+ printf '%s\n' "$snapshot" | awk -v roots="$roots" -v names="$names" '
741
+ { cmd[$1] = $3; if ($2 != "") children[$2] = children[$2] " " $1 }
742
+ END {
743
+ nr = split(roots, r, " ")
744
+ nn = split(names, n, " ")
745
+ for (i = 1; i <= nr; i++)
746
+ for (j = 1; j <= nn; j++)
747
+ if (_tree_has(r[i], n[j])) print r[i] "\t" n[j]
748
+ }
749
+ function _tree_has(root, want, frontier, nxt, depth, m, pids, i, k, base, c, kids) {
750
+ frontier = root
751
+ depth = 0
752
+ while (frontier != "" && depth < 10) {
753
+ m = split(frontier, pids, " ")
754
+ nxt = ""
755
+ for (i = 1; i <= m; i++) {
756
+ base = cmd[pids[i]]
757
+ sub(/.*\//, "", base)
758
+ if (base == want \
759
+ || substr(base, 1, length(want) + 1) == want "-" \
760
+ || substr(base, 1, length(want) + 1) == want "_") return 1
761
+ c = split(children[pids[i]], kids, " ")
762
+ for (k = 1; k <= c; k++) {
763
+ if (kids[k] == "") continue
764
+ nxt = nxt (nxt == "" ? "" : " ") kids[k]
765
+ }
766
+ }
767
+ frontier = nxt
768
+ depth++
769
+ }
770
+ return 0
771
+ }'
708
772
  }
709
773
 
710
774
  # BFS pane 进程树,匹配给定进程名集合(空格分隔,前缀匹配)。
711
- _pane_has_process_names() { # $1=pane_id $2=names
775
+ _pane_has_process_names() { # $1=pane_id $2=names [$3=进程快照]
712
776
  local pane_id="$1" pane_pid
713
777
  # 注意:list-panes -t "%N" 会返回该 pane 所在 window 的全部 pane,
714
778
  # 直接取 #{pane_pid} 会混入同 window 其他 pane 的 pid,导致 BFS 爬到
@@ -716,13 +780,13 @@ _pane_has_process_names() { # $1=pane_id $2=names
716
780
  pane_pid=$(tmux list-panes -a -F "#{pane_id} #{pane_pid}" 2>/dev/null \
717
781
  | awk -v id="$pane_id" '$1==id{print $2; exit}')
718
782
  [ -z "$pane_pid" ] && return 1
719
- _pid_tree_has_names "$pane_pid" "$2"
783
+ _pid_tree_has_names "$pane_pid" "$2" "${3:-}"
720
784
  }
721
785
 
722
786
  # 判定 pane 进程树内是否仍有该工具进程(前台命令优先,再 BFS 遍历进程树)
723
- _pane_has_tool_process() { # $1=pane_id $2=工具名
787
+ _pane_has_tool_process() { # $1=pane_id $2=工具名 [$3=进程快照]
724
788
  _pane_command_is_tool "$1" "$2" && return 0
725
- _pane_has_process_names "$1" "$2"
789
+ _pane_has_process_names "$1" "$2" "${3:-}"
726
790
  }
727
791
 
728
792
  # 所有 adapter 汇总的 AI 进程名
@@ -5,7 +5,7 @@
5
5
  # SessionStart 还会延迟到首个 turn。本监控器每秒刷新:
6
6
  # - 前台启动此类工具、尚无 hook 状态时,显示 idle `-`
7
7
  # - cc-switch 的 `switch` 启动时前台命令是 volta-shim/node 而非工具名,改为
8
- # (每 N 个 tick)探测 launcher pane 的进程树来识别,并写 seen 标记供聚合补 `-`
8
+ # 每 tick 探测 launcher pane 的进程树来识别,并写 seen 标记供聚合补 `-`
9
9
  # - 退出返回 shell(或进程树已无该工具)后,清除残留 pane 状态
10
10
  # 泛化实现:扫描 adapters/*.sh 找出 ADAPTER_HAS_SESSION_END="false" 的工具及其
11
11
  # ADAPTER_PROCESS_NAMES,逐个工具做生命周期检测——新增同类工具的 adapter 无需改本文件。
@@ -17,8 +17,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
17
17
  _ADAPTER_DIR="${SCRIPT_DIR}/adapters"
18
18
  MODE="${1:-start}"
19
19
  MONITOR_INTERVAL="${TMUX_AI_MONITOR_INTERVAL:-1}"
20
- # 进程树探测节流:launcher pane 每 N 个 tick 做一次 BFS(run 循环内 _TICK 持续累加)。
21
- _MONITOR_PROBE_EVERY=5
20
+ # 进程树探测不再跨 tick 节流:lib 用一次 ps 快照 + 一次 awk 批量判定所有 pane,
21
+ # 单 tick 成本 ~30ms,每 tick 探测让新启动的工具在 ≤1 个 tick(≈1s)内显示。
22
22
  _MONITOR_TICK=0
23
23
  # 升级自检:每 N tick 对比脚本内容指纹,变化则 exec 自身重载新代码。
24
24
  _MONITOR_RECHECK_EVERY=60
@@ -68,31 +68,64 @@ _collect_no_session_end_tools() {
68
68
  # → 标记见过;曾标记但前台已切走且进程树也无该工具 → 清空 pane 状态(该工具已退出)。
69
69
  # seen 标记按工具名区分(@ai_monitor_seen_<name>),避免多工具互相踩踏清理语义;
70
70
  # 该标记同时是 build_all_status 给 launcher pane 补 idle `-` 的依据。
71
+ #
72
+ # 性能:每 tick 只取一次进程快照,并把「待识别 launcher」与「已见 pane 存活确认」
73
+ # 合并成一次 _pid_trees_hits(一次 awk)。旧实现逐 pane 调 _pane_has_tool_process,
74
+ # 每个 pid 各起一次 ps/pgrep,launcher 多的环境单 tick 可达 1.8s,叠加 5 tick 节流
75
+ # 后新启动 codex 要 7-8s 才显示(2026-10-19 实测)。
71
76
  _monitor_no_session_end_lifecycle() {
72
- local tools name pane_id current_command sanitized pane_dir
77
+ local tools name pane_id current_command seen attached pane_pid
73
78
  tools=$(_collect_no_session_end_tools)
74
79
  [ -n "$tools" ] || return 0
80
+ local snapshot
81
+ snapshot=$(_ps_process_snapshot)
75
82
  for name in $tools; do
76
- while IFS='|' read -r pane_id current_command seen attached; do
83
+ local probe="" watch="" roots="" hits="" hit_pid hit_name hit_pids=" "
84
+ local entry sanitized pane_dir
85
+ while IFS='|' read -r pane_id current_command seen attached pane_pid; do
86
+ [ -n "$pane_id" ] || continue
77
87
  if [ -n "$current_command" ] && _is_tool_process_name "$(basename "$current_command" 2>/dev/null)" "$name"; then
88
+ # 前台命令即目标工具:直接标记见过(无需 BFS)
78
89
  [ "$seen" = "1" ] || tmux set-option -pt "$pane_id" "@ai_monitor_seen_${name}" 1 2>/dev/null || true
79
- elif [ "$seen" = "1" ] && ! _pane_has_tool_process "$pane_id" "$name"; then
80
- tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
81
- tmux set-option -pu -t "$pane_id" "@ai_monitor_seen_${name}" 2>/dev/null || true
82
- sanitized="${pane_id//[^a-zA-Z0-9]/_}"
83
- pane_dir="${_STATUS_DIR}/${sanitized}"
84
- rm -rf "$pane_dir" 2>/dev/null
85
- elif [ "$seen" != "1" ] \
86
- && [ "${attached:-0}" -gt 0 ] \
87
- && [ $((_MONITOR_TICK % _MONITOR_PROBE_EVERY)) -eq 0 ] \
88
- && _is_launcher_command "$current_command" \
89
- && _pane_has_tool_process "$pane_id" "$name"; then
90
+ elif [ "$seen" = "1" ] && [ -n "$pane_pid" ]; then
91
+ # 曾见过:确认进程树里是否还有该工具(前台命令已在上一分支排除)
92
+ watch="$watch $pane_id:$pane_pid"
93
+ elif [ "${attached:-0}" -gt 0 ] && _is_launcher_command "$current_command" && [ -n "$pane_pid" ]; then
90
94
  # launcher(switch/volta-shim/node)启动:前台命令不是工具名,
91
- # 但进程树里有。标记 seen 后 build_all_status 才会补 idle `-`,
92
- # 工具退出时也才会走上面的清理分支(否则残留状态永不清)。
93
- tmux set-option -pt "$pane_id" "@ai_monitor_seen_${name}" 1 2>/dev/null || true
95
+ # 但进程树里可能有。标记 seen 后 build_all_status 才会补 idle `-`,
96
+ # 工具退出时也才会走清理分支(否则残留状态永不清)。
97
+ probe="$probe $pane_id:$pane_pid"
94
98
  fi
95
- done < <(tmux list-panes -a -F "#{pane_id}|#{pane_current_command}|#{@ai_monitor_seen_${name}}|#{session_attached}" 2>/dev/null)
99
+ done < <(tmux list-panes -a -F "#{pane_id}|#{pane_current_command}|#{@ai_monitor_seen_${name}}|#{session_attached}|#{pane_pid}" 2>/dev/null)
100
+
101
+ for entry in $probe $watch; do roots="$roots ${entry#*:}"; done
102
+ [ -n "$roots" ] && hits=$(_pid_trees_hits "$roots" "$name" "$snapshot")
103
+ while IFS=$'\t' read -r hit_pid hit_name; do
104
+ [ -n "$hit_pid" ] && hit_pids="$hit_pids$hit_pid "
105
+ done <<EOF
106
+ $hits
107
+ EOF
108
+
109
+ for entry in $probe; do
110
+ pane_pid="${entry#*:}"
111
+ case "$hit_pids" in
112
+ *" $pane_pid "*) tmux set-option -pt "${entry%%:*}" "@ai_monitor_seen_${name}" 1 2>/dev/null || true ;;
113
+ esac
114
+ done
115
+ for entry in $watch; do
116
+ pane_pid="${entry#*:}"
117
+ case "$hit_pids" in
118
+ *" $pane_pid "*) ;;
119
+ *)
120
+ pane_id="${entry%%:*}"
121
+ tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
122
+ tmux set-option -pu -t "$pane_id" "@ai_monitor_seen_${name}" 2>/dev/null || true
123
+ sanitized="${pane_id//[^a-zA-Z0-9]/_}"
124
+ pane_dir="${_STATUS_DIR}/${sanitized}"
125
+ rm -rf "$pane_dir" 2>/dev/null
126
+ ;;
127
+ esac
128
+ done
96
129
  done
97
130
  }
98
131
 
@@ -20,7 +20,12 @@ IFS=$'\t' read -r SESSION_ID HOOK_CWD <<< "$_hook_fields"
20
20
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
21
  source "${SCRIPT_DIR}/lib-tmux-ai-status.sh"
22
22
 
23
- 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
24
29
  # 同 pane 的并发 hooks 必须完整串行化,否则一个事件可能用过期派生结果覆盖另一个
25
30
  # PermissionRequest 已写入的审批态。
26
31
  if [ -n "$TMUX_PANE" ]; then