tmuxclihook 0.1.0 → 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,190 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# tmux-ai-monitor — tmux server 级单例监控器
|
|
3
|
+
#
|
|
4
|
+
# 部分 AI CLI(codex、opencode)没有 SessionEnd hook,退出无法主动清空状态;codex 的
|
|
5
|
+
# SessionStart 还会延迟到首个 turn。本监控器每秒根据 pane_current_command / 进程树刷新:
|
|
6
|
+
# - 前台启动此类工具、尚无 hook 状态时,显示 idle `-`
|
|
7
|
+
# - 退出返回 shell 后,清除残留 pane 状态
|
|
8
|
+
# 泛化实现:扫描 adapters/*.sh 找出 ADAPTER_HAS_SESSION_END="false" 的工具及其
|
|
9
|
+
# ADAPTER_PROCESS_NAMES,逐个工具做生命周期检测——新增同类工具的 adapter 无需改本文件。
|
|
10
|
+
# `/new`(codex)等同工具内会话切换不改变前台进程,因此不会改写已有 `✓`。
|
|
11
|
+
|
|
12
|
+
set -u
|
|
13
|
+
|
|
14
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
_ADAPTER_DIR="${SCRIPT_DIR}/adapters"
|
|
16
|
+
MODE="${1:-start}"
|
|
17
|
+
MONITOR_INTERVAL="${TMUX_AI_MONITOR_INTERVAL:-1}"
|
|
18
|
+
|
|
19
|
+
_tmux_server_pid() {
|
|
20
|
+
tmux display-message -p '#{pid}' 2>/dev/null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
_pane_current_command() {
|
|
24
|
+
local pane_id="$1"
|
|
25
|
+
tmux list-panes -a -F "#{pane_id}|#{pane_current_command}" 2>/dev/null \
|
|
26
|
+
| awk -F'|' -v id="$pane_id" '$1==id{print $2; exit}'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# 无 SessionEnd 的工具名集合(惰性求值缓存)。扫 adapters/*.sh 提取
|
|
30
|
+
# ADAPTER_HAS_SESSION_END="false" 的文件对应的 ADAPTER_PROCESS_NAMES,不 source(避免副作用)。
|
|
31
|
+
_NO_SESSION_END_TOOLS=""
|
|
32
|
+
_collect_no_session_end_tools() {
|
|
33
|
+
[ -n "$_NO_SESSION_END_TOOLS" ] && { echo "$_NO_SESSION_END_TOOLS"; return; }
|
|
34
|
+
local names="" f has_end names_in_f
|
|
35
|
+
for f in "${_ADAPTER_DIR}"/*.sh; do
|
|
36
|
+
[ -f "$f" ] || continue
|
|
37
|
+
has_end=$(sed -n 's/^ADAPTER_HAS_SESSION_END="\([^"]*\)".*/\1/p' "$f" 2>/dev/null)
|
|
38
|
+
[ "$has_end" = "false" ] || continue
|
|
39
|
+
names_in_f=$(sed -n 's/^ADAPTER_PROCESS_NAMES="\([^"]*\)".*/\1/p' "$f" 2>/dev/null)
|
|
40
|
+
[ -n "$names_in_f" ] && names="$names $names_in_f"
|
|
41
|
+
done
|
|
42
|
+
_NO_SESSION_END_TOOLS=$(echo "$names" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')
|
|
43
|
+
echo "$_NO_SESSION_END_TOOLS"
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# $1=basename 命令 $2=工具进程名(如 codex/opencode)→ 是否前缀匹配
|
|
47
|
+
_is_tool_command() {
|
|
48
|
+
local base="$1" name="$2"
|
|
49
|
+
case "$base" in
|
|
50
|
+
"$name"|"$name"-*|"$name"_*) return 0 ;;
|
|
51
|
+
esac
|
|
52
|
+
return 1
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# 判定 pane 前台命令是否属于给定工具名
|
|
56
|
+
_pane_command_is_tool() {
|
|
57
|
+
local pane_id="$1" name="$2" current_command
|
|
58
|
+
current_command=$(_pane_current_command "$pane_id")
|
|
59
|
+
[ -n "$current_command" ] || return 1
|
|
60
|
+
_is_tool_command "$(basename "$current_command" 2>/dev/null)" "$name"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# 判定 pane 进程树内是否仍有该工具进程(前台命令优先,再 BFS 遍历进程树)
|
|
64
|
+
_pane_has_tool_process() {
|
|
65
|
+
local pane_id="$1" name="$2"
|
|
66
|
+
_pane_command_is_tool "$pane_id" "$name" && return 0
|
|
67
|
+
|
|
68
|
+
local pane_pid
|
|
69
|
+
pane_pid=$(tmux list-panes -a -F "#{pane_id} #{pane_pid}" 2>/dev/null \
|
|
70
|
+
| awk -v id="$pane_id" '$1==id{print $2; exit}')
|
|
71
|
+
[ -n "$pane_pid" ] || return 1
|
|
72
|
+
|
|
73
|
+
local pids="$pane_pid" next="" depth=0 p cmd base children
|
|
74
|
+
while [ "$depth" -lt 10 ] && [ -n "$pids" ]; do
|
|
75
|
+
for p in $pids; do
|
|
76
|
+
cmd=$(ps -o comm= -p "$p" 2>/dev/null)
|
|
77
|
+
base=$(basename "$cmd" 2>/dev/null)
|
|
78
|
+
_is_tool_command "$base" "$name" && return 0
|
|
79
|
+
children=$(pgrep -P "$p" 2>/dev/null)
|
|
80
|
+
[ -n "$children" ] && next="$next $children"
|
|
81
|
+
done
|
|
82
|
+
pids="${next# }"
|
|
83
|
+
next=""
|
|
84
|
+
depth=$((depth + 1))
|
|
85
|
+
done
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# 对每个无 SessionEnd 的工具名做生命周期检测:前台命令匹配 → 标记见过;
|
|
90
|
+
# 曾标记但前台已切走且进程树也无该工具 → 清空 pane 状态(该工具已退出)。
|
|
91
|
+
# seen 标记按工具名区分(@ai_monitor_seen_<name>),避免多工具互相踩踏清理语义。
|
|
92
|
+
_monitor_no_session_end_lifecycle() {
|
|
93
|
+
local tools name pane_id current_command sanitized pane_dir
|
|
94
|
+
tools=$(_collect_no_session_end_tools)
|
|
95
|
+
[ -n "$tools" ] || return 0
|
|
96
|
+
for name in $tools; do
|
|
97
|
+
while IFS='|' read -r pane_id current_command seen; do
|
|
98
|
+
if [ -n "$current_command" ] && _is_tool_command "$(basename "$current_command" 2>/dev/null)" "$name"; then
|
|
99
|
+
[ "$seen" = "1" ] || tmux set-option -pt "$pane_id" "@ai_monitor_seen_${name}" 1 2>/dev/null || true
|
|
100
|
+
elif [ "$seen" = "1" ] && ! _pane_has_tool_process "$pane_id" "$name"; then
|
|
101
|
+
tmux set-option -pt "$pane_id" @ai_pane_status "" 2>/dev/null || true
|
|
102
|
+
tmux set-option -pu -t "$pane_id" "@ai_monitor_seen_${name}" 2>/dev/null || true
|
|
103
|
+
sanitized="${pane_id//[^a-zA-Z0-9]/_}"
|
|
104
|
+
pane_dir="${_STATUS_DIR}/${sanitized}"
|
|
105
|
+
rm -rf "$pane_dir" 2>/dev/null
|
|
106
|
+
fi
|
|
107
|
+
done < <(tmux list-panes -a -F "#{pane_id}|#{pane_current_command}|#{@ai_monitor_seen_${name}}" 2>/dev/null)
|
|
108
|
+
done
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_monitor_tick() {
|
|
112
|
+
_monitor_no_session_end_lifecycle
|
|
113
|
+
build_all_status
|
|
114
|
+
|
|
115
|
+
local previous
|
|
116
|
+
previous=$(tmux show-option -gv @ai_all_status 2>/dev/null || true)
|
|
117
|
+
if [ "$previous" != "$ALL" ]; then
|
|
118
|
+
tmux set-option -g @ai_all_status "$ALL" 2>/dev/null || return 1
|
|
119
|
+
tmux refresh-client -S 2>/dev/null || true
|
|
120
|
+
fi
|
|
121
|
+
return 0
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
case "$MODE" in
|
|
125
|
+
start)
|
|
126
|
+
server_pid=$(_tmux_server_pid)
|
|
127
|
+
[ -n "$server_pid" ] || exit 0
|
|
128
|
+
|
|
129
|
+
# 每次插件加载都发布新 generation。旧 monitor 看到 generation 变化后自行退出,
|
|
130
|
+
# 因而无需冒险 kill 一个可能已被系统复用的 PID,也不会长期存在重复 monitor。
|
|
131
|
+
generation="$(date +%s)-$$-${RANDOM:-0}"
|
|
132
|
+
tmux set-option -g @ai_monitor_generation "$generation" 2>/dev/null || exit 0
|
|
133
|
+
|
|
134
|
+
nohup "$0" run "$server_pid" "$generation" >/dev/null 2>&1 &
|
|
135
|
+
monitor_pid=$!
|
|
136
|
+
tmux set-option -g @ai_monitor_pid "$monitor_pid" 2>/dev/null || true
|
|
137
|
+
;;
|
|
138
|
+
|
|
139
|
+
run)
|
|
140
|
+
expected_server_pid="${2:-}"
|
|
141
|
+
generation="${3:-}"
|
|
142
|
+
[ -n "$expected_server_pid" ] && [ -n "$generation" ] || exit 1
|
|
143
|
+
|
|
144
|
+
# monitor 自身不处理 hook,仅复用现有状态聚合函数。
|
|
145
|
+
TOOL_ID="claude"
|
|
146
|
+
EVENT="_monitor"
|
|
147
|
+
source "${SCRIPT_DIR}/lib-tmux-ai-status.sh"
|
|
148
|
+
|
|
149
|
+
cleanup_monitor_option() {
|
|
150
|
+
local registered_pid
|
|
151
|
+
registered_pid=$(tmux show-option -gv @ai_monitor_pid 2>/dev/null || true)
|
|
152
|
+
[ "$registered_pid" = "$$" ] && tmux set-option -gu @ai_monitor_pid 2>/dev/null || true
|
|
153
|
+
}
|
|
154
|
+
stop_monitor() {
|
|
155
|
+
exit 0
|
|
156
|
+
}
|
|
157
|
+
trap cleanup_monitor_option EXIT
|
|
158
|
+
trap stop_monitor INT TERM
|
|
159
|
+
|
|
160
|
+
while :; do
|
|
161
|
+
current_server_pid=$(_tmux_server_pid)
|
|
162
|
+
[ "$current_server_pid" = "$expected_server_pid" ] || break
|
|
163
|
+
|
|
164
|
+
current_generation=$(tmux show-option -gv @ai_monitor_generation 2>/dev/null || true)
|
|
165
|
+
[ "$current_generation" = "$generation" ] || break
|
|
166
|
+
|
|
167
|
+
# 单次 tmux 查询失败不应永久杀死 monitor;server/generation 检查负责终止。
|
|
168
|
+
_monitor_tick || true
|
|
169
|
+
sleep "$MONITOR_INTERVAL"
|
|
170
|
+
done
|
|
171
|
+
;;
|
|
172
|
+
|
|
173
|
+
stop)
|
|
174
|
+
# 令当前 monitor 在下一 tick 自行退出。
|
|
175
|
+
tmux set-option -gu @ai_monitor_generation 2>/dev/null || true
|
|
176
|
+
;;
|
|
177
|
+
|
|
178
|
+
tick)
|
|
179
|
+
# 诊断/测试用的一次性刷新。
|
|
180
|
+
TOOL_ID="claude"
|
|
181
|
+
EVENT="_monitor"
|
|
182
|
+
source "${SCRIPT_DIR}/lib-tmux-ai-status.sh"
|
|
183
|
+
_monitor_tick
|
|
184
|
+
;;
|
|
185
|
+
|
|
186
|
+
*)
|
|
187
|
+
echo "Usage: $0 [start|run|stop|tick]" >&2
|
|
188
|
+
exit 2
|
|
189
|
+
;;
|
|
190
|
+
esac
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# AI CLI → tmux 状态栏 hook
|
|
3
|
+
# 用法: tmux-ai-status <TOOL_ID> <EVENT_TYPE>
|
|
4
|
+
# TOOL_ID: claude | codex | opencode
|
|
5
|
+
# stdin: adapter 归一化后的 JSON 数据
|
|
6
|
+
#
|
|
7
|
+
# 状态机: per-pane 状态 + PreToolUse/PostToolUse 配对集合
|
|
8
|
+
# 详见 lib-tmux-ai-status.sh 中 pending 集合管理注释
|
|
9
|
+
|
|
10
|
+
TOOL_ID="$1"
|
|
11
|
+
EVENT="$2"
|
|
12
|
+
|
|
13
|
+
INPUT=$(cat)
|
|
14
|
+
|
|
15
|
+
SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // ""' 2>/dev/null)
|
|
16
|
+
|
|
17
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
|
+
source "${SCRIPT_DIR}/lib-tmux-ai-status.sh"
|
|
19
|
+
|
|
20
|
+
resolve_tmux_pane
|
|
21
|
+
# 同 pane 的并发 hooks 必须完整串行化,否则一个事件可能用过期派生结果覆盖另一个
|
|
22
|
+
# PermissionRequest 已写入的审批态。
|
|
23
|
+
if [ -n "$TMUX_PANE" ]; then
|
|
24
|
+
_acquire_event_lock || exit 0
|
|
25
|
+
trap '_release_event_lock' EXIT
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
_save_pane_id
|
|
29
|
+
_maybe_cleanup_stale
|
|
30
|
+
|
|
31
|
+
_pane_loc=""
|
|
32
|
+
if [ -n "$TMUX_PANE" ]; then
|
|
33
|
+
_pane_loc=$(tmux display-message -pt "$TMUX_PANE" -p "#{session_name}:#{window_index}.#{pane_index}" 2>/dev/null)
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# 读取当前 pane 状态符号(用于 Stop 时判断是否已被前置事件置为 "-")
|
|
37
|
+
_current_pane_status() {
|
|
38
|
+
[ -z "$TMUX_PANE" ] && return
|
|
39
|
+
tmux display-message -pt "$TMUX_PANE" -p "#{@ai_pane_status}" 2>/dev/null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# 事件块日志变量(各 case 分支按需赋值,脚本末尾统一出块)
|
|
43
|
+
PREV_STATUS=$(_current_pane_status)
|
|
44
|
+
TOOL=""
|
|
45
|
+
TOOL_USE_ID=""
|
|
46
|
+
INPUT_DIGEST=""
|
|
47
|
+
|
|
48
|
+
case "$EVENT" in
|
|
49
|
+
Notification)
|
|
50
|
+
NOTIF_TYPE=$(echo "$INPUT" | jq -r '.notification_type // ""' 2>/dev/null)
|
|
51
|
+
MSG=$(echo "$INPUT" | jq -r '.message // ""' 2>/dev/null)
|
|
52
|
+
INPUT_DIGEST=$(echo "$INPUT" | jq -c '{notification_type, message}' 2>/dev/null)
|
|
53
|
+
case "$NOTIF_TYPE" in
|
|
54
|
+
idle_prompt)
|
|
55
|
+
STATUS="-"
|
|
56
|
+
;;
|
|
57
|
+
*)
|
|
58
|
+
case "$MSG" in
|
|
59
|
+
*"waiting for your input"*)
|
|
60
|
+
STATUS="-"
|
|
61
|
+
;;
|
|
62
|
+
*"denied"*|*"cancelled"*|*"canceled"*|*"rejected"*)
|
|
63
|
+
STATUS="-"
|
|
64
|
+
;;
|
|
65
|
+
*)
|
|
66
|
+
STATUS="$PREV_STATUS"
|
|
67
|
+
;;
|
|
68
|
+
esac
|
|
69
|
+
;;
|
|
70
|
+
esac
|
|
71
|
+
;;
|
|
72
|
+
PermissionRequest)
|
|
73
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
|
|
74
|
+
TOOL_USE_ID=$(echo "$INPUT" | jq -r '.tool_use_id // ""' 2>/dev/null)
|
|
75
|
+
INPUT_DIGEST=$(echo "$INPUT" | jq -c '.' 2>/dev/null)
|
|
76
|
+
_log_full_input=1
|
|
77
|
+
if [ "$TOOL" = "AskUserQuestion" ]; then
|
|
78
|
+
STATUS="?"
|
|
79
|
+
else
|
|
80
|
+
STATUS="!"
|
|
81
|
+
fi
|
|
82
|
+
# 铁律: PermissionRequest 立即写入,优先级最高。同时记入 pending-perm,
|
|
83
|
+
# 保证后续乱序到达的 PreToolUse/PostToolUse 派生时仍守住 !/?
|
|
84
|
+
[ -n "$TMUX_PANE" ] && tmux set-option -pt "$TMUX_PANE" @ai_pane_status "$STATUS" 2>/dev/null || true
|
|
85
|
+
if [ -n "$TOOL_USE_ID" ]; then
|
|
86
|
+
_add_pending_perm "$TOOL_USE_ID" "$STATUS" "$TOOL"
|
|
87
|
+
elif [ "${ADAPTER_HOLD_UNMATCHED_PERMISSION:-false}" = "true" ]; then
|
|
88
|
+
# Codex 的 PermissionRequest 无请求 ID。sentinel 不与任何 PostToolUse ID 配对,
|
|
89
|
+
# 因而保守保持审批态,直到 turn/session 边界统一清理。
|
|
90
|
+
_add_pending_perm "__unmatched_permission__" "$STATUS" "$TOOL"
|
|
91
|
+
fi
|
|
92
|
+
;;
|
|
93
|
+
PermissionResolved)
|
|
94
|
+
TOOL_USE_ID=$(echo "$INPUT" | jq -r '.request_id // ""' 2>/dev/null)
|
|
95
|
+
INPUT_DIGEST=$(echo "$INPUT" | jq -c '{request_id, reply}' 2>/dev/null)
|
|
96
|
+
_remove_pending_perm_id "$TOOL_USE_ID"
|
|
97
|
+
if _derive_status_from_pending; then
|
|
98
|
+
STATUS="$DERIVED"
|
|
99
|
+
else
|
|
100
|
+
STATUS=">"
|
|
101
|
+
fi
|
|
102
|
+
;;
|
|
103
|
+
PreToolUse)
|
|
104
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
|
|
105
|
+
TOOL_USE_ID=$(echo "$INPUT" | jq -r '.tool_use_id // ""' 2>/dev/null)
|
|
106
|
+
INPUT_DIGEST=$(echo "$INPUT" | jq -c '.tool_input // null' 2>/dev/null)
|
|
107
|
+
_add_pending_pre "$TOOL_USE_ID" "$TOOL"
|
|
108
|
+
# Claude 的 PermReq 不带 tool_use_id,派生看不到审批态;用 LIVE_STATUS 兜底。
|
|
109
|
+
# Codex sentinel 和 OpenCode request ID 则会直接从 pending 集合派生。
|
|
110
|
+
LIVE_STATUS=$(_current_pane_status)
|
|
111
|
+
case "$LIVE_STATUS" in
|
|
112
|
+
"!"|"?") STATUS="$LIVE_STATUS" ;;
|
|
113
|
+
*)
|
|
114
|
+
if _derive_status_from_pending; then
|
|
115
|
+
STATUS="$DERIVED"
|
|
116
|
+
else
|
|
117
|
+
STATUS=">"
|
|
118
|
+
fi
|
|
119
|
+
;;
|
|
120
|
+
esac
|
|
121
|
+
;;
|
|
122
|
+
PostToolUse|PostToolUseFailure)
|
|
123
|
+
TOOL=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
|
|
124
|
+
TOOL_USE_ID=$(echo "$INPUT" | jq -r '.tool_use_id // ""' 2>/dev/null)
|
|
125
|
+
_remove_pending_id "$TOOL_USE_ID"
|
|
126
|
+
# 只解除当前工具对应项;其他权限请求仍 pending 时必须保持 !/?。
|
|
127
|
+
if _derive_status_from_pending; then
|
|
128
|
+
STATUS="$DERIVED"
|
|
129
|
+
else
|
|
130
|
+
STATUS=">"
|
|
131
|
+
fi
|
|
132
|
+
;;
|
|
133
|
+
UserPromptSubmit)
|
|
134
|
+
_clear_pending
|
|
135
|
+
STATUS=">"
|
|
136
|
+
;;
|
|
137
|
+
Stop|StopFailure)
|
|
138
|
+
# 判定: pending-perm 还有项 = 用户未批准被打断 → "-"; 否则 "✓"
|
|
139
|
+
HAS_PERM=0
|
|
140
|
+
if [ -n "$TMUX_PANE" ]; then
|
|
141
|
+
PERM_FILE=$(_pending_perm_path)
|
|
142
|
+
[ -s "$PERM_FILE" ] && HAS_PERM=1
|
|
143
|
+
fi
|
|
144
|
+
_clear_pending
|
|
145
|
+
if [ "$HAS_PERM" = "1" ] || [ "$PREV_STATUS" = "!" ] || [ "$PREV_STATUS" = "?" ] || [ "$PREV_STATUS" = "-" ]; then
|
|
146
|
+
STATUS="-"
|
|
147
|
+
else
|
|
148
|
+
STATUS="✓"
|
|
149
|
+
fi
|
|
150
|
+
# fallback: TMUX_PANE 未解析时,精准清理孤儿状态(不碰其他活跃 pane)
|
|
151
|
+
if [ -n "$TMUX" ] && [ -z "$TMUX_PANE" ]; then
|
|
152
|
+
_cleanup_stale_panes
|
|
153
|
+
build_all_status
|
|
154
|
+
tmux set-option -g @ai_all_status "$ALL" 2>/dev/null || true
|
|
155
|
+
_ai_log "Stop FALLBACK: _cleanup_stale_panes ran"
|
|
156
|
+
fi
|
|
157
|
+
;;
|
|
158
|
+
SessionStart)
|
|
159
|
+
INPUT_DIGEST=$(echo "$INPUT" | jq -c '{source, model}' 2>/dev/null)
|
|
160
|
+
STATUS="-"
|
|
161
|
+
_clear_pending
|
|
162
|
+
if ! _check_hooks_integrity; then
|
|
163
|
+
"${ADAPTER_INSTALLER}" >/dev/null 2>&1
|
|
164
|
+
tmux display-message "⚠ ${TOOL_ID} hooks 已自动修复(检测到缺失)" 2>/dev/null || true
|
|
165
|
+
fi
|
|
166
|
+
;;
|
|
167
|
+
SessionEnd)
|
|
168
|
+
INPUT_DIGEST=$(echo "$INPUT" | jq -c '{reason}' 2>/dev/null)
|
|
169
|
+
if [ -z "$TMUX_PANE" ]; then
|
|
170
|
+
_load_pane_id
|
|
171
|
+
_ai_log "SessionEnd pane restored: TMUX_PANE=${TMUX_PANE:-?}"
|
|
172
|
+
fi
|
|
173
|
+
_clear_pending
|
|
174
|
+
_clear_pane_id
|
|
175
|
+
# /new 竞态保护:SessionEnd(async) 可能晚于 SessionStart(async) 执行,
|
|
176
|
+
# 此时不应清空状态。检查是否有新 session 已接管此 pane。
|
|
177
|
+
if _new_session_owns_pane; then
|
|
178
|
+
STATUS="-"
|
|
179
|
+
else
|
|
180
|
+
STATUS=""
|
|
181
|
+
fi
|
|
182
|
+
;;
|
|
183
|
+
_refresh)
|
|
184
|
+
_maybe_repair_hooks
|
|
185
|
+
_cleanup_stale_panes
|
|
186
|
+
build_all_status
|
|
187
|
+
tmux set-option -g @ai_all_status "$ALL" 2>/dev/null || true
|
|
188
|
+
# 诊断日志:记录 pane 状态快照(纯文本,去掉 tmux 格式码)
|
|
189
|
+
_refresh_snapshot=$(tmux list-panes -a -F "#{session_name}:#{window_index}.#{pane_index}=#{@ai_pane_status}|#{session_attached}" 2>/dev/null \
|
|
190
|
+
| awk -F'|' '$2+0>0' | cut -d'|' -f1 | tr '\n' ' ')
|
|
191
|
+
_ai_log "REFRESH: ${_refresh_snapshot:-<empty>}"
|
|
192
|
+
exit 0
|
|
193
|
+
;;
|
|
194
|
+
*)
|
|
195
|
+
exit 0
|
|
196
|
+
;;
|
|
197
|
+
esac
|
|
198
|
+
|
|
199
|
+
tmux set-option -g @ai_status "$STATUS" 2>/dev/null || true
|
|
200
|
+
|
|
201
|
+
# 构造事件块详情行:
|
|
202
|
+
# tool: <tool_name> id=<short tool_use_id>
|
|
203
|
+
# input: <精简 JSON,超 2000B 截断>
|
|
204
|
+
_log_tool_line=""
|
|
205
|
+
if [ -n "$TOOL" ] || [ -n "$TOOL_USE_ID" ]; then
|
|
206
|
+
_log_tool_line="tool: ${TOOL:-·} id=${TOOL_USE_ID:-·}"
|
|
207
|
+
fi
|
|
208
|
+
_log_input_line=""
|
|
209
|
+
if [ -n "$INPUT_DIGEST" ] && [ "$INPUT_DIGEST" != "null" ]; then
|
|
210
|
+
if [ "${_log_full_input:-0}" = "1" ]; then
|
|
211
|
+
_log_input_line="input: $INPUT_DIGEST"
|
|
212
|
+
else
|
|
213
|
+
_log_input_line="input: $(_ai_log_truncate_input "$INPUT_DIGEST")"
|
|
214
|
+
fi
|
|
215
|
+
fi
|
|
216
|
+
_ai_log_event "$PREV_STATUS" "$STATUS" "" "$_log_tool_line" "$_log_input_line"
|
|
217
|
+
|
|
218
|
+
if [ -n "$TMUX_PANE" ] && { [ -n "$TMUX" ] || command -v tmux &>/dev/null; }; then
|
|
219
|
+
# 写入前重新读取 pending,避免并发事件用过期结果覆盖仍有效的审批态。
|
|
220
|
+
case "$EVENT" in
|
|
221
|
+
PreToolUse)
|
|
222
|
+
if _derive_status_from_pending; then
|
|
223
|
+
STATUS="$DERIVED"
|
|
224
|
+
else
|
|
225
|
+
# Claude PermissionRequest 没有 ID,继续用 live 状态保护串行审批窗口。
|
|
226
|
+
_live_final=$(tmux display-message -pt "$TMUX_PANE" -p '#{@ai_pane_status}' 2>/dev/null)
|
|
227
|
+
case "$_live_final" in
|
|
228
|
+
"!"|"?") STATUS="$_live_final" ;;
|
|
229
|
+
esac
|
|
230
|
+
fi
|
|
231
|
+
;;
|
|
232
|
+
PostToolUse|PostToolUseFailure|PermissionResolved)
|
|
233
|
+
if _derive_status_from_pending; then
|
|
234
|
+
STATUS="$DERIVED"
|
|
235
|
+
fi
|
|
236
|
+
;;
|
|
237
|
+
esac
|
|
238
|
+
tmux set-option -pt "$TMUX_PANE" @ai_pane_status "$STATUS" 2>/dev/null || true
|
|
239
|
+
build_all_status
|
|
240
|
+
tmux set-option -g @ai_all_status "$ALL" 2>/dev/null || true
|
|
241
|
+
tmux refresh-client -S 2>/dev/null || true
|
|
242
|
+
fi
|
|
243
|
+
|
|
244
|
+
# Fallback: SessionEnd 仍无 TMUX_PANE 时,用 _cleanup_stale_panes 精准清理
|
|
245
|
+
# (只清除孤儿 > / ! / ?,保留 - 和 ✓)
|
|
246
|
+
if [ -z "$TMUX_PANE" ] && command -v tmux &>/dev/null; then
|
|
247
|
+
case "$EVENT" in
|
|
248
|
+
SessionEnd)
|
|
249
|
+
_cleanup_stale_panes
|
|
250
|
+
build_all_status
|
|
251
|
+
tmux set-option -g @ai_all_status "$ALL" 2>/dev/null || true
|
|
252
|
+
tmux refresh-client -S 2>/dev/null || true
|
|
253
|
+
_ai_log "SessionEnd FALLBACK: _cleanup_stale_panes ran"
|
|
254
|
+
;;
|
|
255
|
+
esac
|
|
256
|
+
fi
|
package/src/adapters-meta.js
CHANGED
|
@@ -2,64 +2,101 @@
|
|
|
2
2
|
//
|
|
3
3
|
// TUI 侧的工具清单(对应 scripts/adapters/*.sh)。新增 CLI 时在此加一条 + 加 bash adapter。
|
|
4
4
|
// bin/minVersion/hooksFile 用于侦测;installer 指向瘦 wrapper(真实逻辑在 bash adapter)。
|
|
5
|
+
//
|
|
6
|
+
// REPO_ROOT/SCRIPTS_DIR 不再是写死的相对路径,而是委托 source.js 的三级解析
|
|
7
|
+
// (env TMUXCLIHOOK_SOURCE > 持久化配置 > 包内自带 > 开发回落)。这些导出是
|
|
8
|
+
// ES module 的 live binding:resolveSource()/refreshSource() 重新赋值后,
|
|
9
|
+
// 所有 `import { REPO_ROOT } from './adapters-meta.js'` 的调用方会自动看到新值,
|
|
10
|
+
// 无需重启进程或改动各自的 import 语句。
|
|
5
11
|
|
|
6
|
-
import {
|
|
7
|
-
import { dirname, join, resolve } from 'node:path';
|
|
12
|
+
import { join } from 'node:path';
|
|
8
13
|
import { existsSync } from 'node:fs';
|
|
9
14
|
import { homedir } from 'node:os';
|
|
15
|
+
import { resolveSource } from './source.js';
|
|
10
16
|
|
|
11
|
-
const
|
|
17
|
+
const HOME = homedir();
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
19
|
+
function buildTools(scriptsDir) {
|
|
20
|
+
return [
|
|
21
|
+
{
|
|
22
|
+
id: 'claude',
|
|
23
|
+
label: 'Claude Code',
|
|
24
|
+
bin: 'claude',
|
|
25
|
+
versionArgs: ['--version'],
|
|
26
|
+
versionRe: /(\d+\.\d+\.\d+)/, // "2.1.210 (Claude Code)"
|
|
27
|
+
minVersion: null, // 无硬性最低要求
|
|
28
|
+
hooksFile: join(process.env.CLAUDE_CONFIG_DIR || join(HOME, '.claude'), 'settings.json'),
|
|
29
|
+
installer: join(scriptsDir, 'install-claude-hooks.sh'),
|
|
30
|
+
hookMarker: 'tmux-ai-status',
|
|
31
|
+
minHookCount: 10,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
id: 'codex',
|
|
35
|
+
label: 'Codex CLI',
|
|
36
|
+
bin: 'codex',
|
|
37
|
+
versionArgs: ['--version'],
|
|
38
|
+
versionRe: /(\d+\.\d+\.\d+)/, // "codex-cli 0.144.4"
|
|
39
|
+
minVersion: '0.144.0', // hooks.json + trust 起于 0.144
|
|
40
|
+
hooksFile: join(process.env.CODEX_HOME || join(HOME, '.codex'), 'hooks.json'),
|
|
41
|
+
installer: join(scriptsDir, 'install-codex-hooks.sh'),
|
|
42
|
+
hookMarker: 'tmux-ai-status codex',
|
|
43
|
+
minHookCount: 6,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: 'opencode',
|
|
47
|
+
label: 'opencode',
|
|
48
|
+
bin: 'opencode',
|
|
49
|
+
versionArgs: ['--version'],
|
|
50
|
+
versionRe: /(\d+\.\d+\.\d+)/,
|
|
51
|
+
minVersion: null, // plugin 系统本地文件跳过版本兼容检查
|
|
52
|
+
// hooksFile 实为 JS/TS plugin 文件(非 JSON hooks 配置),install/uninstall 整文件写入/删除
|
|
53
|
+
hooksFile: join(
|
|
54
|
+
process.env.OPENCODE_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(HOME, '.config'), 'opencode'),
|
|
55
|
+
'plugins',
|
|
56
|
+
'tmux-ai-status.ts'
|
|
57
|
+
),
|
|
58
|
+
installer: join(scriptsDir, 'install-opencode-hooks.sh'),
|
|
59
|
+
hookMarker: 'tmux-ai-status (managed by tmux-ai-hooks-status)',
|
|
60
|
+
minHookCount: 1,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
16
64
|
|
|
17
|
-
|
|
65
|
+
function computeSource() {
|
|
66
|
+
const s = resolveSource();
|
|
67
|
+
return {
|
|
68
|
+
repoRoot: s.repoRoot,
|
|
69
|
+
scriptsDir: s.scriptsDir,
|
|
70
|
+
tmuxEntry: s.tmuxEntry,
|
|
71
|
+
origin: s.origin,
|
|
72
|
+
warnings: s.warnings,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let _current = computeSource();
|
|
18
77
|
|
|
19
|
-
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
installer: join(SCRIPTS_DIR, 'install-codex-hooks.sh'),
|
|
42
|
-
hookMarker: 'tmux-ai-status codex',
|
|
43
|
-
minHookCount: 6,
|
|
44
|
-
},
|
|
45
|
-
{
|
|
46
|
-
id: 'opencode',
|
|
47
|
-
label: 'opencode',
|
|
48
|
-
bin: 'opencode',
|
|
49
|
-
versionArgs: ['--version'],
|
|
50
|
-
versionRe: /(\d+\.\d+\.\d+)/,
|
|
51
|
-
minVersion: null, // plugin 系统本地文件跳过版本兼容检查
|
|
52
|
-
// hooksFile 实为 JS/TS plugin 文件(非 JSON hooks 配置),install/uninstall 整文件写入/删除
|
|
53
|
-
hooksFile: join(
|
|
54
|
-
process.env.OPENCODE_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(HOME, '.config'), 'opencode'),
|
|
55
|
-
'plugins',
|
|
56
|
-
'tmux-ai-status.ts'
|
|
57
|
-
),
|
|
58
|
-
installer: join(SCRIPTS_DIR, 'install-opencode-hooks.sh'),
|
|
59
|
-
hookMarker: 'tmux-ai-status (managed by tmux-ai-hooks-status)',
|
|
60
|
-
minHookCount: 1,
|
|
61
|
-
},
|
|
62
|
-
];
|
|
78
|
+
export let REPO_ROOT = _current.repoRoot;
|
|
79
|
+
export let SCRIPTS_DIR = _current.scriptsDir;
|
|
80
|
+
export let TMUX_ENTRY = _current.tmuxEntry;
|
|
81
|
+
export let SOURCE_ORIGIN = _current.origin;
|
|
82
|
+
export let SOURCE_WARNINGS = _current.warnings;
|
|
83
|
+
export let TOOLS = buildTools(SCRIPTS_DIR);
|
|
84
|
+
export let repoExists = existsSync(SCRIPTS_DIR);
|
|
85
|
+
|
|
86
|
+
// 重新解析 source 并刷新以上所有导出(live binding,下游 import 自动看到新值)。
|
|
87
|
+
// 在 TUI 内「source 管理」菜单执行 set/clear 之后调用,使同一进程内立即生效,
|
|
88
|
+
// 不需要重启 TUI。
|
|
89
|
+
export function refreshSource() {
|
|
90
|
+
_current = computeSource();
|
|
91
|
+
REPO_ROOT = _current.repoRoot;
|
|
92
|
+
SCRIPTS_DIR = _current.scriptsDir;
|
|
93
|
+
TMUX_ENTRY = _current.tmuxEntry;
|
|
94
|
+
SOURCE_ORIGIN = _current.origin;
|
|
95
|
+
SOURCE_WARNINGS = _current.warnings;
|
|
96
|
+
TOOLS = buildTools(SCRIPTS_DIR);
|
|
97
|
+
repoExists = existsSync(SCRIPTS_DIR);
|
|
98
|
+
return _current;
|
|
99
|
+
}
|
|
63
100
|
|
|
64
101
|
export function toolById(id) {
|
|
65
102
|
return TOOLS.find((t) => t.id === id);
|
|
@@ -77,6 +114,6 @@ export function versionGte(a, b) {
|
|
|
77
114
|
return true;
|
|
78
115
|
}
|
|
79
116
|
|
|
80
|
-
// TPM
|
|
117
|
+
// TPM 软链路径(仓库目录名)。已不是主加载路径(改用 .tmux.conf 直接 run-shell
|
|
118
|
+
// 当前 source 的 tmux 入口),仅供「tmux 集成」检测/清理历史遗留软链时复用。
|
|
81
119
|
export const PLUGIN_LINK = join(HOME, '.tmux', 'plugins', 'tmux-ai-hooks-status');
|
|
82
|
-
export const repoExists = existsSync(SCRIPTS_DIR);
|
package/src/purge.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
// purge.js — 完整卸载编排:monitor stop + 清聚合状态 +
|
|
1
|
+
// purge.js — 完整卸载编排:monitor stop + 清聚合状态 + 清理历史遗留软链
|
|
2
|
+
// (不删仓库/不动 .tmux.conf/不 kill-server)
|
|
3
|
+
//
|
|
4
|
+
// 主加载路径已改为 .tmux.conf 的 run-shell(见 tmuxconf.js),不再依赖软链;
|
|
5
|
+
// removeSymlinks() 仅用于清理旧版本遗留在 ~/.tmux/plugins/ 下的软链
|
|
6
|
+
// (canonical 名 + 历史遗留的 tmux-claude-hooks-status 旧名)。
|
|
7
|
+
// 移除 .tmux.conf 里的声明属破坏性操作,与 kill-server 一样只在 TUI 打印手动指引。
|
|
2
8
|
import { execa } from 'execa';
|
|
3
9
|
import { rmSync, lstatSync } from 'node:fs';
|
|
4
10
|
import { join, dirname } from 'node:path';
|