tmuxclihook 0.1.1 → 0.2.0
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/README.md +46 -12
- package/bin/cli.js +158 -52
- package/package.json +7 -3
- package/scripts/adapters/claude.sh +127 -0
- package/scripts/adapters/codex.sh +114 -0
- package/scripts/adapters/opencode.sh +169 -0
- package/scripts/install-claude-hooks.sh +19 -0
- package/scripts/install-codex-hooks.sh +19 -0
- package/scripts/install-opencode-hooks.sh +19 -0
- package/scripts/lib-install-hooks.sh +26 -0
- package/scripts/lib-tmux-ai-log.sh +81 -0
- package/scripts/lib-tmux-ai-status.sh +521 -0
- package/scripts/tmux-ai-esc +24 -0
- package/scripts/tmux-ai-monitor +190 -0
- package/scripts/tmux-ai-status +256 -0
- package/src/adapters-meta.js +90 -53
- package/src/purge.js +7 -1
- package/src/source.js +140 -0
- package/src/symlink.js +20 -19
- package/src/tmuxconf.js +66 -21
- package/src/update.js +39 -0
- package/tmux-ai-hooks-status.tmux +114 -0
|
@@ -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
|