tmuxclihook 0.5.16 → 0.5.17

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/bin/cli.js CHANGED
@@ -14,7 +14,7 @@ import { checkEnv, detectClis, interactiveShell } from '../src/detect.js';
14
14
  import { planBrewRemediation, runBrewAction } from '../src/env.js';
15
15
  import { installHooks, uninstallHooks, repairHooks } from '../src/hooks.js';
16
16
  import { checkSymlink, checkTmuxConf } from '../src/symlink.js';
17
- import { checkToolDetail, renderToolDetail, summarizeDetails } from '../src/check-detail.js';
17
+ import { checkToolDetail, renderToolDetail, summarizeDetails, findDuplicateEvents } from '../src/check-detail.js';
18
18
  import { ensureTmuxIntegration, reloadTmux } from '../src/tmuxconf.js';
19
19
  import { stopMonitor, clearAggregateStatus, removeSymlinks } from '../src/purge.js';
20
20
  import {
@@ -165,26 +165,41 @@ async function detectCliMenu() {
165
165
  await offerDriftConvergence(details);
166
166
  }
167
167
 
168
- // 漂移收敛入口:检测(check --json 的 drift 字段 + .tmux.conf 的 pointsToCurrent)
169
- // 与收敛分离。收敛策略不对称——claude 无代价直接收敛;codex 重写即 trusted_hash
170
- // 失效,必须显式确认;opencode 重写无信任代价,确认一次即可。
168
+ // 漂移/重复收敛入口:检测(check --json 的 drift 字段 + .tmux.conf 的 pointsToCurrent
169
+ // + codex 的 registered>1)与收敛分离。收敛策略不对称——claude 无代价直接收敛;
170
+ // codex 重写即 trusted_hash 失效,必须显式确认;opencode 重写无信任代价,确认一次即可。
171
171
  async function offerDriftConvergence(details) {
172
- const driftTools = details.filter((d) => d.status !== 'unknown' && d.drift?.drifted);
172
+ const candidates = details.filter((d) => {
173
+ if (d.status === 'unknown') return false;
174
+ if (d.drift?.drifted) return true;
175
+ // codex 重复注册:drift 检测不到(同路径),但 force install 可收敛
176
+ return d.tool === 'codex' && findDuplicateEvents(d).length > 0;
177
+ });
173
178
  const conf = checkTmuxConf();
174
179
  const confStale = conf.exists && conf.referenced && !conf.pointsToCurrent;
175
180
 
176
- const options = driftTools.map((d) => ({
177
- value: `tool:${d.tool}`,
178
- label: `${d.label} hooks 收敛到当前 source`,
179
- hint: `旧路径 ${(d.drift.stale || []).join(', ')}`,
180
- }));
181
+ const options = candidates.map((d) => {
182
+ const hints = [];
183
+ if (d.drift?.drifted) hints.push(`旧路径 ${(d.drift.stale || []).join(', ')}`);
184
+ if (d.tool === 'codex') {
185
+ const dups = findDuplicateEvents(d);
186
+ if (dups.length > 0) {
187
+ hints.push(`重复:${dups.map((x) => `${x.event}×${x.registered}`).join('、')}`);
188
+ }
189
+ }
190
+ return {
191
+ value: `tool:${d.tool}`,
192
+ label: `${d.label} hooks 收敛到当前 source`,
193
+ hint: hints.join(';'),
194
+ };
195
+ });
181
196
  if (confStale) {
182
197
  options.push({ value: 'tmuxconf', label: '.tmux.conf 加载声明更新为当前 source' });
183
198
  }
184
199
  if (options.length === 0) return;
185
200
 
186
201
  options.push({ value: '__skip__', label: '← 暂不处理' });
187
- p.log.warn('检测到部分配置指向其他安装源(漂移)。功能仍可用,但新安装/自修复只会收敛当前 source。');
202
+ p.log.warn('检测到配置漂移或 codex hooks 重复注册。功能仍可用,但收敛会重写 hooks(codex 需重新 Trust)。');
188
203
  const sel = await p.select({
189
204
  message: '选择要收敛的项(Esc 跳过)',
190
205
  options,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmuxclihook",
3
- "version": "0.5.16",
3
+ "version": "0.5.17",
4
4
  "description": "交互式 TUI 安装器:环境侦测、AI CLI 侦测、tmux-ai-status hooks 装/卸/修、TPM 软链校验",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,12 +36,17 @@ ADAPTER_PERMISSION_PRE_TOOL_NAMES="request_permissions"
36
36
  # 自修复/TUI 调用的安装脚本
37
37
  ADAPTER_INSTALLER="${_LIB_DIR}/install-codex-hooks.sh"
38
38
 
39
- # 完整性计数:6 个事件中注册了含 tmux-ai-status codex 的 command 的事件数(按事件,
39
+ # 本插件条目的唯一 marker(归属判定路径无关:contains 子串匹配)。
40
+ # 统一用于 count/integrity/detail/strip——避免历史实现里 strip 用宽串
41
+ # `tmux-ai-status`、check 用 `tmux-ai-status codex` 的发散。
42
+ _CODEX_MARKER="tmux-ai-status codex"
43
+
44
+ # 完整性计数:6 个事件中注册了含 _CODEX_MARKER 的 command 的事件数(按事件,
40
45
  # 非扁平总数——重复+缺失的组合在扁平计数下会假阳性)。
41
46
  _CODEX_JQ_COUNT='
42
47
  .hooks as $h |
43
48
  ["SessionStart","UserPromptSubmit","PreToolUse","PermissionRequest","PostToolUse","Stop"]
44
- | map(select([$h[.][]?.hooks[]?.command // empty] | any(contains("tmux-ai-status codex"))))
49
+ | map(select([$h[.][]?.hooks[]?.command // empty] | any(contains($marker))))
45
50
  | length
46
51
  '
47
52
 
@@ -51,17 +56,17 @@ _CODEX_JQ_COUNT='
51
56
  adapter_check_integrity() {
52
57
  [ -f "$ADAPTER_HOOKS_FILE" ] || return 1
53
58
  local n ev p ok_events=""
54
- n=$(jq "$_CODEX_JQ_COUNT" "$ADAPTER_HOOKS_FILE" 2>/dev/null)
59
+ n=$(jq --arg marker "$_CODEX_MARKER" "$_CODEX_JQ_COUNT" "$ADAPTER_HOOKS_FILE" 2>/dev/null)
55
60
  [ "${n:-0}" -ge 6 ] || return 1
56
61
  while IFS=$'\t' read -r ev p; do
57
62
  if [ -n "$ev" ] && [ -x "$p" ]; then
58
63
  case ",$ok_events," in *",$ev,"*) ;; *) ok_events="$ok_events,$ev" ;; esac
59
64
  fi
60
- done < <(jq -r '.hooks as $h |
65
+ done < <(jq -r --arg marker "$_CODEX_MARKER" '.hooks as $h |
61
66
  ["SessionStart","UserPromptSubmit","PreToolUse","PermissionRequest","PostToolUse","Stop"] as $events |
62
67
  $events[] as $e |
63
68
  [$h[$e][]?.hooks[]?.command // empty
64
- | select(contains("tmux-ai-status codex")) | split(" ")[0]] as $scripts |
69
+ | select(contains($marker)) | split(" ")[0]] as $scripts |
65
70
  select(($scripts | length) > 0) |
66
71
  $scripts[] | "\($e)\t\(.)"' "$ADAPTER_HOOKS_FILE" 2>/dev/null)
67
72
  for ev in SessionStart UserPromptSubmit PreToolUse PermissionRequest PostToolUse Stop; do
@@ -135,11 +140,11 @@ adapter_check_detail() {
135
140
  printf '{"mode":"file","events":{},"plugin":null,"registeredPaths":[],"trust":%s}\n' "$trust_json"
136
141
  return 0
137
142
  fi
138
- pairs=$(jq -r '.hooks as $h |
143
+ pairs=$(jq -r --arg marker "$_CODEX_MARKER" '.hooks as $h |
139
144
  ["SessionStart","UserPromptSubmit","PreToolUse","PermissionRequest","PostToolUse","Stop"] as $events |
140
145
  $events[] as $e |
141
146
  $h[$e][]?.hooks[]?.command // empty |
142
- select(contains("tmux-ai-status codex")) | split(" ")[0] | "\($e)\t\(.)"' "$src" 2>/dev/null)
147
+ select(contains($marker)) | split(" ")[0] | "\($e)\t\(.)"' "$src" 2>/dev/null)
143
148
  rows=""
144
149
  while IFS= read -r line; do
145
150
  [ -n "$line" ] || continue
@@ -208,17 +213,22 @@ EOF
208
213
  printf '%s\n' "${out# }" | tr ' ' '\n'
209
214
  }
210
215
 
211
- # 从每个事件数组剔除本插件 group(command 含 tmux-ai-status),事件/hooks 清空后删键。
216
+ # 命令级剥离:逐 hook 删除含 _CODEX_MARKER 的 command(不看 type,缺 type 的
217
+ # 本插件条目同样清除);group 仍有剩余 hook 则保留,空 group/事件删除。
218
+ # 与 claude 的 _CLAUDE_JQ_STRIP 同构;防御缺 hooks 键 / null / 非数组的 group。
212
219
  _codex_strip_managed() {
213
- jq '
214
- def clean_event:
215
- map(select(
216
- (.hooks // []) | any(.type == "command" and (.command // "" | contains("tmux-ai-status"))) | not
217
- ));
218
- if .hooks then
219
- .hooks |= (with_entries(.value |= clean_event) | with_entries(select(.value | length > 0)))
220
+ jq --arg marker "$_CODEX_MARKER" '
221
+ def trim_group:
222
+ if (.hooks | type) == "array" then
223
+ .hooks |= [ .[] | select(((.command // "") | tostring | contains($marker)) | not) ]
224
+ | select((.hooks | length) > 0)
225
+ else . end;
226
+ def trim_event:
227
+ if type == "array" then [ .[] | trim_group ] else . end;
228
+ if (.hooks | type) == "object" then
229
+ .hooks |= (with_entries(.value |= trim_event) | with_entries(select((.value | length) > 0)))
220
230
  else . end
221
- | if (.hooks // {}) == {} then del(.hooks) else . end
231
+ | if (.hooks | type) == "object" and ((.hooks | length) == 0) then del(.hooks) else . end
222
232
  '
223
233
  }
224
234
 
@@ -479,17 +479,23 @@ build_all_status() {
479
479
  }
480
480
 
481
481
  # --- Pending 集合管理(PreToolUse / PermissionRequest 配对)---
482
- # 状态判定基于两个 per-pane 集合:
483
- # pending-pre : 已 PreToolUse 未 PostToolUse 的 tool_use_id
484
- # pending-perm : 已 PermissionRequest 未配对的 tool_use_id (含 ! 或 ?)
482
+ # 状态判定基于两个 per-pane 集合(另有一个乱序辅助集合):
483
+ # pending-pre : 已 PreToolUse 未 PostToolUse 的 tool_use_id(行 "id tool ts")
484
+ # pending-perm : 已 PermissionRequest 未配对的 tool_use_id(行 "id mark tool ts")
485
+ # recent-post : 已 PostToolUse 完成的 tool_use_id(行 "id ts")
486
+ # Post 先于 Pre 到达时,迟到的 Pre 据此跳过写入,避免状态卡 ">"
485
487
  # 派生规则:
486
488
  # perm 含 "!" → STATUS=!
487
489
  # perm 含 "?" → STATUS=?
488
490
  # pre 非空 → STATUS=>
489
491
  # 都空 → STATUS=""(事件自行决定)
490
492
 
493
+ _PENDING_DONE_WINDOW=60 # 迟到 Pre 的拦截窗口(tool_use_id 单次唯一,10x 余量)
494
+ _PENDING_DONE_RETAIN=300 # recent-post 保留时长(写时修剪)
495
+
491
496
  _pending_pre_path() { echo "$(_pane_dir)/pending-pre"; }
492
497
  _pending_perm_path() { echo "$(_pane_dir)/pending-perm"; }
498
+ _pending_done_path() { echo "$(_pane_dir)/recent-post"; }
493
499
  _lock_dir() { echo "$(_pane_dir)/.lock"; }
494
500
  _event_lock_dir() { echo "$(_pane_dir)/.event-lock"; }
495
501
 
@@ -586,17 +592,73 @@ _release_lock() {
586
592
  rmdir "$(_lock_dir)" 2>/dev/null
587
593
  }
588
594
 
595
+ # --- recent-post(已完成 id 的 tombstone)---
596
+ # 调用方必须持 .lock。只读:id 是否在窗口内已完成。
597
+ _recent_done_has() { # $1=id
598
+ local id="$1" f now
599
+ [ -n "$id" ] || return 1
600
+ f=$(_pending_done_path)
601
+ [ -s "$f" ] || return 1
602
+ now=$(date +%s)
603
+ awk -v id="$id" -v now="$now" -v max="$_PENDING_DONE_WINDOW" '
604
+ $1 == id && $2 ~ /^[0-9]+$/ && now - $2 <= max { found = 1; exit }
605
+ END { exit !found }' "$f" 2>/dev/null
606
+ }
607
+
608
+ # 调用方必须持 .lock。一次 awk 同时完成「同 id 去重 + 过期修剪 + 追加」。
609
+ _record_pending_done() { # $1=id
610
+ local id="$1" f now tmp
611
+ [ -n "$id" ] || return 0
612
+ f=$(_pending_done_path)
613
+ now=$(date +%s)
614
+ tmp="${f}.tmp.$$"
615
+ if [ -f "$f" ]; then
616
+ awk -v id="$id" -v cutoff="$((now - _PENDING_DONE_RETAIN))" '
617
+ $1 != id && $2 ~ /^[0-9]+$/ && $2 >= cutoff { print }' "$f" > "$tmp" 2>/dev/null
618
+ else
619
+ : > "$tmp"
620
+ fi
621
+ printf '%s %s\n' "$id" "$now" >> "$tmp"
622
+ mv -f "$tmp" "$f" 2>/dev/null || rm -f "$tmp"
623
+ }
624
+
625
+ # 调用方必须持 .lock。按首列精确比较(id 可能含 . [ 等 BRE 元字符)。
626
+ _remove_pending_id_locked() { # $1=id
627
+ local id="$1" pre perm
628
+ [ -n "$id" ] || return 0
629
+ pre=$(_pending_pre_path)
630
+ perm=$(_pending_perm_path)
631
+ if [ -f "$pre" ]; then
632
+ awk -v id="$id" '$1 != id' "$pre" > "${pre}.tmp" 2>/dev/null
633
+ mv -f "${pre}.tmp" "$pre" 2>/dev/null || rm -f "${pre}.tmp"
634
+ fi
635
+ if [ -f "$perm" ]; then
636
+ awk -v id="$id" '$1 != id' "$perm" > "${perm}.tmp" 2>/dev/null
637
+ mv -f "${perm}.tmp" "$perm" 2>/dev/null || rm -f "${perm}.tmp"
638
+ fi
639
+ }
640
+
589
641
  # 在锁内追加一行到 pending-pre。$1=tool_use_id $2=tool_name
642
+ # Post 已先到达的同 id(recent-post 窗口内)→ 跳过并置 _pending_pre_skipped=1,
643
+ # 调用方据此保持已完成状态(不能把迟到的 Pre 重新抬成处理中/审批态)。
644
+ _pending_pre_skipped=0
590
645
  _add_pending_pre() {
591
- local id="$1" tool="$2"
592
- [ -z "$id" ] && return
593
- _acquire_lock || return
594
- local f; f=$(_pending_pre_path)
595
- # 已存在则不重复
596
- if ! grep -q "^${id} " "$f" 2>/dev/null; then
597
- echo "${id} ${tool:-?} $(date +%s)" >> "$f"
646
+ local id="$1" tool="$2" pre
647
+ _pending_pre_skipped=0
648
+ [ -z "$id" ] && return 0
649
+ _acquire_lock || return 0
650
+ pre=$(_pending_pre_path)
651
+ if _recent_done_has "$id"; then
652
+ _pending_pre_skipped=1
653
+ # 防御:Post 的清理曾因 IO/锁失败残留同 id 时,顺手清掉
654
+ _remove_pending_id_locked "$id"
655
+ elif ! awk -v id="$id" '$1 == id { found = 1; exit } END { exit !found }' "$pre" 2>/dev/null; then
656
+ printf '%s %s %s\n' "$id" "${tool:-?}" "$(date +%s)" >> "$pre"
598
657
  fi
599
658
  _release_lock
659
+ if [ "$_pending_pre_skipped" = "1" ]; then
660
+ _ai_log "PENDING: SKIP stale Pre id=${id} tool=${tool:-?} (completed within ${_PENDING_DONE_WINDOW}s)"
661
+ fi
600
662
  }
601
663
 
602
664
  # 在锁内追加一行到 pending-perm。$1=tool_use_id $2=! 或 ? $3=tool_name
@@ -605,8 +667,9 @@ _add_pending_perm() {
605
667
  [ -z "$id" ] && return
606
668
  _acquire_lock || return
607
669
  local f; f=$(_pending_perm_path)
608
- if ! grep -q "^${id} " "$f" 2>/dev/null; then
609
- echo "${id} ${mark} ${tool:-?} $(date +%s)" >> "$f"
670
+ # 首列精确比较(id 可能含 BRE 元字符)
671
+ if ! awk -v id="$id" '$1 == id { found = 1; exit } END { exit !found }' "$f" 2>/dev/null; then
672
+ printf '%s %s %s %s\n' "$id" "$mark" "${tool:-?}" "$(date +%s)" >> "$f"
610
673
  fi
611
674
  _release_lock
612
675
  }
@@ -636,40 +699,36 @@ _bind_pending_perm_to_pre() {
636
699
  [ -n "$BOUND_PERMISSION_ID" ]
637
700
  }
638
701
 
639
- # 从 pending-pre 和 pending-perm 同时移除 id(配对清除)
702
+ # 从 pending-pre 和 pending-perm 同时移除 id(配对清除)。
703
+ # 无论 pending 文件是否存在都记录 tombstone:Post 先于 Pre 到达时,后者是唯一
704
+ # 能拦住迟到 Pre 的证据(见 _add_pending_pre)。
640
705
  _remove_pending_id() {
641
706
  local id="$1"
642
- [ -z "$id" ] && return
643
- _acquire_lock || return
644
- local pre; pre=$(_pending_pre_path)
645
- local perm; perm=$(_pending_perm_path)
646
- # 注意: grep -v 无匹配行时退出 1,不能用 && 串接 mv;改用无条件 mv
647
- if [ -f "$pre" ]; then
648
- grep -v "^${id} " "$pre" > "${pre}.tmp" 2>/dev/null
649
- mv "${pre}.tmp" "$pre" 2>/dev/null || rm -f "${pre}.tmp"
650
- fi
651
- if [ -f "$perm" ]; then
652
- grep -v "^${id} " "$perm" > "${perm}.tmp" 2>/dev/null
653
- mv "${perm}.tmp" "$perm" 2>/dev/null || rm -f "${perm}.tmp"
654
- fi
707
+ [ -z "$id" ] && return 0
708
+ _acquire_lock || return 0
709
+ _record_pending_done "$id"
710
+ _remove_pending_id_locked "$id"
655
711
  _release_lock
656
712
  }
657
713
 
658
714
  # 仅移除已回复的权限请求。OpenCode 的 request ID 与 tool call ID 独立,不能同时清
659
715
  # pending-pre;后续 PostToolUse 会用 call ID 清理工具执行项。
716
+ # 不记 tombstone:request id 与 callID 命名空间不同,记了也拦不到迟到的 Pre。
660
717
  _remove_pending_perm_id() {
661
718
  local id="$1"
662
719
  [ -z "$id" ] && return
663
720
  _acquire_lock || return
664
721
  local perm; perm=$(_pending_perm_path)
665
722
  if [ -f "$perm" ]; then
666
- grep -v "^${id} " "$perm" > "${perm}.tmp" 2>/dev/null
667
- mv "${perm}.tmp" "$perm" 2>/dev/null || rm -f "${perm}.tmp"
723
+ awk -v id="$id" '$1 != id' "$perm" > "${perm}.tmp" 2>/dev/null
724
+ mv -f "${perm}.tmp" "$perm" 2>/dev/null || rm -f "${perm}.tmp"
668
725
  fi
669
726
  _release_lock
670
727
  }
671
728
 
672
- # 清空两个 pending 文件(Stop/SessionStart/SessionEnd/UserPromptSubmit/Esc 用)
729
+ # 清空两个 pending 文件(Stop/SessionStart/SessionEnd/UserPromptSubmit/Esc 用)。
730
+ # recent-post 刻意不清:turn/session 边界之后仍可能收到同 turn 迟到的 Pre,
731
+ # 删掉 tombstone 会在边界重新引入残留;其生命周期由 300s 修剪与 pane 目录销毁负责。
673
732
  _clear_pending() {
674
733
  [ -n "$TMUX_PANE" ] || return
675
734
  _acquire_lock || return
@@ -677,16 +736,18 @@ _clear_pending() {
677
736
  _release_lock
678
737
  }
679
738
 
680
- # pending 集合中是否有 N 秒内的条目(每行 "id tool ts")。用于区分「工具确实在
681
- # 跑」与「泄漏/中断残留的旧条目」——并发 hook 抢锁超时会让 _remove_pending_id
682
- # 静默放弃,集合可能长期残留陈旧 id(实测存在),不能只按非空判定活跃。
739
+ # pending 集合中是否有 N 秒内的条目。用于区分「工具确实在跑」与「泄漏/中断残留
740
+ # 的旧条目」——并发 hook 抢锁超时会让 _remove_pending_id 静默放弃,集合可能长期
741
+ # 残留陈旧 id(实测存在),不能只按非空判定活跃。
742
+ # 时间戳取末列:pending-pre 是 "id tool ts"(3 列),pending-perm 是
743
+ # "id mark tool ts"(4 列)——固定 $3 对 perm 永远不命中(历史 bug)。
683
744
  _pending_recent() { # $1=秒数 → 0=有新鲜条目
684
745
  local max="${1:-10}" now f
685
746
  now=$(date +%s)
686
747
  for f in "$(_pending_pre_path)" "$(_pending_perm_path)"; do
687
748
  [ -s "$f" ] || continue
688
749
  if awk -v now="$now" -v max="$max" '
689
- $3 ~ /^[0-9]+$/ && now - $3 <= max { found = 1; exit }
750
+ $NF ~ /^[0-9]+$/ && now - $NF <= max { found = 1; exit }
690
751
  END { exit !found }' "$f" 2>/dev/null; then
691
752
  return 0
692
753
  fi
@@ -192,27 +192,34 @@ case "$EVENT" in
192
192
  IFS=$'\t' read -r TOOL TOOL_USE_ID <<< "$_tool_fields"
193
193
  INPUT_DIGEST=$(printf '%s' "$INPUT" | jq -c '{tool_name, tool_input}' 2>/dev/null)
194
194
  _add_pending_pre "$TOOL_USE_ID" "$TOOL"
195
- _permission_pre_tool=0
196
- for _permission_tool in ${ADAPTER_PERMISSION_PRE_TOOL_NAMES:-}; do
197
- [ "$TOOL" = "$_permission_tool" ] && { _permission_pre_tool=1; break; }
198
- done
199
- if [ "$_permission_pre_tool" = "1" ]; then
200
- STATUS="!"
201
- _add_pending_perm "$TOOL_USE_ID" "$STATUS" "$TOOL"
195
+ if [ "$_pending_pre_skipped" = "1" ]; then
196
+ # Post/PostToolUseFailure 已处理同 id(乱序到达):不重建 pending、不进入
197
+ # 审批态(尤其 codex request_permissions 不得复活 !),保持已完成状态。
198
+ STATUS="$PREV_STATUS"
199
+ _log_flags="STALE_PRE=skip"
202
200
  else
203
- # Claude 的 PermReq 不带 tool_use_id,派生看不到审批态;用 LIVE_STATUS 兜底。
204
- # Codex sentinel 和 OpenCode request ID 则会直接从 pending 集合派生。
205
- LIVE_STATUS=$(_current_pane_status)
206
- case "$LIVE_STATUS" in
207
- "!"|"?") STATUS="$LIVE_STATUS" ;;
208
- *)
209
- if _derive_status_from_pending; then
210
- STATUS="$DERIVED"
211
- else
212
- STATUS=">"
213
- fi
214
- ;;
215
- esac
201
+ _permission_pre_tool=0
202
+ for _permission_tool in ${ADAPTER_PERMISSION_PRE_TOOL_NAMES:-}; do
203
+ [ "$TOOL" = "$_permission_tool" ] && { _permission_pre_tool=1; break; }
204
+ done
205
+ if [ "$_permission_pre_tool" = "1" ]; then
206
+ STATUS="!"
207
+ _add_pending_perm "$TOOL_USE_ID" "$STATUS" "$TOOL"
208
+ else
209
+ # Claude 的 PermReq 不带 tool_use_id,派生看不到审批态;用 LIVE_STATUS 兜底。
210
+ # Codex sentinel 和 OpenCode request ID 则会直接从 pending 集合派生。
211
+ LIVE_STATUS=$(_current_pane_status)
212
+ case "$LIVE_STATUS" in
213
+ "!"|"?") STATUS="$LIVE_STATUS" ;;
214
+ *)
215
+ if _derive_status_from_pending; then
216
+ STATUS="$DERIVED"
217
+ else
218
+ STATUS=">"
219
+ fi
220
+ ;;
221
+ esac
222
+ fi
216
223
  fi
217
224
  ;;
218
225
  PostToolUse|PostToolUseFailure)
@@ -95,6 +95,14 @@ export async function checkToolDetail(tool, { run } = {}) {
95
95
  };
96
96
  }
97
97
 
98
+ // 重复注册检测:同事件下 registered > 1 表示有多条本插件 command(跨 group 或多 group)。
99
+ // 只用于提示——codex 重写 hooks.json 会使 trusted_hash 失效,因此不自动收敛。
100
+ export function findDuplicateEvents(detail) {
101
+ return Object.entries(detail?.events || {})
102
+ .filter(([, e]) => Number(e?.registered) > 1)
103
+ .map(([event, e]) => ({ event, registered: Number(e.registered) }));
104
+ }
105
+
98
106
  // 渲染单工具明细为人类可读行。drift 行用 ⚠ 前缀(脚本仍可用,功能未坏,
99
107
  // 但指向的安装源不是当前 source——收敛入口在 TUI 侧)。
100
108
  export function renderToolDetail(detail) {
@@ -108,6 +116,17 @@ export function renderToolDetail(detail) {
108
116
  lines.push(` ${NO} 无法判定:${detail.error || '未知错误'}`);
109
117
  }
110
118
 
119
+ // codex 重复注册:功能性提示(status 仍 ok、exit code 不变),收敛需显式操作
120
+ if (detail.tool === 'codex') {
121
+ const dups = findDuplicateEvents(detail);
122
+ if (dups.length > 0) {
123
+ const list = dups.map((d) => `${d.event}×${d.registered}`).join('、');
124
+ lines.push(
125
+ ` ${WARN} codex hooks 重复注册:${list}——codex 不会自动收敛(重写 hooks.json 会使 trusted_hash 失效,需要重新 Trust all);可用 TMUX_AI_FORCE_INSTALL=1 重跑 install-codex-hooks.sh,或使用 TUI 收敛入口(会先确认 re-trust 代价)`
126
+ );
127
+ }
128
+ }
129
+
111
130
  if (detail.suppress) {
112
131
  lines.push(` ${WARN} 存在卸载意图标记(suppressed-${detail.tool}):自动修复已被抑制`);
113
132
  }