tmuxclihook 0.5.8 → 0.5.10

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.8",
3
+ "version": "0.5.10",
4
4
  "description": "交互式 TUI 安装器:环境侦测、AI CLI 侦测、tmux-ai-status hooks 装/卸/修、TPM 软链校验",
5
5
  "type": "module",
6
6
  "bin": {
@@ -255,6 +255,51 @@ adapter_check_integrity() {
255
255
  return 0
256
256
  }
257
257
 
258
+ # 已收敛判定(仅供 wrapper 的 auto 分支使用——⚠️ 不得被事件路径复用)。
259
+ # 四条件全过 = auto 可安全跳过重装:integrity 过 + 全部条目指向当前源(无漂移/
260
+ # 死条目)+ 每事件本插件条目 ≤1(无重复)。
261
+ # 参照系:wrapper 的 _LIB_DIR 是 pwd -P 物理路径;写入(install/auto/修复全部经
262
+ # wrapper)与比较同参照系自洽。事件路径的 _LIB_DIR 是 pwd 逻辑路径(软链场景
263
+ # 两者不等),事件路径复用会误判漂移。
264
+ # 取源逻辑与 adapter_check_integrity 的三模式 case 保持同步(不抽共享以控制
265
+ # integrity 的回归面)。
266
+ adapter_check_converged() {
267
+ adapter_check_integrity || return 1
268
+ local mode src="" tmp="" stats
269
+ mode=$(_ccswitch_mode)
270
+ case "$mode" in
271
+ live)
272
+ src=$(_ccswitch_live_file)
273
+ [ -f "$src" ] || return 1
274
+ ;;
275
+ legacy)
276
+ _ccswitch_dump_common tmp || return 1
277
+ src="$tmp"
278
+ ;;
279
+ *)
280
+ [ -f "$ADAPTER_HOOKS_FILE" ] || return 1
281
+ src="$ADAPTER_HOOKS_FILE"
282
+ ;;
283
+ esac
284
+ # 一次 jq 同时产出漂移计数(≠ 当前源,含死路径)与重复计数(每事件条目 >1)
285
+ stats=$(jq -r --arg cur "${_LIB_DIR}/tmux-ai-status" '
286
+ .hooks as $h |
287
+ ["SessionStart","SessionEnd","UserPromptSubmit","PreToolUse","PostToolUse",
288
+ "PostToolUseFailure","PermissionRequest","Notification","Stop","StopFailure"] as $events |
289
+ [$events[] as $e |
290
+ [$h[$e][]?.hooks[]?.command // empty
291
+ | select(contains("tmux-ai-status")) | split(" ")[0]] as $scripts |
292
+ select(($scripts | length) > 0) |
293
+ {bad: ([$scripts[] | select(. != $cur)] | length),
294
+ n: ($scripts | length)}]
295
+ | "bad=\([.[].bad] | add // 0) dup=\([.[].n | select(. > 1)] | length)"' "$src" 2>/dev/null)
296
+ [ -z "$tmp" ] || rm -f "$tmp"
297
+ case "$stats" in
298
+ "bad=0 dup=0") return 0 ;;
299
+ *) return 1 ;;
300
+ esac
301
+ }
302
+
258
303
  # 明细检查(仅 install wrapper 的 check --json 调用;事件热路径不触达)。
259
304
  # stdout 输出单行紧凑 JSON 片段 {"mode":...,"events":{...},"plugin":null,"registeredPaths":[...]},
260
305
  # 由 lib-install-hooks.sh 的 _wrapper_check_json 拼装 tool/status/suppress/drift。
@@ -342,6 +387,16 @@ adapter_install_hooks() {
342
387
  _ccswitch_warn_gui
343
388
  _ccswitch_dump_common tmp || return 1
344
389
  merged=$(_claude_apply_merge "$tmp")
390
+ # 语义不变跳写:merge 只动 .hooks,顶层其他字段天然继承 tmp——
391
+ # 两边 jq -S 规范化对比,相同则跳过 upsert(GUI 冲掉 hooks 时
392
+ # 库内现值 ≠ merged,恒不跳,对抗不弱化;行缺失时 tmp={},
393
+ # merged 恒含 10 事件,同样恒不跳)
394
+ if [ "$(jq -S . "$tmp" 2>/dev/null)" = "$(jq -S . <<<"$merged" 2>/dev/null)" ]; then
395
+ rm -f "$tmp"
396
+ echo "Claude hooks already up to date in cc-switch common config (skip write)"
397
+ echo "Events: $_CLAUDE_EVENTS"
398
+ return 0
399
+ fi
345
400
  rm -f "$tmp"
346
401
  hooks_patch=$(jq -c '{hooks: (.hooks // {})}' <<<"$merged")
347
402
  full_doc=$(jq -c . <<<"$merged")
@@ -360,6 +415,14 @@ adapter_install_hooks() {
360
415
  esac
361
416
  [ -f "$target" ] || _install_atomic_write '{}' "$target"
362
417
  updated=$(_claude_apply_merge "$target")
418
+ # 语义不变跳写:jq 输出格式与手写文件不同,字节 cmp 不可用——两边 jq -S
419
+ # 规范化后比较(命令替换对称剥尾换行)。跳写不触碰 mtime,避免触发 Claude
420
+ # Code file watcher 的无谓 hooks 重载。
421
+ if [ "$(jq -S . "$target" 2>/dev/null)" = "$(jq -S . <<<"$updated" 2>/dev/null)" ]; then
422
+ echo "Claude hooks already up to date at $target (skip write)"
423
+ echo "Events: $_CLAUDE_EVENTS"
424
+ return 0
425
+ fi
363
426
  _install_atomic_write "$updated" "$target"
364
427
  echo "Claude hooks installed to $target"
365
428
  echo "Events: $_CLAUDE_EVENTS"
@@ -412,6 +475,11 @@ adapter_uninstall_hooks() {
412
475
  esac
413
476
  [ -f "$target" ] || { echo "Claude hooks: nothing to uninstall"; return 0; }
414
477
  updated=$(_claude_apply_strip "$target")
478
+ # 无本插件条目时 strip 无变化 → 跳写(不触碰 mtime)
479
+ if [ "$(jq -S . "$target" 2>/dev/null)" = "$(jq -S . <<<"$updated" 2>/dev/null)" ]; then
480
+ echo "Claude hooks: nothing to uninstall from $target (skip write)"
481
+ return 0
482
+ fi
415
483
  _install_atomic_write "$updated" "$target"
416
484
  echo "Claude hooks uninstalled from $target"
417
485
  }
@@ -18,6 +18,21 @@ _LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
18
18
  source "${_LIB_DIR}/lib-install-hooks.sh"
19
19
  source "${_LIB_DIR}/adapters/claude.sh"
20
20
 
21
+ # 写路径互斥(check 只读不锁):临界区覆盖 adapter 调用 + suppress 标记读-改-写,
22
+ # 串行化 auto 与显式 uninstall 的标记 TOCTOU。失败语义见 _install_acquire_lock:
23
+ # 降级无锁(rc=2)继续;等待超时(rc=1)退出。
24
+ case "${1:-install}" in
25
+ check) ;;
26
+ *)
27
+ _lock_rc=0
28
+ _install_acquire_lock claude || _lock_rc=$?
29
+ if [ "$_lock_rc" = 1 ]; then exit 1; fi
30
+ if [ "$_lock_rc" = 0 ]; then
31
+ trap '_install_release_lock claude' EXIT
32
+ fi
33
+ ;;
34
+ esac
35
+
21
36
  case "${1:-install}" in
22
37
  uninstall)
23
38
  adapter_uninstall_hooks
@@ -37,11 +52,19 @@ case "${1:-install}" in
37
52
  if [ -f "$(_hooks_suppress_file claude)" ]; then
38
53
  exit 0
39
54
  fi
55
+ # 已收敛(integrity + 无漂移/死条目/重复)→ 跳过重装:tmux 每次 reload
56
+ # 不再全量重写真源。强收敛保留——漂移/死条目/缺失/重复任一存在都走
57
+ # install 收敛。结构漂移(async/matcher 被外部改)不在判定内,auto 跳过
58
+ # 不修复,显式 install 仍强收敛。
59
+ if adapter_check_converged; then
60
+ echo "claude hooks already converged (skip install)"
61
+ exit 0
62
+ fi
40
63
  adapter_install_hooks
41
- _hooks_suppress_clear claude
64
+ _hooks_suppress_clear claude || true
42
65
  ;;
43
66
  *)
44
67
  adapter_install_hooks
45
- _hooks_suppress_clear claude
68
+ _hooks_suppress_clear claude || true
46
69
  ;;
47
70
  esac
@@ -15,6 +15,19 @@ _LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
15
15
  source "${_LIB_DIR}/lib-install-hooks.sh"
16
16
  source "${_LIB_DIR}/adapters/codex.sh"
17
17
 
18
+ # 写路径互斥(check 只读不锁):见 _install_acquire_lock 头注释。
19
+ case "${1:-install}" in
20
+ check) ;;
21
+ *)
22
+ _lock_rc=0
23
+ _install_acquire_lock codex || _lock_rc=$?
24
+ if [ "$_lock_rc" = 1 ]; then exit 1; fi
25
+ if [ "$_lock_rc" = 0 ]; then
26
+ trap '_install_release_lock codex' EXIT
27
+ fi
28
+ ;;
29
+ esac
30
+
18
31
  case "${1:-install}" in
19
32
  uninstall)
20
33
  adapter_uninstall_hooks
@@ -32,6 +45,6 @@ case "${1:-install}" in
32
45
  ;;
33
46
  *)
34
47
  adapter_install_hooks
35
- _hooks_suppress_clear codex
48
+ _hooks_suppress_clear codex || true
36
49
  ;;
37
50
  esac
@@ -15,6 +15,19 @@ _LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
15
15
  source "${_LIB_DIR}/lib-install-hooks.sh"
16
16
  source "${_LIB_DIR}/adapters/opencode.sh"
17
17
 
18
+ # 写路径互斥(check 只读不锁):见 _install_acquire_lock 头注释。
19
+ case "${1:-install}" in
20
+ check) ;;
21
+ *)
22
+ _lock_rc=0
23
+ _install_acquire_lock opencode || _lock_rc=$?
24
+ if [ "$_lock_rc" = 1 ]; then exit 1; fi
25
+ if [ "$_lock_rc" = 0 ]; then
26
+ trap '_install_release_lock opencode' EXIT
27
+ fi
28
+ ;;
29
+ esac
30
+
18
31
  case "${1:-install}" in
19
32
  uninstall)
20
33
  adapter_uninstall_hooks
@@ -32,6 +45,6 @@ case "${1:-install}" in
32
45
  ;;
33
46
  *)
34
47
  adapter_install_hooks
35
- _hooks_suppress_clear opencode
48
+ _hooks_suppress_clear opencode || true
36
49
  ;;
37
50
  esac
@@ -29,14 +29,85 @@ _install_atomic_write() {
29
29
  # 显式 uninstall 成功 → 写 suppressed-<tool>;显式 install 成功 → 清。
30
30
  # 自动修复路径(_maybe_repair_hooks / 入口 auto / SessionStart 重装)见标记一律不装。
31
31
  # ⚠️ _SUPPRESS_DIR 与 lib-tmux-ai-status.sh 中的定义保持一致(两处同步改)。
32
+ #
33
+ # 失败语义(两个方向都告警,不对称):
34
+ # - write 失败:hooks 已剥离但标记没落 → auto 会静默装回(用户显式卸载被反转)
35
+ # → return 1,wrapper uninstall 分支 errexit 使其以非 0 退出,用户可见。
36
+ # - clear 失败:install 已成功但残留标记会把 auto 永久挡住(比 write 失败更隐蔽)
37
+ # → 告警但 return 0(主操作已成功,调用方 wrapper 的 clear 调用需 || true 容忍)。
32
38
  _SUPPRESS_DIR="${TMUX_AI_SUPPRESS_DIR:-${XDG_CONFIG_HOME:-${HOME}/.config}/tmuxclihook}"
33
39
 
34
40
  _hooks_suppress_file() { printf '%s/suppressed-%s' "$_SUPPRESS_DIR" "$1"; }
35
41
  _hooks_suppress_write() {
36
- mkdir -p "$_SUPPRESS_DIR" 2>/dev/null || true
37
- date +%s > "$(_hooks_suppress_file "$1")" 2>/dev/null || true
42
+ if ! mkdir -p "$_SUPPRESS_DIR" 2>/dev/null \
43
+ || ! date +%s > "$(_hooks_suppress_file "$1")" 2>/dev/null; then
44
+ echo "WARN: 抑制标记写入失败——hooks 已卸载,但 tmux 重载/新会话可能自动装回;" \
45
+ "请检查 $_SUPPRESS_DIR 可写性" >&2
46
+ return 1
47
+ fi
48
+ }
49
+ _hooks_suppress_clear() {
50
+ if [ -e "$(_hooks_suppress_file "$1")" ] \
51
+ && ! rm -f "$(_hooks_suppress_file "$1")" 2>/dev/null; then
52
+ echo "WARN: 卸载意图标记清除失败——tmux 自动安装将持续跳过;" \
53
+ "请检查 $_SUPPRESS_DIR 可写性" >&2
54
+ return 1
55
+ fi
56
+ }
57
+
58
+ # --- 安装/卸载读-改-写互斥锁 ---
59
+ # 写路径的并发方:TUI 显式装 / tmux 入口 auto / SessionStart 自修复 / _maybe_repair_hooks /
60
+ # drift 收敛——五方都经 wrapper dispatch,锁在此层一处覆盖三 adapter。
61
+ # mkdir mutex(项目风格同 .lock/.event-lock;macOS 无 flock(1))。两种失败语义区分:
62
+ # - 锁目录建不出来(权限/ENOTDIR)→ 降级无锁继续 + 告警(历史行为是无锁写;install
63
+ # 真正写目标与锁目录不同权限域,锁失败不代表写不了)
64
+ # - 他人持锁 → 等待(0.2s × 50 ≈ 10s)→ stale(10s)强夺 → 超时报错 return 1(显式操作可见)。
65
+ # ⚠️ codex SessionStart 是同步 hook(timeout:5s):极端并发下锁等待可能超过 5s 使该次
66
+ # hook 超时——后果轻(该次 hook 不执行、codex 继续、无阻塞),概率极低(repair 60s 一次)。
67
+ # 边界:锁只收敛「我们自己的多方」;Claude Code 自身写 settings.json 不遵守本锁(外部
68
+ # 写者无法防)——这正是 60s 自修复存在的原因。写路径互斥由 wrapper 层负责,adapter
69
+ # 函数不含锁——直接 source adapter 调 install 会绕过锁。
70
+ _install_lock_dir() { printf '%s/.install-lock-%s' "$_SUPPRESS_DIR" "$1"; }
71
+ _install_acquire_lock() { # $1=tool id → 0=持锁 1=超时(2=降级无锁,调用方继续)
72
+ local lock; lock=$(_install_lock_dir "$1")
73
+ if ! mkdir -p "$_SUPPRESS_DIR" 2>/dev/null; then
74
+ echo "WARN: 锁目录不可创建($_SUPPRESS_DIR),降级无锁继续安装" >&2
75
+ return 2
76
+ fi
77
+ local _start=$SECONDS i
78
+ for i in {1..50}; do
79
+ if mkdir "$lock" 2>/dev/null; then
80
+ echo "$$" > "$lock/owner"
81
+ return 0
82
+ fi
83
+ # stale 强夺:持锁者进程已死 → 立即夺;锁 mtime 超 10s → 强夺。
84
+ # 外部命令(stat/sleep/rmdir)逐一防御退出码——ccswitch 用例 8 的 stubbin
85
+ # 是最小 PATH 沙箱(无 stat/rmdir/sleep),127 会在 errexit 下炸掉 install。
86
+ local owner mtime
87
+ owner=$(cat "$lock/owner" 2>/dev/null)
88
+ if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then
89
+ rm -f "$lock/owner" 2>/dev/null || true
90
+ rmdir "$lock" 2>/dev/null || true
91
+ continue
92
+ fi
93
+ mtime=""
94
+ mtime=$(stat -c %Y "$lock" 2>/dev/null || stat -f %m "$lock" 2>/dev/null || true)
95
+ if [ -n "$mtime" ] && [ $(( _start + SECONDS - mtime )) -ge 10 ]; then
96
+ echo "WARN: 强夺超时的安装锁(owner=${owner:-?})" >&2
97
+ rm -f "$lock/owner" 2>/dev/null || true
98
+ rmdir "$lock" 2>/dev/null || true
99
+ continue
100
+ fi
101
+ sleep 0.2 2>/dev/null || :
102
+ done
103
+ echo "ERROR: 安装锁获取超时($lock)——另一安装操作可能仍在进行,稍后重试" >&2
104
+ return 1
105
+ }
106
+ _install_release_lock() { # $1=tool id
107
+ local lock; lock=$(_install_lock_dir "$1")
108
+ rm -f "$lock/owner" 2>/dev/null || true
109
+ rmdir "$lock" 2>/dev/null || true
38
110
  }
39
- _hooks_suppress_clear() { rm -f "$(_hooks_suppress_file "$1")" 2>/dev/null || true; }
40
111
 
41
112
  # --- check --json:完整性明细 JSON(wrapper 专用,事件热路径不触达)---
42
113
  # stdout = 单行紧凑 JSON;exit code 与默认 check 一致(ok→0 / missing→1)。
@@ -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 超长截断:>2000B 保留前 2000B 加 "…+Nbytes"
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 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
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
- pane=$(_pane_from_ancestry)
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 $(seq 1 200); do
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" ] && [ $(( $(date +%s) - mtime )) -ge 10 ]; then
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 || sleep 1
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 $(seq 1 20); do
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=$(( $(date +%s) - mtime ))
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 || sleep 1
563
+ sleep 0.01 2>/dev/null || :
510
564
  done
511
565
  _ai_log "LOCK: acquire timeout on $lock"
512
566
  return 1
@@ -790,13 +844,15 @@ _pane_has_tool_process() { # $1=pane_id $2=工具名 [$3=进程快照]
790
844
  }
791
845
 
792
846
  # 所有 adapter 汇总的 AI 进程名
793
- _pane_has_ai_process() { # $1=pane_id
794
- _pane_has_process_names "$1" "$(_collect_ai_process_names)"
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:-}"
795
850
  }
796
851
 
797
852
  # 检查 pane 的所有 pane-id 文件是否过期(> 5 min),再验证进程树
853
+ # $2 可选进程快照(批量调用方传入,避免逐 pane 一次 ps)
798
854
  _is_pane_session_dead() {
799
- local pane_id="$1"
855
+ local pane_id="$1" snapshot="${2:-}"
800
856
  local sanitized="${pane_id//[^a-zA-Z0-9]/_}"
801
857
  local pane_dir="${_STATUS_DIR}/${sanitized}"
802
858
  [ -d "$pane_dir" ] || return 0
@@ -809,7 +865,7 @@ _is_pane_session_dead() {
809
865
  local age=$(( $(date +%s) - mtime ))
810
866
  [ "$age" -lt 300 ] && return 1
811
867
  done
812
- ! _pane_has_ai_process "$pane_id"
868
+ ! _pane_has_ai_process "$pane_id" "$snapshot"
813
869
  }
814
870
 
815
871
  # --- 孤儿状态清理(_refresh 用)---
@@ -825,6 +881,11 @@ _cleanup_stale_panes() {
825
881
  [ -n "$pane_id" ] && active_panes="$active_panes $pane_id"
826
882
  done
827
883
 
884
+ # 进程快照取一次循环共用:✓/- 分支与死会话判定逐 pane 调 _pane_has_ai_process
885
+ # 时,旧实现每个 pane 各起一次 ps(pane 多时放大明显)
886
+ local ps_snap
887
+ ps_snap=$(_ps_process_snapshot)
888
+
828
889
  while IFS='|' read -r pane_id pane_status; do
829
890
  case "$pane_status" in
830
891
  "!"|"?"|">")
@@ -834,14 +895,14 @@ _cleanup_stale_panes() {
834
895
  _ai_log "STALE: clearing orphaned '$pane_status' on $pane_id"
835
896
  tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
836
897
  rm -rf "$pane_dir" 2>/dev/null
837
- elif _is_pane_session_dead "$pane_id"; then
898
+ elif _is_pane_session_dead "$pane_id" "$ps_snap"; then
838
899
  _ai_log "STALE: clearing dead session '$pane_status' on $pane_id"
839
900
  tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
840
901
  rm -rf "$pane_dir" 2>/dev/null
841
902
  fi
842
903
  ;;
843
904
  "✓"|"-")
844
- if ! _pane_has_ai_process "$pane_id"; then
905
+ if ! _pane_has_ai_process "$pane_id" "$ps_snap"; then
845
906
  _ai_log "STALE: clearing terminal state '$pane_status' on $pane_id (no ai process)"
846
907
  tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
847
908
  fi
@@ -901,12 +962,14 @@ _check_hooks_integrity() {
901
962
  fi
902
963
  }
903
964
 
904
- # 兜底自修复:tmux 端事件路径调用,配置被外部覆盖时自动重装。
905
- # 不依赖 SESSION_ID/EVENT,可被 _refresh 触发。60s 节流。
965
+ # 兜底自修复:tmux 端事件路径与 SessionStart hook 共用,配置被外部覆盖时自动重装。
966
+ # 不依赖 SESSION_ID/EVENT,可被 _refresh 与 SessionStart 触发。60s 节流退避
967
+ # (窗口内不重复 integrity 检查;修复失败 60s 后才重试)。
906
968
  # 安装器由 adapter 声明(ADAPTER_INSTALLER)。
907
969
  _maybe_repair_hooks() {
908
970
  # 用户显式卸载意图(suppressed-<tool>)→ 不自动修复,尊重到显式安装为止。
909
971
  if [ -f "${_SUPPRESS_DIR}/suppressed-${TOOL_ID:-claude}" ]; then
972
+ _ai_log "REPAIR: suppressed by uninstall marker, skip (${TOOL_ID:-claude})"
910
973
  return 0
911
974
  fi
912
975
  local marker="${_STATUS_DIR}/.hooks-repair-${TOOL_ID:-claude}-ts"
@@ -922,9 +985,9 @@ _maybe_repair_hooks() {
922
985
  local installer="${ADAPTER_INSTALLER}"
923
986
  if ! _check_hooks_integrity; then
924
987
  _ai_log "REPAIR: hooks integrity check failed, reinstalling (${TOOL_ID:-claude})"
925
- # 浮出提示与 SessionStart 修复路径(tmux-ai-status)对齐:修复不再完全静默。
926
- # 本函数仅经 _run_refresh 被 tmux hook 事件触发(run-shell 上下文,不在 AI CLI
927
- # 同步 hook 路径);display-message 无 -p 写 client tty 而非调用方 stdout。
988
+ # 浮出提示(成败区分):SessionStart(Claude Code hook 进程内)与
989
+ # _run_refresh(tmux run-shell)两个上下文都会到这里;hook 进程继承
990
+ # TMUX env,display-message 无 -p 写 client tty 而非调用方 stdout。
928
991
  if [ -x "$installer" ] && "$installer" >/dev/null 2>&1; then
929
992
  tmux display-message "✓ ${TOOL_ID:-claude} hooks 已自动修复(检测到缺失)" 2>/dev/null || true
930
993
  else
@@ -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
- _acquire_event_lock || exit 0
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
- _cleanup_stale_panes
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
- TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
107
- TOOL_USE_ID=$(echo "$INPUT" | jq -r '.tool_use_id // ""' 2>/dev/null)
108
- INPUT_DIGEST=$(echo "$INPUT" | jq -c '.' 2>/dev/null)
109
- _log_full_input=1
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
- TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
139
- TOOL_USE_ID=$(echo "$INPUT" | jq -r '.tool_use_id // ""' 2>/dev/null)
140
- INPUT_DIGEST=$(echo "$INPUT" | jq -c '.tool_input // null' 2>/dev/null)
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
- TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
167
- TOOL_USE_ID=$(echo "$INPUT" | jq -r '.tool_use_id // ""' 2>/dev/null)
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
@@ -202,14 +221,12 @@ case "$EVENT" in
202
221
  INPUT_DIGEST=$(echo "$INPUT" | jq -c '{source, model}' 2>/dev/null)
203
222
  STATUS="-"
204
223
  _clear_pending
205
- if ! _check_hooks_integrity; then
206
- if [ -f "${_SUPPRESS_DIR}/suppressed-${TOOL_ID}" ]; then
207
- _ai_log "REPAIR: suppressed by uninstall marker, skip (${TOOL_ID})"
208
- else
209
- "${ADAPTER_INSTALLER}" >/dev/null 2>&1
210
- tmux display-message "⚠ ${TOOL_ID} hooks 已自动修复(检测到缺失)" 2>/dev/null || true
211
- fi
212
- fi
224
+ # 自修复归并到 _maybe_repair_hooks:60s 节流退避(旧实现每次 SessionStart——
225
+ # 含 resume/clear/fork——都全量 integrity + 失败即重装且无 exit code 检查),
226
+ # 并统一 suppress 检查与成功/失败区分的 display-message。节流语义:窗口内
227
+ # 新会话不重复检查 integrity(外部改 settings 后最多延迟 60s 才被发现);
228
+ # 修复失败 60s 后由 _run_refresh 或下一次 SessionStart 重试。
229
+ _maybe_repair_hooks
213
230
  ;;
214
231
  SessionEnd)
215
232
  INPUT_DIGEST=$(echo "$INPUT" | jq -c '{reason}' 2>/dev/null)
@@ -260,11 +277,6 @@ case "$EVENT" in
260
277
  ;;
261
278
  esac
262
279
 
263
- # pane 解析失败时不写全局状态(避免把无归属事件当成有效状态)
264
- if [ -n "$TMUX_PANE" ]; then
265
- tmux set-option -g @ai_status "$STATUS" 2>/dev/null || true
266
- fi
267
-
268
280
  # 构造事件块详情行:
269
281
  # tool: <tool_name> id=<short tool_use_id>
270
282
  # input: <精简 JSON,超 2000B 截断>
@@ -274,8 +286,10 @@ if [ -n "$TOOL" ] || [ -n "$TOOL_USE_ID" ]; then
274
286
  fi
275
287
  _log_input_line=""
276
288
  if [ -n "$INPUT_DIGEST" ] && [ "$INPUT_DIGEST" != "null" ]; then
277
- if [ "${_log_full_input:-0}" = "1" ]; then
278
- _log_input_line="input: $INPUT_DIGEST"
289
+ # PermissionRequest 曾走完全不截断的 full 路径(9248967),实测大 tool_input
290
+ # (ExitPlanMode 的 plan、AUQ 的 questions)可达数 KB~数十 KB,改为 16KB 宽松上限。
291
+ if [ "$EVENT" = "PermissionRequest" ]; then
292
+ _log_input_line="input: $(_ai_log_truncate_input "$INPUT_DIGEST" 16384)"
279
293
  else
280
294
  _log_input_line="input: $(_ai_log_truncate_input "$INPUT_DIGEST")"
281
295
  fi