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.
@@ -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
@@ -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 { fileURLToPath } from 'node:url';
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 __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const HOME = homedir();
12
18
 
13
- // installer/ 的上一级即仓库根
14
- export const REPO_ROOT = resolve(__dirname, '..', '..');
15
- export const SCRIPTS_DIR = join(REPO_ROOT, 'scripts');
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
- const HOME = homedir();
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
- // 工具元数据表。version 解析用正则从 `<bin> --version` 输出提取。
20
- export const TOOLS = [
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(SCRIPTS_DIR, '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(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 + 清聚合状态 + 删软链(不删仓库/不动 conf/不 kill-server)
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';