tmuxclihook 0.5.15 → 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.15",
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
@@ -74,20 +79,72 @@ adapter_check_integrity() {
74
79
  _CODEX_EVENTS="SessionStart UserPromptSubmit PreToolUse PermissionRequest PostToolUse Stop"
75
80
  _CODEX_LEGACY_TOML="${CODEX_HOME:-$HOME/.codex}/hooks.toml"
76
81
 
82
+ # Hook 授信记录(trust 维度,check --json 用):codex 把 user 来源 hook 的授信写在
83
+ # 每个实例 config.toml 的 [hooks.state."<hooks.json 路径>:<下划线事件>:<组>:<hook>"]。
84
+ # 只判键是否存在——bash 无法复现 trusted_hash;因此:
85
+ # - 键缺失 = 该 hook 一定不会执行(强信号)
86
+ # - 键存在 ≠ 一定执行(hooks 被改写后旧键仍在、hash 失效,codex 会重新弹 Trust)
87
+ # config.toml 不存在 → trusted:null(无法判定)。
88
+ _CODEX_TRUST_EVENTS="session_start user_prompt_submit pre_tool_use permission_request post_tool_use stop"
89
+
90
+ _codex_trust_scan() { # $1=config.toml $2=hooks.json → 每行 "event<TAB>0|1"
91
+ local cfg="$1" hooks="$2" keys e
92
+ [ -f "$cfg" ] || return 1
93
+ # 只提取键名,绝不读取 trusted_hash 值
94
+ keys=$(sed -n 's/^\[hooks\.state\."\([^"]*\)"\].*/\1/p' "$cfg" 2>/dev/null)
95
+ for e in $_CODEX_TRUST_EVENTS; do
96
+ case "$keys" in
97
+ *"$hooks:$e:"*) printf '%s\t1\n' "$e" ;;
98
+ *) printf '%s\t0\n' "$e" ;;
99
+ esac
100
+ done
101
+ }
102
+
103
+ # 汇总 $HOME/.codex 与所有 cc-switch 实例的授信状态(去重;config.toml 不存在则不收录,
104
+ # 当前实例不存在时 trusted:null)。只读。
105
+ _codex_trust_json() {
106
+ local cfg hooks rows e mark missing trusted seen=""
107
+ for cfg in "${HOME}/.codex/config.toml" "${HOME}"/.cc-switch/instances/codex/*/config.toml; do
108
+ [ -f "$cfg" ] || continue
109
+ [ "$cfg" = "$seen" ] && continue
110
+ seen="$cfg"
111
+ hooks="$(dirname "$cfg")/hooks.json"
112
+ rows=$(_codex_trust_scan "$cfg" "$hooks" 2>/dev/null) || continue
113
+ missing=""
114
+ while IFS=$'\t' read -r e mark; do
115
+ [ "$mark" = "0" ] && missing="${missing}${missing:+,}$e"
116
+ done <<EOF
117
+ $rows
118
+ EOF
119
+ if [ -n "$missing" ]; then trusted=0; else trusted=1; fi
120
+ printf '%s\t%s\t%s\t%s\n' "$cfg" "$hooks" "$trusted" "$missing"
121
+ done | jq -R -s --arg cur "${CODEX_HOME:-$HOME/.codex}/config.toml" '
122
+ split("\n") | map(select(length > 0) | split("\t")) as $rows
123
+ | [$rows[] | {configFile: .[0], hooksFile: .[1],
124
+ trusted: (.[2] == "1"),
125
+ missingEvents: (if .[3] == "" then [] else (.[3] | split(",")) end)}] as $inst
126
+ | ($inst | map(select(.configFile == $cur)) | first) as $cur_obj
127
+ | {configFile: $cur,
128
+ trusted: (if $cur_obj then $cur_obj.trusted else null end),
129
+ missingEvents: (if $cur_obj then $cur_obj.missingEvents else [] end),
130
+ instances: $inst}'
131
+ }
132
+
77
133
  # 明细检查(仅 install wrapper 的 check --json 调用;与 claude adapter 同构)。
78
134
  # 差异:无 cc-switch 分支(hooks.json 即文件真源,mode 恒 file)、6 事件、
79
135
  # 匹配串为 "tmux-ai-status codex"。失败输出空 events 降级片段,不 exit 非 0。
80
136
  adapter_check_detail() {
81
- local src="$ADAPTER_HOOKS_FILE" pairs rows line ev p a
137
+ local src="$ADAPTER_HOOKS_FILE" pairs rows line ev p a trust_json
138
+ trust_json=$(_codex_trust_json)
82
139
  if [ ! -f "$src" ]; then
83
- printf '{"mode":"file","events":{},"plugin":null,"registeredPaths":[]}\n'
140
+ printf '{"mode":"file","events":{},"plugin":null,"registeredPaths":[],"trust":%s}\n' "$trust_json"
84
141
  return 0
85
142
  fi
86
- pairs=$(jq -r '.hooks as $h |
143
+ pairs=$(jq -r --arg marker "$_CODEX_MARKER" '.hooks as $h |
87
144
  ["SessionStart","UserPromptSubmit","PreToolUse","PermissionRequest","PostToolUse","Stop"] as $events |
88
145
  $events[] as $e |
89
146
  $h[$e][]?.hooks[]?.command // empty |
90
- 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)
91
148
  rows=""
92
149
  while IFS= read -r line; do
93
150
  [ -n "$line" ] || continue
@@ -98,7 +155,7 @@ adapter_check_detail() {
98
155
  done <<EOF
99
156
  $pairs
100
157
  EOF
101
- jq -R -s --arg mode "file" --arg events "$_CODEX_EVENTS" '
158
+ jq -R -s --arg mode "file" --arg events "$_CODEX_EVENTS" --argjson trust "$trust_json" '
102
159
  (reduce ($events | split(" ")[]) as $e
103
160
  ({}; .[$e] = {registered: 0, alive: 0, scriptPaths: []})) as $base
104
161
  | reduce (split("\n") | .[] | select(length > 0) | split("\t")) as $r (
@@ -109,7 +166,7 @@ EOF
109
166
  scriptPaths: ((.[$r[0]].scriptPaths + [$r[1]]) | unique)
110
167
  })
111
168
  | {mode: $mode, events: ., plugin: null,
112
- registeredPaths: ([.[] | .scriptPaths[]] | unique)}
169
+ registeredPaths: ([.[] | .scriptPaths[]] | unique), trust: $trust}
113
170
  ' <<<"$rows"
114
171
  }
115
172
 
@@ -156,17 +213,22 @@ EOF
156
213
  printf '%s\n' "${out# }" | tr ' ' '\n'
157
214
  }
158
215
 
159
- # 从每个事件数组剔除本插件 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。
160
219
  _codex_strip_managed() {
161
- jq '
162
- def clean_event:
163
- map(select(
164
- (.hooks // []) | any(.type == "command" and (.command // "" | contains("tmux-ai-status"))) | not
165
- ));
166
- if .hooks then
167
- .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)))
168
230
  else . end
169
- | if (.hooks // {}) == {} then del(.hooks) else . end
231
+ | if (.hooks | type) == "object" and ((.hooks | length) == 0) then del(.hooks) else . end
170
232
  '
171
233
  }
172
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)
@@ -32,6 +32,9 @@ function baseDetail(tool) {
32
32
  events: {},
33
33
  plugin: null,
34
34
  registeredPaths: [],
35
+ // codex 授信维度:{configFile, trusted: true|false|null, missingEvents, instances}
36
+ // 仅 codex adapter 提供;null = 该工具无此概念/旧版 adapter。
37
+ trust: null,
35
38
  drift: null,
36
39
  error: null,
37
40
  raw: null,
@@ -70,6 +73,7 @@ export async function checkToolDetail(tool, { run } = {}) {
70
73
  events: parsed.events || {},
71
74
  plugin: parsed.plugin ?? null,
72
75
  registeredPaths: parsed.registeredPaths || [],
76
+ trust: parsed.trust ?? null,
73
77
  drift: parsed.drift || null,
74
78
  error: parsed.error,
75
79
  raw: stdout,
@@ -85,11 +89,20 @@ export async function checkToolDetail(tool, { run } = {}) {
85
89
  events: parsed.events || {},
86
90
  plugin: parsed.plugin ?? null,
87
91
  registeredPaths: parsed.registeredPaths || [],
92
+ trust: parsed.trust ?? null,
88
93
  drift: parsed.drift || null,
89
94
  raw: stdout,
90
95
  };
91
96
  }
92
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
+
93
106
  // 渲染单工具明细为人类可读行。drift 行用 ⚠ 前缀(脚本仍可用,功能未坏,
94
107
  // 但指向的安装源不是当前 source——收敛入口在 TUI 侧)。
95
108
  export function renderToolDetail(detail) {
@@ -103,10 +116,47 @@ export function renderToolDetail(detail) {
103
116
  lines.push(` ${NO} 无法判定:${detail.error || '未知错误'}`);
104
117
  }
105
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
+
106
130
  if (detail.suppress) {
107
131
  lines.push(` ${WARN} 存在卸载意图标记(suppressed-${detail.tool}):自动修复已被抑制`);
108
132
  }
109
133
 
134
+ // codex 授信维度:hooks 注册完整 ≠ 会执行。键缺失 = 一定不执行;键存在只说明
135
+ // 有授信记录(bash 无法复现 trusted_hash,改写后仍可能需重新 Trust)。
136
+ const trust = detail.trust;
137
+ if (trust) {
138
+ if (trust.trusted === false) {
139
+ const miss = (trust.missingEvents || []).join(', ') || '全部事件';
140
+ lines.push(
141
+ ` ${WARN} hooks 未授信:当前实例缺少 [hooks.state] 记录(${miss})——codex 会跳过这些 hook,请在 codex TUI 选择 "Trust all and continue"`
142
+ );
143
+ } else if (trust.trusted === true) {
144
+ lines.push(` ${OK} 授信记录存在(键存在不等于 hash 匹配:hooks 改写后仍可能需重新 Trust)`);
145
+ } else if (detail.status === 'ok') {
146
+ lines.push(` ${WARN} 无法判定授信状态:${trust.configFile} 不存在`);
147
+ }
148
+ const otherUntrusted = (trust.instances || []).filter(
149
+ (i) => i.configFile !== trust.configFile && i.trusted === false
150
+ );
151
+ if (otherUntrusted.length > 0) {
152
+ lines.push(` ${WARN} 其他实例未授信(首次启动会弹 Hooks need review):`);
153
+ for (const i of otherUntrusted) {
154
+ const miss = (i.missingEvents || []).join(', ') || '全部事件';
155
+ lines.push(` ${i.configFile}(缺 ${miss})`);
156
+ }
157
+ }
158
+ }
159
+
110
160
  // 逐事件缺失明细(cc-switch/文件模式的 tools)
111
161
  if (detail.status === 'missing') {
112
162
  const broken = [];