tmuxclihook 0.1.1 → 0.2.1

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.
@@ -0,0 +1,521 @@
1
+ #!/bin/bash
2
+ # lib-tmux-ai-status.sh: 共享库 — TMUX_PANE 解析、状态聚合、pane title 监控
3
+ # 被 tmux-ai-status source
4
+ # 调用方需设置: TOOL_ID ("claude" | "codex" | "opencode")、SESSION_ID(从 hook input 解析)
5
+
6
+ _LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+
8
+ _STATUS_DIR="${TMUX_AI_STATUS_DIR:-/tmp/ai-status}"
9
+ STATUS_COLOR="#F1FA8C"
10
+
11
+ # --- 日志模块 ---
12
+ source "${_LIB_DIR}/lib-tmux-ai-log.sh"
13
+
14
+ # --- 工具适配器加载 ---
15
+ # 每个 AI CLI 的差异(事件集/hooks路径/格式/完整性/进程名/SessionEnd/SessionStart时机)
16
+ # 抽到 adapters/<TOOL_ID>.sh,核心引擎读 adapter 暴露的变量/函数分派,不再写 case "$TOOL_ID"。
17
+ # 加新 CLI = 加一个 adapters/<tool>.sh,核心零改动。
18
+ _ADAPTER_DIR="${_LIB_DIR}/adapters"
19
+ _load_adapter() {
20
+ local tool="${TOOL_ID:-claude}"
21
+ local adapter="${_ADAPTER_DIR}/${tool}.sh"
22
+ if [ -f "$adapter" ]; then
23
+ source "$adapter"
24
+ else
25
+ _ai_log "ADAPTER: 未找到 ${adapter},回退默认(claude)"
26
+ [ -f "${_ADAPTER_DIR}/claude.sh" ] && source "${_ADAPTER_DIR}/claude.sh"
27
+ fi
28
+ }
29
+ _load_adapter
30
+
31
+ # 跨 adapter 汇总所有 AI CLI 的进程名(供 _pane_has_ai_process 识别聚合场景下的
32
+ # 任意工具进程——聚合/清理路径需认全部工具,不止当前 TOOL_ID)。扫 adapters/*.sh
33
+ # 提取 ADAPTER_PROCESS_NAMES。惰性求值,缓存到 _ALL_AI_PROCESS_NAMES。
34
+ _ALL_AI_PROCESS_NAMES=""
35
+ _collect_ai_process_names() {
36
+ [ -n "$_ALL_AI_PROCESS_NAMES" ] && { echo "$_ALL_AI_PROCESS_NAMES"; return; }
37
+ local names="" f n
38
+ for f in "${_ADAPTER_DIR}"/*.sh; do
39
+ [ -f "$f" ] || continue
40
+ # 提取 ADAPTER_PROCESS_NAMES="..." 的值(不 source,避免副作用)
41
+ n=$(sed -n 's/^ADAPTER_PROCESS_NAMES="\([^"]*\)".*/\1/p' "$f" 2>/dev/null)
42
+ [ -n "$n" ] && names="$names $n"
43
+ done
44
+ # 去重
45
+ _ALL_AI_PROCESS_NAMES=$(echo "$names" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')
46
+ echo "$_ALL_AI_PROCESS_NAMES"
47
+ }
48
+
49
+ # --- Per-pane 目录 ---
50
+ # 结构: ${_STATUS_DIR}/${PANE_SANITIZED}/
51
+ # pane-${SESSION_ID} session → pane 映射(内容为 pane_id)
52
+ # pending-pre 已收 PreToolUse、未收 PostToolUse 的 tool_use_id(每行 "id tool ts")
53
+ # pending-perm 已收 PermissionRequest、未配对的 tool_use_id(每行 "id !/? tool ts")
54
+ # .lock/ 修改 pending-* 时的 per-pane mkdir mutex
55
+ # .event-lock/ 串行化一次 hook 的 pending 变更、派生和 tmux option 写入
56
+
57
+ _pane_sanitized() {
58
+ echo "${TMUX_PANE//[^a-zA-Z0-9]/_}"
59
+ }
60
+
61
+ _pane_dir() {
62
+ echo "${_STATUS_DIR}/$(_pane_sanitized)"
63
+ }
64
+
65
+ _ensure_pane_dir() {
66
+ local dir; dir=$(_pane_dir)
67
+ [ -d "$dir" ] || mkdir -p "$dir" 2>/dev/null
68
+ }
69
+
70
+ # --- TMUX_PANE 解析 ---
71
+ # hook 子进程不继承 $TMUX_PANE,通过进程树向上查找所属 pane
72
+ resolve_tmux_pane() {
73
+ if [ -z "$TMUX_PANE" ]; then
74
+ if [ -n "$TMUX" ]; then
75
+ :
76
+ elif command -v tmux &>/dev/null; then
77
+ :
78
+ else
79
+ return
80
+ fi
81
+ local check_pid=$$
82
+ while [ "${check_pid:-0}" -gt 1 ]; do
83
+ local found
84
+ found=$(tmux list-panes -a -F "#{pane_id} #{pane_pid}" 2>/dev/null \
85
+ | awk -v pid="$check_pid" '$2==pid{print $1; exit}')
86
+ if [ -n "$found" ]; then TMUX_PANE="$found"; break; fi
87
+ check_pid=$(ps -o ppid= -p "$check_pid" 2>/dev/null | tr -d '[:space:]')
88
+ done
89
+ fi
90
+ }
91
+
92
+ # --- Pane ID 持久化(供 SessionEnd 回读)---
93
+ # 注意: SessionStart/SessionEnd 事件不含 session_id,_save_pane_id 在这两事件中不会保存。
94
+ # 映射由后续事件(PreToolUse、PermissionRequest 等)建立。
95
+ # SessionEnd 回退链: resolve_tmux_pane → _load_pane_id → _cleanup_stale_panes。
96
+ # 若 Claude 会话全程无工具调用,pane 状态可能残留最多 60s(直到下次 cleanup)。
97
+ _save_pane_id() {
98
+ [ -n "$TMUX_PANE" ] && [ -n "$SESSION_ID" ] && { _ensure_pane_dir; echo "$TMUX_PANE" > "$(_pane_dir)/pane-${SESSION_ID}"; }
99
+ }
100
+ _load_pane_id() {
101
+ [ -z "$SESSION_ID" ] && return 1
102
+ local f
103
+ for f in "${_STATUS_DIR}"/*/pane-${SESSION_ID}; do
104
+ [ -f "$f" ] || continue
105
+ TMUX_PANE=$(cat "$f" 2>/dev/null) && [ -n "$TMUX_PANE" ] && return 0
106
+ done
107
+ return 1
108
+ }
109
+ _clear_pane_id() {
110
+ [ -n "$SESSION_ID" ] && rm -f "$(_pane_dir)/pane-${SESSION_ID}"
111
+ }
112
+
113
+ # --- 状态聚合 ---
114
+ # 扫描所有 attached session 的 pane,读取 @ai_pane_status,写入 @ai_all_status
115
+ #
116
+ # 空窗期补 idle:codex 的 SessionStart hook 延迟到首个 turn 才触发(上游 run_pending_
117
+ # session_start_hooks 设计),空闲蹲在提示符时插件收不到任何事件 → 无 @ai_pane_status。
118
+ # 用 pane_current_command 兜底:前台命令是 codex/claude 但无显式状态时,显示 idle `-`。
119
+ # 纯显示,不落 @ai_pane_status(避免污染 pending/cleanup 逻辑)。零进程扫描。
120
+ build_all_status() {
121
+ ALL=""
122
+ cur_sess=""
123
+ local _ai_names
124
+ _ai_names=$(_collect_ai_process_names)
125
+ while IFS='|' read -r pane_id session_name win_idx pane_idx claude_status _attached _last cur_cmd; do
126
+ local pane_status="$claude_status"
127
+ if [ -z "$pane_status" ]; then
128
+ # 无显式状态:若前台是 AI 进程(codex 首个 turn 前空窗期),补 idle。
129
+ # 进程名集合从所有 adapter 汇总,前缀匹配(codex-aarch64-* 等)。
130
+ local _cmd_base _n _is_ai=0
131
+ _cmd_base=$(basename "$cur_cmd" 2>/dev/null)
132
+ for _n in $_ai_names; do
133
+ case "$_cmd_base" in
134
+ "$_n"|"$_n"-*|"$_n"_*) _is_ai=1; break ;;
135
+ esac
136
+ done
137
+ [ "$_is_ai" = "1" ] && pane_status="-" || continue
138
+ fi
139
+ local session_block="#[bg=#6272A4,fg=#F8F8F2] ${session_name} #[bg=default,fg=default]"
140
+ local panel_block="#[bg=#44475A,fg=#BD93F9] ${win_idx}.${pane_idx} #[bg=default,fg=default]"
141
+ local status_block
142
+ if [ "$pane_status" = "!" ] || [ "$pane_status" = "?" ]; then
143
+ status_block="#[bg=#FF5555,fg=#F8F8F2] ${pane_status} #[bg=default,fg=default]"
144
+ elif [ "$pane_status" = ">" ]; then
145
+ status_block="#[bg=#50FA7B,fg=#282A36] ${pane_status} #[bg=default,fg=default]"
146
+ else
147
+ status_block="#[bg=${STATUS_COLOR},fg=#282A36] ${pane_status} #[bg=default,fg=default]"
148
+ fi
149
+ local seg="${panel_block}${status_block}"
150
+ if [ "$session_name" != "$cur_sess" ]; then
151
+ ALL="${ALL:+$ALL }${session_block}${seg}"
152
+ cur_sess="$session_name"
153
+ else
154
+ ALL="${ALL}${seg}"
155
+ fi
156
+ done < <(tmux list-panes -a -F "#{pane_id}|#{session_name}|#{window_index}|#{pane_index}|#{@ai_pane_status}|#{session_attached}|#{session_last_attached}|#{pane_current_command}" 2>/dev/null | awk -F'|' '$6>0' | sort -t'|' -k7,7n -k2,2 -k3,3n -k4,4n)
157
+ }
158
+
159
+ # --- Pending 集合管理(PreToolUse / PermissionRequest 配对)---
160
+ # 状态判定基于两个 per-pane 集合:
161
+ # pending-pre : 已 PreToolUse 未 PostToolUse 的 tool_use_id
162
+ # pending-perm : 已 PermissionRequest 未配对的 tool_use_id (含 ! 或 ?)
163
+ # 派生规则:
164
+ # perm 含 "!" → STATUS=!
165
+ # perm 含 "?" → STATUS=?
166
+ # pre 非空 → STATUS=>
167
+ # 都空 → STATUS=""(事件自行决定)
168
+
169
+ _pending_pre_path() { echo "$(_pane_dir)/pending-pre"; }
170
+ _pending_perm_path() { echo "$(_pane_dir)/pending-perm"; }
171
+ _lock_dir() { echo "$(_pane_dir)/.lock"; }
172
+ _event_lock_dir() { echo "$(_pane_dir)/.event-lock"; }
173
+
174
+ # 串行化同 pane 的完整事件处理,避免 pending 变更后、tmux option 写入前被其他 hook
175
+ # 插入,导致较晚写入的过期派生结果覆盖审批态。使用独立锁,允许事件内部继续使用 .lock。
176
+ _acquire_event_lock() {
177
+ [ -n "$TMUX_PANE" ] || return 1
178
+ _ensure_pane_dir
179
+ local lock; lock=$(_event_lock_dir)
180
+ local i
181
+ for i in $(seq 1 200); do
182
+ if mkdir "$lock" 2>/dev/null; then
183
+ echo "$$" > "$lock/owner"
184
+ return 0
185
+ fi
186
+ local owner mtime
187
+ owner=$(cat "$lock/owner" 2>/dev/null)
188
+ if [ -n "$owner" ] && ! kill -0 "$owner" 2>/dev/null; then
189
+ rm -f "$lock/owner" 2>/dev/null
190
+ rmdir "$lock" 2>/dev/null
191
+ continue
192
+ fi
193
+ mtime=$(stat -f %m "$lock" 2>/dev/null || stat -c %Y "$lock" 2>/dev/null)
194
+ if [ -n "$mtime" ] && [ $(( $(date +%s) - mtime )) -ge 10 ]; then
195
+ rm -f "$lock/owner" 2>/dev/null
196
+ rmdir "$lock" 2>/dev/null
197
+ fi
198
+ sleep 0.01 2>/dev/null || sleep 1
199
+ done
200
+ _ai_log "EVENT_LOCK: acquire timeout on $lock"
201
+ return 1
202
+ }
203
+
204
+ _release_event_lock() {
205
+ [ -n "$TMUX_PANE" ] || return
206
+ rm -f "$(_event_lock_dir)/owner" 2>/dev/null
207
+ rmdir "$(_event_lock_dir)" 2>/dev/null
208
+ }
209
+
210
+ # Per-pane mkdir mutex。5s stale 强夺。retry 20 × 10ms ≈ 200ms 上限。
211
+ _acquire_lock() {
212
+ [ -n "$TMUX_PANE" ] || return 1
213
+ _ensure_pane_dir
214
+ local lock; lock=$(_lock_dir)
215
+ local i
216
+ for i in $(seq 1 20); do
217
+ if mkdir "$lock" 2>/dev/null; then
218
+ return 0
219
+ fi
220
+ # stale 检测: 锁目录 mtime > 5s 视为遗弃
221
+ local mtime
222
+ mtime=$(stat -f %m "$lock" 2>/dev/null || stat -c %Y "$lock" 2>/dev/null)
223
+ if [ -n "$mtime" ]; then
224
+ local age=$(( $(date +%s) - mtime ))
225
+ if [ "$age" -ge 5 ]; then
226
+ rmdir "$lock" 2>/dev/null
227
+ mkdir "$lock" 2>/dev/null && return 0
228
+ fi
229
+ fi
230
+ sleep 0.01 2>/dev/null || sleep 1
231
+ done
232
+ _ai_log "LOCK: acquire timeout on $lock"
233
+ return 1
234
+ }
235
+
236
+ _release_lock() {
237
+ [ -n "$TMUX_PANE" ] || return
238
+ rmdir "$(_lock_dir)" 2>/dev/null
239
+ }
240
+
241
+ # 在锁内追加一行到 pending-pre。$1=tool_use_id $2=tool_name
242
+ _add_pending_pre() {
243
+ local id="$1" tool="$2"
244
+ [ -z "$id" ] && return
245
+ _acquire_lock || return
246
+ local f; f=$(_pending_pre_path)
247
+ # 已存在则不重复
248
+ if ! grep -q "^${id} " "$f" 2>/dev/null; then
249
+ echo "${id} ${tool:-?} $(date +%s)" >> "$f"
250
+ fi
251
+ _release_lock
252
+ }
253
+
254
+ # 在锁内追加一行到 pending-perm。$1=tool_use_id $2=! 或 ? $3=tool_name
255
+ _add_pending_perm() {
256
+ local id="$1" mark="$2" tool="$3"
257
+ [ -z "$id" ] && return
258
+ _acquire_lock || return
259
+ local f; f=$(_pending_perm_path)
260
+ if ! grep -q "^${id} " "$f" 2>/dev/null; then
261
+ echo "${id} ${mark} ${tool:-?} $(date +%s)" >> "$f"
262
+ fi
263
+ _release_lock
264
+ }
265
+
266
+ # 从 pending-pre 和 pending-perm 同时移除 id(配对清除)
267
+ _remove_pending_id() {
268
+ local id="$1"
269
+ [ -z "$id" ] && return
270
+ _acquire_lock || return
271
+ local pre; pre=$(_pending_pre_path)
272
+ local perm; perm=$(_pending_perm_path)
273
+ # 注意: grep -v 无匹配行时退出 1,不能用 && 串接 mv;改用无条件 mv
274
+ if [ -f "$pre" ]; then
275
+ grep -v "^${id} " "$pre" > "${pre}.tmp" 2>/dev/null
276
+ mv "${pre}.tmp" "$pre" 2>/dev/null || rm -f "${pre}.tmp"
277
+ fi
278
+ if [ -f "$perm" ]; then
279
+ grep -v "^${id} " "$perm" > "${perm}.tmp" 2>/dev/null
280
+ mv "${perm}.tmp" "$perm" 2>/dev/null || rm -f "${perm}.tmp"
281
+ fi
282
+ _release_lock
283
+ }
284
+
285
+ # 仅移除已回复的权限请求。OpenCode 的 request ID 与 tool call ID 独立,不能同时清
286
+ # pending-pre;后续 PostToolUse 会用 call ID 清理工具执行项。
287
+ _remove_pending_perm_id() {
288
+ local id="$1"
289
+ [ -z "$id" ] && return
290
+ _acquire_lock || return
291
+ local perm; perm=$(_pending_perm_path)
292
+ if [ -f "$perm" ]; then
293
+ grep -v "^${id} " "$perm" > "${perm}.tmp" 2>/dev/null
294
+ mv "${perm}.tmp" "$perm" 2>/dev/null || rm -f "${perm}.tmp"
295
+ fi
296
+ _release_lock
297
+ }
298
+
299
+ # 清空两个 pending 文件(Stop/SessionStart/SessionEnd/UserPromptSubmit/Esc 用)
300
+ _clear_pending() {
301
+ [ -n "$TMUX_PANE" ] || return
302
+ _acquire_lock || return
303
+ rm -f "$(_pending_pre_path)" "$(_pending_perm_path)" 2>/dev/null
304
+ _release_lock
305
+ }
306
+
307
+ # 根据当前 pending-perm / pending-pre 派生 STATUS。结果写入全局 DERIVED
308
+ # return 0 = 派生出非空 STATUS, return 1 = 都空(事件需自行决定)
309
+ _derive_status_from_pending() {
310
+ DERIVED=""
311
+ [ -n "$TMUX_PANE" ] || return 1
312
+ local perm; perm=$(_pending_perm_path)
313
+ local pre; pre=$(_pending_pre_path)
314
+ if [ -s "$perm" ]; then
315
+ if awk '{if ($2=="!") {f=1; exit}} END{exit !f}' "$perm" 2>/dev/null; then
316
+ DERIVED="!"; return 0
317
+ fi
318
+ if awk '{if ($2=="?") {f=1; exit}} END{exit !f}' "$perm" 2>/dev/null; then
319
+ DERIVED="?"; return 0
320
+ fi
321
+ fi
322
+ if [ -s "$pre" ]; then
323
+ DERIVED=">"; return 0
324
+ fi
325
+ return 1
326
+ }
327
+
328
+ # 兼容旧调用名(Stop/SessionEnd 仍可能引用)
329
+ _clear_all_state() {
330
+ _clear_pending
331
+ }
332
+
333
+ # --- SessionEnd 竞态保护 ---
334
+ # /new 触发时 SessionEnd(async) 和 SessionStart(async) 同时执行,
335
+ # SessionEnd 可能晚于 SessionStart 写入,将 "-" 覆盖为 ""。
336
+ # 清除自身 pane-id 文件后,检查是否有其他 session 已接管此 pane。
337
+ _new_session_owns_pane() {
338
+ [ -z "$TMUX_PANE" ] && return 1
339
+ local sanitized; sanitized=$(_pane_sanitized)
340
+ local pane_dir="${_STATUS_DIR}/${sanitized}"
341
+ [ -d "$pane_dir" ] || return 1
342
+ local f
343
+ for f in "${pane_dir}"/pane-*; do
344
+ [ -f "$f" ] || continue
345
+ local stored_pane
346
+ stored_pane=$(cat "$f" 2>/dev/null)
347
+ [ "$stored_pane" = "$TMUX_PANE" ] || continue
348
+ local mtime
349
+ # stat 失败时保守视为新鲜(date +%s),避免误清活跃会话
350
+ mtime=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || date +%s)
351
+ local age=$(( $(date +%s) - mtime ))
352
+ [ "$age" -ge 300 ] && ! _pane_has_ai_process "$TMUX_PANE" && continue
353
+ return 0
354
+ done
355
+ return 1
356
+ }
357
+
358
+ # --- 进程树检查 ---
359
+ # BFS 遍历 pane 的进程树,检查是否有 AI CLI 进程。
360
+ # 进程名集合从所有 adapter 的 ADAPTER_PROCESS_NAMES 汇总(_collect_ai_process_names),
361
+ # 前缀匹配(codex 二进制常为 codex-aarch64-* 等带后缀名)。
362
+ _pane_has_ai_process() {
363
+ local pane_id="$1"
364
+ local pane_pid
365
+ # 注意:list-panes -t "%N" 会返回该 pane 所在 window 的全部 pane,
366
+ # 直接取 #{pane_pid} 会混入同 window 其他 pane 的 pid,导致 BFS 爬到
367
+ # 邻近 pane 的 AI 进程而误判。必须用 -a 全局列 + 精确匹配 pane_id。
368
+ pane_pid=$(tmux list-panes -a -F "#{pane_id} #{pane_pid}" 2>/dev/null \
369
+ | awk -v id="$pane_id" '$1==id{print $2; exit}')
370
+ [ -z "$pane_pid" ] && return 1
371
+ local ai_names
372
+ ai_names=$(_collect_ai_process_names)
373
+ local pids="$pane_pid" next="" depth=0
374
+ while [ $depth -lt 10 ] && [ -n "$pids" ]; do
375
+ for p in $pids; do
376
+ local cmd base name
377
+ cmd=$(ps -o comm= -p "$p" 2>/dev/null)
378
+ base=$(basename "$cmd" 2>/dev/null)
379
+ for name in $ai_names; do
380
+ case "$base" in
381
+ "$name"|"$name"-*|"$name"_*) return 0 ;;
382
+ esac
383
+ done
384
+ local children
385
+ children=$(pgrep -P "$p" 2>/dev/null)
386
+ [ -n "$children" ] && next="$next $children"
387
+ done
388
+ pids="${next# }"
389
+ next=""
390
+ depth=$((depth + 1))
391
+ done
392
+ return 1
393
+ }
394
+
395
+ # 检查 pane 的所有 pane-id 文件是否过期(> 5 min),再验证进程树
396
+ _is_pane_session_dead() {
397
+ local pane_id="$1"
398
+ local sanitized="${pane_id//[^a-zA-Z0-9]/_}"
399
+ local pane_dir="${_STATUS_DIR}/${sanitized}"
400
+ [ -d "$pane_dir" ] || return 0
401
+ local f
402
+ for f in "${pane_dir}"/pane-*; do
403
+ [ -f "$f" ] || continue
404
+ local mtime
405
+ # stat 失败时保守视为新鲜(date +%s),避免误清活跃会话
406
+ mtime=$(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f" 2>/dev/null || date +%s)
407
+ local age=$(( $(date +%s) - mtime ))
408
+ [ "$age" -lt 300 ] && return 1
409
+ done
410
+ ! _pane_has_ai_process "$pane_id"
411
+ }
412
+
413
+ # --- 孤儿状态清理(_refresh 用)---
414
+ # 活跃状态 (!, ?, >) 的 pane 必须有对应的 pane 目录和 pane-${SESSION_ID} 文件(由 SessionStart 写入)。
415
+ # 无对应文件则说明状态已过期(手动测试残留、pane 被销毁等),清除整个 pane 目录。
416
+ # 有对应文件但文件过期且进程已死(Stop 未触发的崩溃场景),也清除。
417
+ _cleanup_stale_panes() {
418
+ local active_panes=""
419
+ local f pane_id
420
+ for f in "${_STATUS_DIR}"/*/pane-*; do
421
+ [ -f "$f" ] || continue
422
+ pane_id=$(cat "$f" 2>/dev/null)
423
+ [ -n "$pane_id" ] && active_panes="$active_panes $pane_id"
424
+ done
425
+
426
+ while IFS='|' read -r pane_id pane_status; do
427
+ case "$pane_status" in
428
+ "!"|"?"|">")
429
+ local sanitized="${pane_id//[^a-zA-Z0-9]/_}"
430
+ local pane_dir="${_STATUS_DIR}/${sanitized}"
431
+ if ! echo " $active_panes " | grep -q " $pane_id "; then
432
+ _ai_log "STALE: clearing orphaned '$pane_status' on $pane_id"
433
+ tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
434
+ rm -rf "$pane_dir" 2>/dev/null
435
+ elif _is_pane_session_dead "$pane_id"; then
436
+ _ai_log "STALE: clearing dead session '$pane_status' on $pane_id"
437
+ tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
438
+ rm -rf "$pane_dir" 2>/dev/null
439
+ fi
440
+ ;;
441
+ "✓"|"-")
442
+ if ! _pane_has_ai_process "$pane_id"; then
443
+ _ai_log "STALE: clearing terminal state '$pane_status' on $pane_id (no ai process)"
444
+ tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
445
+ fi
446
+ ;;
447
+ esac
448
+ done < <(tmux list-panes -a -F "#{pane_id}|#{@ai_pane_status}" 2>/dev/null)
449
+
450
+ # 清理孤儿目录:目录存在但对应 pane 已不在 tmux 中
451
+ local all_pane_ids
452
+ all_pane_ids=$(tmux list-panes -a -F "#{pane_id}" 2>/dev/null)
453
+ for pane_dir in "${_STATUS_DIR}"/*/; do
454
+ [ -d "$pane_dir" ] || continue
455
+ local dir_name
456
+ dir_name=$(basename "$pane_dir")
457
+ local found=0
458
+ while IFS= read -r pid; do
459
+ [ "${pid//[^a-zA-Z0-9]/_}" = "$dir_name" ] && { found=1; break; }
460
+ done <<< "$all_pane_ids"
461
+ if [ "$found" -eq 0 ]; then
462
+ # 清理孤儿 poll 进程
463
+ for pid_file in "${pane_dir}"*-poll-pid; do
464
+ [ -f "$pid_file" ] || continue
465
+ local pid
466
+ pid=$(cat "$pid_file" 2>/dev/null)
467
+ [ -n "$pid" ] && kill "$pid" 2>/dev/null
468
+ done
469
+ _ai_log "ORPHAN: removing $pane_dir (pane no longer exists)"
470
+ rm -rf "$pane_dir" 2>/dev/null
471
+ fi
472
+ done
473
+ }
474
+
475
+ # 频率限制的孤儿清理(每次 hook 调用,最多每 60s 执行一次)
476
+ _maybe_cleanup_stale() {
477
+ local marker="${_STATUS_DIR}/.stale-cleanup-ts"
478
+ local now
479
+ now=$(date +%s)
480
+ [ -d "$_STATUS_DIR" ] || mkdir -p "$_STATUS_DIR" 2>/dev/null
481
+ if [ -f "$marker" ]; then
482
+ local last
483
+ last=$(cat "$marker" 2>/dev/null)
484
+ [ $((now - ${last:-0})) -lt 60 ] && return
485
+ fi
486
+ _cleanup_stale_panes
487
+ echo "$now" > "$marker"
488
+ }
489
+
490
+ # Hooks 完整性检查:委托给当前 adapter(adapter_check_integrity)。
491
+ # 各工具的路径/格式/事件数判定在 adapters/<tool>.sh 中声明,核心不再 case 分派。
492
+ # 缺失 → 返回 1(配置被外部应用覆盖时会发生)。
493
+ _check_hooks_integrity() {
494
+ if declare -f adapter_check_integrity >/dev/null 2>&1; then
495
+ adapter_check_integrity
496
+ else
497
+ # adapter 未加载(异常)→ 保守视为完整,避免误触发重装
498
+ return 0
499
+ fi
500
+ }
501
+
502
+ # 兜底自修复:tmux 端事件路径调用,配置被外部覆盖时自动重装。
503
+ # 不依赖 SESSION_ID/EVENT,可被 _refresh 触发。60s 节流。
504
+ # 安装器由 adapter 声明(ADAPTER_INSTALLER)。
505
+ _maybe_repair_hooks() {
506
+ local marker="${_STATUS_DIR}/.hooks-repair-${TOOL_ID:-claude}-ts"
507
+ local now
508
+ now=$(date +%s)
509
+ [ -d "$_STATUS_DIR" ] || mkdir -p "$_STATUS_DIR" 2>/dev/null
510
+ if [ -f "$marker" ]; then
511
+ local last
512
+ last=$(cat "$marker" 2>/dev/null)
513
+ [ $((now - ${last:-0})) -lt 60 ] && return
514
+ fi
515
+ echo "$now" > "$marker"
516
+ local installer="${ADAPTER_INSTALLER}"
517
+ if ! _check_hooks_integrity; then
518
+ _ai_log "REPAIR: hooks integrity check failed, reinstalling (${TOOL_ID:-claude})"
519
+ [ -x "$installer" ] && "$installer" >/dev/null 2>&1 || true
520
+ fi
521
+ }
@@ -0,0 +1,24 @@
1
+ #!/bin/bash
2
+ # tmux-ai-esc: Esc 拒绝检测 — 激活态 pane 状态为 !/? 时设为 -
3
+ # 被 tmux bind-key -n Escape 调用
4
+ # pending 路径只基于 pane_id(与工具无关),TOOL_ID 仅作日志标签
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ PANE_ID=$(tmux display-message -p "#{pane_id}")
8
+ STATUS=$(tmux display-message -pt "$PANE_ID" -p '#{@ai_pane_status}' 2>/dev/null)
9
+
10
+ case "$STATUS" in
11
+ "!"|"?"|">")
12
+ TOOL_ID="ai"
13
+ TMUX_PANE="$PANE_ID"
14
+ source "${SCRIPT_DIR}/lib-tmux-ai-status.sh"
15
+ _clear_pending
16
+ tmux set-option -pt "$PANE_ID" @ai_pane_status "-"
17
+ build_all_status
18
+ tmux set-option -g @ai_all_status "$ALL"
19
+ tmux refresh-client -S
20
+ _ai_log "ESC: denied (status was '$STATUS'), → '-', pending cleared"
21
+ ;;
22
+ esac
23
+
24
+ tmux send-keys -t "$PANE_ID" Escape