tmuxclihook 0.5.7 → 0.5.9
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 +1 -1
- package/scripts/lib-tmux-ai-log.sh +5 -2
- package/scripts/lib-tmux-ai-status.sh +172 -47
- package/scripts/tmux-ai-monitor +53 -20
- package/scripts/tmux-ai-status +34 -18
package/package.json
CHANGED
|
@@ -69,9 +69,12 @@ _ai_log_event() {
|
|
|
69
69
|
[ "${fsize:-0}" -gt "$AI_LOG_MAX_SIZE" ] && _ai_log_rotate
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
# input
|
|
72
|
+
# input 超长截断:默认 >2000B 保留前 2000B 加 "…+Nbytes";$2 可传自定义上限。
|
|
73
|
+
# PermissionRequest 的 full 日志路径传 16384(保留调试价值同时限制体积)。
|
|
74
|
+
# 注意:即便 16384 也远超 PIPE_BUF(macOS 512 / Linux 4096),并发 hook 的
|
|
75
|
+
# 大 input 日志行可能交错撕裂——接受(日志观测用途,非结构化数据)。
|
|
73
76
|
_ai_log_truncate_input() {
|
|
74
|
-
local s="$1" max=2000
|
|
77
|
+
local s="$1" max="${2:-2000}"
|
|
75
78
|
local n=${#s}
|
|
76
79
|
if [ "$n" -gt "$max" ]; then
|
|
77
80
|
printf '%s…+%dbytes' "${s:0:$max}" "$((n - max))"
|
|
@@ -113,26 +113,51 @@ _snapshot_has_pane() { # $1=pane_id
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
# 从当前进程沿父链向上匹配 pane_pid;命中即返回该 pane(权威信号)。
|
|
116
|
+
# $1 可选进程快照(_ps_process_snapshot 输出):调用方(resolve_tmux_pane)
|
|
117
|
+
# 传入以复用同一份快照(ancestry 与进程树候选共用,每事件一次 ps);空参自取。
|
|
118
|
+
# 一次 awk 同时建 ppid 映射与 pane_pid→pane_id 表,从 $$ 内存爬链——
|
|
119
|
+
# depth<24 / pid>1 语义与旧逐层 `ps -o ppid=` 版一致;快照是瞬时一致视图,
|
|
120
|
+
# 还消除了逐层实时 ps 的爬链中途进程死亡问题。
|
|
116
121
|
_pane_from_ancestry() {
|
|
117
|
-
local
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
122
|
+
local snapshot="${1:-}"
|
|
123
|
+
[ -n "$snapshot" ] || snapshot=$(_ps_process_snapshot)
|
|
124
|
+
[ -n "$snapshot" ] || return 1
|
|
125
|
+
# ps 快照与 pane 快照均为变量内容(非文件),各经 process substitution 进 awk:
|
|
126
|
+
# 第一个文件(NR==FNR)建 ppid 映射,第二个建 pane_pid→pane_id 表,END 爬链。
|
|
127
|
+
# ⚠️ split 必须用单空格 " "(awk 特殊语义:忽略前导/尾部空白、折叠连续分隔),
|
|
128
|
+
# 不能用 /[ \t]+/——macOS ps 的 pid 列宽随快照最大 pid 位数浮动,4 位 pid 行
|
|
129
|
+
# 带前导空格,正则 split 的首字段会是空串(pid 全部错位/丢弃),爬链在低位
|
|
130
|
+
# pid 处断链——表现为偶发 PANE: unresolved(实测踩坑,2026-10-20)。
|
|
131
|
+
awk -F'|' -v self="$$" '
|
|
132
|
+
NR==FNR {
|
|
133
|
+
split($0, a, " ")
|
|
134
|
+
if (a[1] != "") ppid[a[1]] = a[2]
|
|
135
|
+
next
|
|
136
|
+
}
|
|
137
|
+
$4 != "" { pane_by_pid[$4] = $1 }
|
|
138
|
+
END {
|
|
139
|
+
pid = self
|
|
140
|
+
depth = 0
|
|
141
|
+
while (pid + 0 > 1 && depth < 24) {
|
|
142
|
+
if (pid in pane_by_pid) { print pane_by_pid[pid]; exit 0 }
|
|
143
|
+
if (!(pid in ppid)) exit 1
|
|
144
|
+
pid = ppid[pid] + 0
|
|
145
|
+
depth++
|
|
146
|
+
}
|
|
147
|
+
exit 1
|
|
148
|
+
}' <(printf '%s\n' "$snapshot") <(printf '%s\n' "${_PANE_SNAPSHOT:-}")
|
|
125
149
|
}
|
|
126
150
|
|
|
127
151
|
# 当前工具在 attached pane 中的候选集合(进程树含 ADAPTER_PROCESS_NAMES)。
|
|
152
|
+
# $1 可选进程快照:透传给 _pid_tree_has_names,避免每个候选 pane 各起一次 ps。
|
|
128
153
|
_pane_process_candidates() {
|
|
129
|
-
local names="${ADAPTER_PROCESS_NAMES:-}" id path cmd pid server attached title
|
|
154
|
+
local names="${ADAPTER_PROCESS_NAMES:-}" snapshot="${1:-}" id path cmd pid server attached title
|
|
130
155
|
[ -n "$names" ] || return 1
|
|
131
156
|
[ "$_PANE_SNAPSHOT_OK" = "1" ] || return 1
|
|
132
157
|
while IFS='|' read -r id path cmd pid server attached title; do
|
|
133
158
|
[ -n "$id" ] || continue
|
|
134
159
|
[ "${attached:-0}" -gt 0 ] || continue
|
|
135
|
-
if _pid_tree_has_names "$pid" "$names"; then printf '%s\n' "$id"; fi
|
|
160
|
+
if _pid_tree_has_names "$pid" "$names" "$snapshot"; then printf '%s\n' "$id"; fi
|
|
136
161
|
done <<EOF
|
|
137
162
|
$_PANE_SNAPSHOT
|
|
138
163
|
EOF
|
|
@@ -214,7 +239,10 @@ resolve_tmux_pane() {
|
|
|
214
239
|
|
|
215
240
|
# 1) 祖先链是权威信号(普通启动 / 旧 cc-switch / Claude 的 pane 内 hook)
|
|
216
241
|
_load_pane_snapshot || return 1
|
|
217
|
-
|
|
242
|
+
# 进程快照取一次,ancestry 爬链与进程树候选共用(每事件 1 次 ps 而非 4-6 次)
|
|
243
|
+
local ps_snap
|
|
244
|
+
ps_snap=$(_ps_process_snapshot)
|
|
245
|
+
pane=$(_pane_from_ancestry "$ps_snap")
|
|
218
246
|
if [ -n "$pane" ]; then
|
|
219
247
|
TMUX_PANE="$pane"
|
|
220
248
|
_TMUX_PANE_SOURCE="ancestry"
|
|
@@ -238,7 +266,7 @@ resolve_tmux_pane() {
|
|
|
238
266
|
fi
|
|
239
267
|
|
|
240
268
|
# 3) 同工具进程树候选 → adapter 提示 → hook cwd
|
|
241
|
-
candidates=$(_pane_process_candidates)
|
|
269
|
+
candidates=$(_pane_process_candidates "$ps_snap")
|
|
242
270
|
if [ -n "$candidates" ]; then
|
|
243
271
|
n=$(printf '%s\n' "$candidates" | grep -c .)
|
|
244
272
|
if [ "$n" -gt 1 ]; then
|
|
@@ -456,8 +484,13 @@ _acquire_event_lock() {
|
|
|
456
484
|
[ -n "$TMUX_PANE" ] || return 1
|
|
457
485
|
_ensure_pane_dir
|
|
458
486
|
local lock; lock=$(_event_lock_dir)
|
|
487
|
+
# 重试预算 ~2s。sleep 0.01 不被支持时退化为无 sleep 自旋(mkdir+stat 系统调用,
|
|
488
|
+
# ~几百 ms 耗尽预算)——绝不能回落 sleep 1:200×1s 病态忙等,而 sync hook 的
|
|
489
|
+
# 上游 timeout 是 600s,不会打断它(实测 macOS/Linux sleep 均支持小数,此为
|
|
490
|
+
# 其他平台兜底)。时间基准用 $SECONDS(builtin,零 fork;date +%s 每迭代一次 fork)。
|
|
491
|
+
local _start=$SECONDS
|
|
459
492
|
local i
|
|
460
|
-
for i in
|
|
493
|
+
for i in {1..200}; do
|
|
461
494
|
if mkdir "$lock" 2>/dev/null; then
|
|
462
495
|
echo "$$" > "$lock/owner"
|
|
463
496
|
return 0
|
|
@@ -470,11 +503,11 @@ _acquire_event_lock() {
|
|
|
470
503
|
continue
|
|
471
504
|
fi
|
|
472
505
|
mtime=$(stat -c %Y "$lock" 2>/dev/null || stat -f %m "$lock" 2>/dev/null)
|
|
473
|
-
if [ -n "$mtime" ] && [ $((
|
|
506
|
+
if [ -n "$mtime" ] && [ $(( _start + SECONDS - mtime )) -ge 10 ]; then
|
|
474
507
|
rm -f "$lock/owner" 2>/dev/null
|
|
475
508
|
rmdir "$lock" 2>/dev/null
|
|
476
509
|
fi
|
|
477
|
-
sleep 0.01 2>/dev/null ||
|
|
510
|
+
sleep 0.01 2>/dev/null || :
|
|
478
511
|
done
|
|
479
512
|
_ai_log "EVENT_LOCK: acquire timeout on $lock"
|
|
480
513
|
return 1
|
|
@@ -486,13 +519,34 @@ _release_event_lock() {
|
|
|
486
519
|
rmdir "$(_event_lock_dir)" 2>/dev/null
|
|
487
520
|
}
|
|
488
521
|
|
|
522
|
+
# 事件锁超时时 PermissionRequest 的无锁降级写入(仅 !/? 这类最强状态可无锁写,
|
|
523
|
+
# 调用点见 tmux-ai-status 锁段落的原则注释)。
|
|
524
|
+
# 最小动作集:一次 jq 取 tool_name → AskUserQuestion ? / 其他 ! → set-option →
|
|
525
|
+
# 日志标记 DOWNGRADE。刻意不碰 pending 文件(不加剧持锁者正在进行的文件竞争)、
|
|
526
|
+
# 不跑 _save_pane_id(锁外写 pane 映射有复活已清映射的风险)、不做 build_all_status
|
|
527
|
+
# (monitor 每秒重建聚合,降级值 ≤1s 内可见)。
|
|
528
|
+
_downgrade_permission_request() { # $1=原始 stdin JSON
|
|
529
|
+
local input="$1" tool status
|
|
530
|
+
tool=$(printf '%s' "$input" | jq -r '.tool_name // ""' 2>/dev/null)
|
|
531
|
+
if [ "$tool" = "AskUserQuestion" ]; then
|
|
532
|
+
status="?"
|
|
533
|
+
else
|
|
534
|
+
status="!"
|
|
535
|
+
fi
|
|
536
|
+
tmux set-option -pt "$TMUX_PANE" @ai_pane_status "$status" 2>/dev/null || true
|
|
537
|
+
_pane_loc=""
|
|
538
|
+
_ai_log "PermReq DOWNGRADE: event lock timeout, unlocked write '$status' (tool=${tool:-?})"
|
|
539
|
+
}
|
|
540
|
+
|
|
489
541
|
# Per-pane mkdir mutex。5s stale 强夺。retry 20 × 10ms ≈ 200ms 上限。
|
|
542
|
+
# sleep 回落策略同 _acquire_event_lock(不支持小数 sleep 时无 sleep 自旋,不回落 sleep 1)。
|
|
490
543
|
_acquire_lock() {
|
|
491
544
|
[ -n "$TMUX_PANE" ] || return 1
|
|
492
545
|
_ensure_pane_dir
|
|
493
546
|
local lock; lock=$(_lock_dir)
|
|
547
|
+
local _start=$SECONDS
|
|
494
548
|
local i
|
|
495
|
-
for i in
|
|
549
|
+
for i in {1..20}; do
|
|
496
550
|
if mkdir "$lock" 2>/dev/null; then
|
|
497
551
|
return 0
|
|
498
552
|
fi
|
|
@@ -500,13 +554,13 @@ _acquire_lock() {
|
|
|
500
554
|
local mtime
|
|
501
555
|
mtime=$(stat -c %Y "$lock" 2>/dev/null || stat -f %m "$lock" 2>/dev/null)
|
|
502
556
|
if [ -n "$mtime" ]; then
|
|
503
|
-
local age=$((
|
|
557
|
+
local age=$(( _start + SECONDS - mtime ))
|
|
504
558
|
if [ "$age" -ge 5 ]; then
|
|
505
559
|
rmdir "$lock" 2>/dev/null
|
|
506
560
|
mkdir "$lock" 2>/dev/null && return 0
|
|
507
561
|
fi
|
|
508
562
|
fi
|
|
509
|
-
sleep 0.01 2>/dev/null ||
|
|
563
|
+
sleep 0.01 2>/dev/null || :
|
|
510
564
|
done
|
|
511
565
|
_ai_log "LOCK: acquire timeout on $lock"
|
|
512
566
|
return 1
|
|
@@ -684,31 +738,95 @@ _pane_command_is_tool() { # $1=pane_id $2=工具名
|
|
|
684
738
|
_is_tool_process_name "$(basename "$current_command" 2>/dev/null)" "$2"
|
|
685
739
|
}
|
|
686
740
|
|
|
741
|
+
# 全量进程快照(一次 ps):每行 "pid ppid comm"。供多次进程树 BFS 复用。
|
|
742
|
+
# 旧的逐 pid `ps -o comm=` + `pgrep -P` 实现在 pane 多的 tick 内会放大到秒级
|
|
743
|
+
# (monitor 每 tick 要对每个 launcher pane × 每个工具做一次),改为一次快照 + 内存 BFS。
|
|
744
|
+
_ps_process_snapshot() {
|
|
745
|
+
ps -axo pid=,ppid=,comm= 2>/dev/null
|
|
746
|
+
}
|
|
747
|
+
|
|
687
748
|
# 从给定 pid 出发 BFS,匹配进程名集合(空格分隔,前缀匹配)。
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
local
|
|
749
|
+
# 名称前缀匹配语义同 _is_tool_process_name(codex 匹配 codex-aarch64-*)。
|
|
750
|
+
_pid_tree_has_names() { # $1=pane_pid $2=names [$3=进程快照]
|
|
751
|
+
local pane_pid="$1" names="$2" snapshot="${3:-}"
|
|
691
752
|
[ -n "$pane_pid" ] || return 1
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
753
|
+
[ -n "$snapshot" ] || snapshot=$(_ps_process_snapshot)
|
|
754
|
+
[ -n "$snapshot" ] || return 1
|
|
755
|
+
printf '%s\n' "$snapshot" | awk -v root="$pane_pid" -v names="$names" '
|
|
756
|
+
{ cmd[$1] = $3; if ($2 != "") children[$2] = children[$2] " " $1 }
|
|
757
|
+
END {
|
|
758
|
+
n = split(names, want, " ")
|
|
759
|
+
frontier = root
|
|
760
|
+
depth = 0
|
|
761
|
+
while (frontier != "" && depth < 10) {
|
|
762
|
+
m = split(frontier, pids, " ")
|
|
763
|
+
nxt = ""
|
|
764
|
+
for (i = 1; i <= m; i++) {
|
|
765
|
+
base = cmd[pids[i]]
|
|
766
|
+
sub(/.*\//, "", base)
|
|
767
|
+
for (j = 1; j <= n; j++) {
|
|
768
|
+
nm = want[j]
|
|
769
|
+
if (base == nm \
|
|
770
|
+
|| substr(base, 1, length(nm) + 1) == nm "-" \
|
|
771
|
+
|| substr(base, 1, length(nm) + 1) == nm "_") exit 0
|
|
772
|
+
}
|
|
773
|
+
c = split(children[pids[i]], kids, " ")
|
|
774
|
+
for (k = 1; k <= c; k++) {
|
|
775
|
+
if (kids[k] == "") continue
|
|
776
|
+
nxt = nxt (nxt == "" ? "" : " ") kids[k]
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
frontier = nxt
|
|
780
|
+
depth++
|
|
781
|
+
}
|
|
782
|
+
exit 1
|
|
783
|
+
}'
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
# 批量版:一次快照 + 一次 awk,判定每个 root × 每个 name。
|
|
787
|
+
# 输出命中行 "<pid><TAB><name>";monitor 用它把多个 pane 的存活探测合并成一次
|
|
788
|
+
# awk(逐 pane 调用 _pid_tree_has_names 会重复解析同一份快照)。
|
|
789
|
+
_pid_trees_hits() { # $1=起点 pid 列表 $2=进程名列表 [$3=进程快照]
|
|
790
|
+
local roots="$1" names="$2" snapshot="${3:-}"
|
|
791
|
+
[ -n "$roots" ] && [ -n "$names" ] || return 0
|
|
792
|
+
[ -n "$snapshot" ] || snapshot=$(_ps_process_snapshot)
|
|
793
|
+
[ -n "$snapshot" ] || return 0
|
|
794
|
+
printf '%s\n' "$snapshot" | awk -v roots="$roots" -v names="$names" '
|
|
795
|
+
{ cmd[$1] = $3; if ($2 != "") children[$2] = children[$2] " " $1 }
|
|
796
|
+
END {
|
|
797
|
+
nr = split(roots, r, " ")
|
|
798
|
+
nn = split(names, n, " ")
|
|
799
|
+
for (i = 1; i <= nr; i++)
|
|
800
|
+
for (j = 1; j <= nn; j++)
|
|
801
|
+
if (_tree_has(r[i], n[j])) print r[i] "\t" n[j]
|
|
802
|
+
}
|
|
803
|
+
function _tree_has(root, want, frontier, nxt, depth, m, pids, i, k, base, c, kids) {
|
|
804
|
+
frontier = root
|
|
805
|
+
depth = 0
|
|
806
|
+
while (frontier != "" && depth < 10) {
|
|
807
|
+
m = split(frontier, pids, " ")
|
|
808
|
+
nxt = ""
|
|
809
|
+
for (i = 1; i <= m; i++) {
|
|
810
|
+
base = cmd[pids[i]]
|
|
811
|
+
sub(/.*\//, "", base)
|
|
812
|
+
if (base == want \
|
|
813
|
+
|| substr(base, 1, length(want) + 1) == want "-" \
|
|
814
|
+
|| substr(base, 1, length(want) + 1) == want "_") return 1
|
|
815
|
+
c = split(children[pids[i]], kids, " ")
|
|
816
|
+
for (k = 1; k <= c; k++) {
|
|
817
|
+
if (kids[k] == "") continue
|
|
818
|
+
nxt = nxt (nxt == "" ? "" : " ") kids[k]
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
frontier = nxt
|
|
822
|
+
depth++
|
|
823
|
+
}
|
|
824
|
+
return 0
|
|
825
|
+
}'
|
|
708
826
|
}
|
|
709
827
|
|
|
710
828
|
# BFS pane 进程树,匹配给定进程名集合(空格分隔,前缀匹配)。
|
|
711
|
-
_pane_has_process_names() { # $1=pane_id $2=names
|
|
829
|
+
_pane_has_process_names() { # $1=pane_id $2=names [$3=进程快照]
|
|
712
830
|
local pane_id="$1" pane_pid
|
|
713
831
|
# 注意:list-panes -t "%N" 会返回该 pane 所在 window 的全部 pane,
|
|
714
832
|
# 直接取 #{pane_pid} 会混入同 window 其他 pane 的 pid,导致 BFS 爬到
|
|
@@ -716,23 +834,25 @@ _pane_has_process_names() { # $1=pane_id $2=names
|
|
|
716
834
|
pane_pid=$(tmux list-panes -a -F "#{pane_id} #{pane_pid}" 2>/dev/null \
|
|
717
835
|
| awk -v id="$pane_id" '$1==id{print $2; exit}')
|
|
718
836
|
[ -z "$pane_pid" ] && return 1
|
|
719
|
-
_pid_tree_has_names "$pane_pid" "$2"
|
|
837
|
+
_pid_tree_has_names "$pane_pid" "$2" "${3:-}"
|
|
720
838
|
}
|
|
721
839
|
|
|
722
840
|
# 判定 pane 进程树内是否仍有该工具进程(前台命令优先,再 BFS 遍历进程树)
|
|
723
|
-
_pane_has_tool_process() { # $1=pane_id $2=工具名
|
|
841
|
+
_pane_has_tool_process() { # $1=pane_id $2=工具名 [$3=进程快照]
|
|
724
842
|
_pane_command_is_tool "$1" "$2" && return 0
|
|
725
|
-
_pane_has_process_names "$1" "$2"
|
|
843
|
+
_pane_has_process_names "$1" "$2" "${3:-}"
|
|
726
844
|
}
|
|
727
845
|
|
|
728
846
|
# 所有 adapter 汇总的 AI 进程名
|
|
729
|
-
|
|
730
|
-
|
|
847
|
+
# $2 可选进程快照:调用方批量场景(_cleanup_stale_panes)传入以共用一次 ps
|
|
848
|
+
_pane_has_ai_process() { # $1=pane_id [$2=进程快照]
|
|
849
|
+
_pane_has_process_names "$1" "$(_collect_ai_process_names)" "${2:-}"
|
|
731
850
|
}
|
|
732
851
|
|
|
733
852
|
# 检查 pane 的所有 pane-id 文件是否过期(> 5 min),再验证进程树
|
|
853
|
+
# $2 可选进程快照(批量调用方传入,避免逐 pane 一次 ps)
|
|
734
854
|
_is_pane_session_dead() {
|
|
735
|
-
local pane_id="$1"
|
|
855
|
+
local pane_id="$1" snapshot="${2:-}"
|
|
736
856
|
local sanitized="${pane_id//[^a-zA-Z0-9]/_}"
|
|
737
857
|
local pane_dir="${_STATUS_DIR}/${sanitized}"
|
|
738
858
|
[ -d "$pane_dir" ] || return 0
|
|
@@ -745,7 +865,7 @@ _is_pane_session_dead() {
|
|
|
745
865
|
local age=$(( $(date +%s) - mtime ))
|
|
746
866
|
[ "$age" -lt 300 ] && return 1
|
|
747
867
|
done
|
|
748
|
-
! _pane_has_ai_process "$pane_id"
|
|
868
|
+
! _pane_has_ai_process "$pane_id" "$snapshot"
|
|
749
869
|
}
|
|
750
870
|
|
|
751
871
|
# --- 孤儿状态清理(_refresh 用)---
|
|
@@ -761,6 +881,11 @@ _cleanup_stale_panes() {
|
|
|
761
881
|
[ -n "$pane_id" ] && active_panes="$active_panes $pane_id"
|
|
762
882
|
done
|
|
763
883
|
|
|
884
|
+
# 进程快照取一次循环共用:✓/- 分支与死会话判定逐 pane 调 _pane_has_ai_process
|
|
885
|
+
# 时,旧实现每个 pane 各起一次 ps(pane 多时放大明显)
|
|
886
|
+
local ps_snap
|
|
887
|
+
ps_snap=$(_ps_process_snapshot)
|
|
888
|
+
|
|
764
889
|
while IFS='|' read -r pane_id pane_status; do
|
|
765
890
|
case "$pane_status" in
|
|
766
891
|
"!"|"?"|">")
|
|
@@ -770,14 +895,14 @@ _cleanup_stale_panes() {
|
|
|
770
895
|
_ai_log "STALE: clearing orphaned '$pane_status' on $pane_id"
|
|
771
896
|
tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
|
|
772
897
|
rm -rf "$pane_dir" 2>/dev/null
|
|
773
|
-
elif _is_pane_session_dead "$pane_id"; then
|
|
898
|
+
elif _is_pane_session_dead "$pane_id" "$ps_snap"; then
|
|
774
899
|
_ai_log "STALE: clearing dead session '$pane_status' on $pane_id"
|
|
775
900
|
tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
|
|
776
901
|
rm -rf "$pane_dir" 2>/dev/null
|
|
777
902
|
fi
|
|
778
903
|
;;
|
|
779
904
|
"✓"|"-")
|
|
780
|
-
if ! _pane_has_ai_process "$pane_id"; then
|
|
905
|
+
if ! _pane_has_ai_process "$pane_id" "$ps_snap"; then
|
|
781
906
|
_ai_log "STALE: clearing terminal state '$pane_status' on $pane_id (no ai process)"
|
|
782
907
|
tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
|
|
783
908
|
fi
|
package/scripts/tmux-ai-monitor
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
# SessionStart 还会延迟到首个 turn。本监控器每秒刷新:
|
|
6
6
|
# - 前台启动此类工具、尚无 hook 状态时,显示 idle `-`
|
|
7
7
|
# - cc-switch 的 `switch` 启动时前台命令是 volta-shim/node 而非工具名,改为
|
|
8
|
-
#
|
|
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
|
-
#
|
|
21
|
-
|
|
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
|
|
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
|
-
|
|
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" ] &&
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
#
|
|
92
|
-
#
|
|
93
|
-
|
|
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
|
|
package/scripts/tmux-ai-status
CHANGED
|
@@ -28,8 +28,22 @@ case "$EVENT" in
|
|
|
28
28
|
esac
|
|
29
29
|
# 同 pane 的并发 hooks 必须完整串行化,否则一个事件可能用过期派生结果覆盖另一个
|
|
30
30
|
# PermissionRequest 已写入的审批态。
|
|
31
|
+
#
|
|
32
|
+
# 锁超时(~2s 重试预算耗尽)时的降级原则——只有最强状态可无锁写:
|
|
33
|
+
# PermissionRequest 是 sync hook 且携带最高优先级审批态(!/?),丢失 = 状态栏
|
|
34
|
+
# 在用户审批前失明,因此降级为无锁最小写入(_downgrade_permission_request)。
|
|
35
|
+
# 其他事件(Pre/Post/Stop…)超时一律 exit 0 丢弃——无锁写弱状态(如 >)会
|
|
36
|
+
# 覆盖并发 PermReq 已早写的 !,正是 7448847 修掉的竞态,绝不能对它们降级。
|
|
37
|
+
# 降级最有价值的场景是「持锁者已死但未到 10s stale 强夺线」(此时无并发写入者,
|
|
38
|
+
# 降级写存活);反方向竞态(持锁者稍后的最终写入覆盖降级的 !)可接受——该
|
|
39
|
+
# 场景现状(超时丢弃)同样不显示 !,降级只多一瞬正确闪烁,终态不劣化。
|
|
40
|
+
# 恢复时间线:stale 强夺线(10s) > 重试预算(~2s),跨过 10s 线后的第一个事件
|
|
41
|
+
# 会强夺锁恢复正常路径。
|
|
31
42
|
if [ -n "$TMUX_PANE" ]; then
|
|
32
|
-
|
|
43
|
+
if ! _acquire_event_lock; then
|
|
44
|
+
[ "$EVENT" = "PermissionRequest" ] && _downgrade_permission_request "$INPUT"
|
|
45
|
+
exit 0
|
|
46
|
+
fi
|
|
33
47
|
trap '_release_event_lock' EXIT
|
|
34
48
|
fi
|
|
35
49
|
|
|
@@ -67,7 +81,10 @@ _current_pane_status() {
|
|
|
67
81
|
# 重建聚合状态(_refresh / _entered 共用)
|
|
68
82
|
_run_refresh() {
|
|
69
83
|
_maybe_repair_hooks
|
|
70
|
-
|
|
84
|
+
# 节流版清理(60s):tmux 生命周期 hook(pane-focus-in 等)高频触发,
|
|
85
|
+
# 全价 _cleanup_stale_panes 实测 ~455ms;卡死状态清理有 :37 的事件路径
|
|
86
|
+
# 节流兜底,此处无需每次全价。Stop/SessionEnd fallback 仍走全价精准清理。
|
|
87
|
+
_maybe_cleanup_stale
|
|
71
88
|
build_all_status
|
|
72
89
|
tmux set-option -g @ai_all_status "$ALL" 2>/dev/null || true
|
|
73
90
|
}
|
|
@@ -103,10 +120,12 @@ case "$EVENT" in
|
|
|
103
120
|
esac
|
|
104
121
|
;;
|
|
105
122
|
PermissionRequest)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
# 一次 @tsv 提取 tool_name/tool_use_id(digest 单独一次;stdin 只能读一次,
|
|
124
|
+
# 故从已缓存的 $INPUT 提取)。digest 只取 tool_name/tool_input——全量重序列化
|
|
125
|
+
# 会把 transcript_path 等无关字段落日志,且大 tool_input 全量展开拖慢 sync 路径。
|
|
126
|
+
_tool_fields=$(printf '%s' "$INPUT" | jq -r '[(.tool_name // ""), (.tool_use_id // "")] | @tsv' 2>/dev/null)
|
|
127
|
+
IFS=$'\t' read -r TOOL TOOL_USE_ID <<< "$_tool_fields"
|
|
128
|
+
INPUT_DIGEST=$(printf '%s' "$INPUT" | jq -c '{tool_name, tool_input}' 2>/dev/null)
|
|
110
129
|
if [ "$TOOL" = "AskUserQuestion" ]; then
|
|
111
130
|
STATUS="?"
|
|
112
131
|
else
|
|
@@ -135,9 +154,9 @@ case "$EVENT" in
|
|
|
135
154
|
fi
|
|
136
155
|
;;
|
|
137
156
|
PreToolUse)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
INPUT_DIGEST=$(
|
|
157
|
+
_tool_fields=$(printf '%s' "$INPUT" | jq -r '[(.tool_name // ""), (.tool_use_id // "")] | @tsv' 2>/dev/null)
|
|
158
|
+
IFS=$'\t' read -r TOOL TOOL_USE_ID <<< "$_tool_fields"
|
|
159
|
+
INPUT_DIGEST=$(printf '%s' "$INPUT" | jq -c '{tool_name, tool_input}' 2>/dev/null)
|
|
141
160
|
_add_pending_pre "$TOOL_USE_ID" "$TOOL"
|
|
142
161
|
_permission_pre_tool=0
|
|
143
162
|
for _permission_tool in ${ADAPTER_PERMISSION_PRE_TOOL_NAMES:-}; do
|
|
@@ -163,8 +182,8 @@ case "$EVENT" in
|
|
|
163
182
|
fi
|
|
164
183
|
;;
|
|
165
184
|
PostToolUse|PostToolUseFailure)
|
|
166
|
-
|
|
167
|
-
|
|
185
|
+
_tool_fields=$(printf '%s' "$INPUT" | jq -r '[(.tool_name // ""), (.tool_use_id // "")] | @tsv' 2>/dev/null)
|
|
186
|
+
IFS=$'\t' read -r TOOL TOOL_USE_ID <<< "$_tool_fields"
|
|
168
187
|
_remove_pending_id "$TOOL_USE_ID"
|
|
169
188
|
# 只解除当前工具对应项;其他权限请求仍 pending 时必须保持 !/?。
|
|
170
189
|
if _derive_status_from_pending; then
|
|
@@ -260,11 +279,6 @@ case "$EVENT" in
|
|
|
260
279
|
;;
|
|
261
280
|
esac
|
|
262
281
|
|
|
263
|
-
# pane 解析失败时不写全局状态(避免把无归属事件当成有效状态)
|
|
264
|
-
if [ -n "$TMUX_PANE" ]; then
|
|
265
|
-
tmux set-option -g @ai_status "$STATUS" 2>/dev/null || true
|
|
266
|
-
fi
|
|
267
|
-
|
|
268
282
|
# 构造事件块详情行:
|
|
269
283
|
# tool: <tool_name> id=<short tool_use_id>
|
|
270
284
|
# input: <精简 JSON,超 2000B 截断>
|
|
@@ -274,8 +288,10 @@ if [ -n "$TOOL" ] || [ -n "$TOOL_USE_ID" ]; then
|
|
|
274
288
|
fi
|
|
275
289
|
_log_input_line=""
|
|
276
290
|
if [ -n "$INPUT_DIGEST" ] && [ "$INPUT_DIGEST" != "null" ]; then
|
|
277
|
-
|
|
278
|
-
|
|
291
|
+
# PermissionRequest 曾走完全不截断的 full 路径(9248967),实测大 tool_input
|
|
292
|
+
# (ExitPlanMode 的 plan、AUQ 的 questions)可达数 KB~数十 KB,改为 16KB 宽松上限。
|
|
293
|
+
if [ "$EVENT" = "PermissionRequest" ]; then
|
|
294
|
+
_log_input_line="input: $(_ai_log_truncate_input "$INPUT_DIGEST" 16384)"
|
|
279
295
|
else
|
|
280
296
|
_log_input_line="input: $(_ai_log_truncate_input "$INPUT_DIGEST")"
|
|
281
297
|
fi
|