tmuxclihook 0.5.2 → 0.5.4

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 CHANGED
@@ -79,7 +79,14 @@ clack 0.7 只把 ctrl+c 映射为取消(Esc 默认无效果),安装器在
79
79
 
80
80
  ### cc-switch 模式(Claude Code)
81
81
 
82
- 检测到 `~/.cc-switch/cc-switch.db` 时,Claude Code hooks 的装/卸/修/状态检查全部指向其通用配置(db `settings` 表 `common_config_claude`)——`switch` 命令启动 claude 时会把通用配置合并进实例配置,`~/.claude/settings.json` 与实例文件都会被 cc-switch 覆盖,直接写它们不持久。写入用与 cc-switch 自身一致的 `json_patch` 原子更新,他人 hook 与配置其他字段保留。注意:cc-switch GUI 运行中会把内存缓存写回 db,安装前建议先退出 GUI(脚本会警告);不经 `switch` 直接运行的 `claude` 读不到 db 里的 hooks。
82
+ 检测到 `~/.cc-switch/cc-switch.db` 时,Claude Code hooks 的装/卸/修/状态检查改走 cc-switch 的共享配置。真源位置随 cc-switch 版本自动切换(判定规则与 cc-switch CLI 的 `detectSharedConfigMode` 一致,`CC_SWITCH_SHARED_SOURCE=live|legacy` 可显式覆盖):
83
+
84
+ | cc-switch 版本 | 判定 | hooks 真源 / 写入目标 |
85
+ |---|---|---|
86
+ | 4.0.2+(live) | `PRAGMA user_version >= 20` | `~/.claude/settings.json` |
87
+ | 旧版(legacy) | `user_version < 20` 或读不到 | db `settings` 表 `common_config_claude` |
88
+
89
+ live 模式以 `~/.claude/settings.json` 为共享配置真源(cc-switch 4.0.2 起不再读库里的 `common_config_claude`),写入用与文件模式相同的原子合并,他人 hook 与配置其他字段保留;legacy 模式用与 cc-switch 自身一致的 `json_patch` 更新 db。注意:cc-switch GUI 运行中会把内存缓存写回真源,安装前建议先退出 GUI(脚本会警告);legacy 模式下不经 `switch` 直接运行的 `claude` 读不到 db 里的 hooks。
83
90
 
84
91
  ## Source 管理(生产默认 / 本地调试切换)
85
92
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmuxclihook",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "交互式 TUI 安装器:环境侦测、AI CLI 侦测、tmux-ai-status hooks 装/卸/修、TPM 软链校验",
5
5
  "type": "module",
6
6
  "bin": {
@@ -119,28 +119,54 @@ _claude_count_registered() { # $1=输入 JSON 文件 → stdout 事件数(jq
119
119
  }
120
120
 
121
121
  # ─── cc-switch 通用配置支持 ───
122
- # `switch` 命令启动 claude 时把 ~/.cc-switch/cc-switch.db 的通用配置(settings 表
123
- # common_config_claude,JSON)深合并进实例 settings.json(provider meta 均
124
- # commonConfigEnabled=true)。~/.claude/settings.json 与实例文件都会被 cc-switch
125
- # 覆盖,通用配置是 hooks 的唯一持久真源。检测到 cc-switch 时装/卸/完整性只操作
126
- # 该库;sqlite3 缺失等一切门禁不过 → 整体回退传统文件模式。
122
+ # `switch` 命令启动 claude 时把「共享配置」深合并进实例 settings.json
123
+ # (provider meta 均 commonConfigEnabled=true)。~/.claude/settings.json 与实例
124
+ # 文件都会被 cc-switch 覆盖,共享配置才是 hooks 的唯一持久真源。
125
+ #
126
+ # 共享配置的真源位置随 cc-switch 版本变化(规则与 cc-switch CLI 的
127
+ # detectSharedConfigMode 保持一致):
128
+ # - legacy(< 4.0.2 / db PRAGMA user_version < 20):
129
+ # 真源 = db settings 表的 common_config_claude(JSON)
130
+ # - live(4.0.2+ / user_version >= 20):
131
+ # 真源 = ~/.claude/settings.json —— 4.0.2 起 cc-switch 不再读库里的
132
+ # common_config_claude(仅作迁移快照),所以新机器上写库等于写进死信。
133
+ # - CC_SWITCH_SHARED_SOURCE=live|legacy 可显式覆盖(cc-switch 同款逃生阀)
134
+ # 门禁不过(db/表/sqlite3 缺失)→ none,整体回退传统文件模式。
127
135
 
128
136
  _ccswitch_db() { printf '%s\n' "${HOME}/.cc-switch/cc-switch.db"; }
129
137
 
130
- # 门禁:db 存在 && sqlite3 可用 && settings 表可查。不要求 key 存在
131
- # (从未编辑过通用配置的用户由 install 的 upsert 建行;若此处要求 key,
132
- # 会静默回退文件模式 → 实例永不含 hooks + 自修复空转)。
133
- _ccswitch_active() {
134
- local db
138
+ # live 模式真源:cc-switch 硬编码 $HOME/.claude/settings.json,
139
+ # 不随 CLAUDE_CONFIG_DIR 变化(后者指向的是 switch 实例目录)。
140
+ _ccswitch_live_file() { printf '%s\n' "${HOME}/.claude/settings.json"; }
141
+
142
+ # 输出 live|legacy|none;恒返回 0(调用方无需容忍失败分支)。
143
+ _ccswitch_mode() {
144
+ local db override version
135
145
  db="$(_ccswitch_db)"
136
- [ -f "$db" ] || return 1
137
- command -v sqlite3 >/dev/null 2>&1 || return 1
138
- sqlite3 -readonly "$db" \
146
+ if [ ! -f "$db" ] || ! command -v sqlite3 >/dev/null 2>&1; then
147
+ printf 'none\n'
148
+ return 0
149
+ fi
150
+ if ! sqlite3 -readonly "$db" \
139
151
  "SELECT 1 FROM sqlite_master WHERE type='table' AND name='settings'" 2>/dev/null \
140
- | grep -q 1
152
+ | grep -q 1; then
153
+ printf 'none\n'
154
+ return 0
155
+ fi
156
+ override=$(printf '%s' "${CC_SWITCH_SHARED_SOURCE:-}" | tr '[:upper:]' '[:lower:]')
157
+ case "$override" in
158
+ live|legacy) printf '%s\n' "$override"; return 0 ;;
159
+ esac
160
+ # 读不到版本(空/非数字)按 legacy——与 cc-switch CLI 的 null → legacy 一致
161
+ version=$(sqlite3 -readonly "$db" "PRAGMA user_version" 2>/dev/null | tr -cd '0-9')
162
+ if [ -n "$version" ] && [ "$version" -ge 20 ]; then
163
+ printf 'live\n'
164
+ else
165
+ printf 'legacy\n'
166
+ fi
141
167
  }
142
168
 
143
- # cc-switch GUI 运行中会把内存缓存的通用配置全量写回 db(实测发生过),警告不阻塞。
169
+ # cc-switch GUI 运行中会把内存缓存的共享配置全量写回真源(实测发生过),警告不阻塞。
144
170
  _ccswitch_warn_gui() {
145
171
  if pgrep -ix "cc-switch" >/dev/null 2>&1 \
146
172
  || pgrep -f "cc-switch.app/Contents/MacOS" >/dev/null 2>&1; then
@@ -190,13 +216,22 @@ ON CONFLICT(key) DO UPDATE SET value = json_patch(settings.value, json($(_sqlquo
190
216
  # 存活提取内联在本函数(jq + test),不依赖 lib-install-hooks.sh——本函数同时被
191
217
  # 事件路径(lib-tmux-ai-status.sh)与 install wrapper(lib-install-hooks.sh)两个上下文调用。
192
218
  adapter_check_integrity() {
193
- local src registered ev p ok_events=""
194
- if _ccswitch_active; then
195
- _ccswitch_dump_common src || return 1
196
- else
197
- [ -f "$ADAPTER_HOOKS_FILE" ] || return 1
198
- src="$ADAPTER_HOOKS_FILE"
199
- fi
219
+ local mode src="" tmp="" registered ev p ok_events=""
220
+ mode=$(_ccswitch_mode)
221
+ case "$mode" in
222
+ live)
223
+ src=$(_ccswitch_live_file)
224
+ [ -f "$src" ] || return 1
225
+ ;;
226
+ legacy)
227
+ _ccswitch_dump_common tmp || return 1
228
+ src="$tmp"
229
+ ;;
230
+ *)
231
+ [ -f "$ADAPTER_HOOKS_FILE" ] || return 1
232
+ src="$ADAPTER_HOOKS_FILE"
233
+ ;;
234
+ esac
200
235
  registered=$(_claude_count_registered "$src")
201
236
  if [ "${registered:-0}" -ge 10 ]; then
202
237
  while IFS=$'\t' read -r ev p; do
@@ -212,9 +247,7 @@ adapter_check_integrity() {
212
247
  select(($scripts | length) > 0) |
213
248
  $scripts[] | "\($e)\t\(.)"' "$src" 2>/dev/null)
214
249
  fi
215
- if [ "$src" != "$ADAPTER_HOOKS_FILE" ]; then
216
- rm -f "$src"
217
- fi
250
+ [ -z "$tmp" ] || rm -f "$tmp"
218
251
  [ "${registered:-0}" -ge 10 ] || return 1
219
252
  for ev in SessionStart SessionEnd UserPromptSubmit PreToolUse PostToolUse PostToolUseFailure PermissionRequest Notification Stop StopFailure; do
220
253
  case ",$ok_events," in *",$ev,"*) ;; *) return 1 ;; esac
@@ -229,20 +262,38 @@ adapter_check_integrity() {
229
262
  # status 判定仍由 integrity 本体独立给出,两者永不发散。
230
263
  # 任何失败(jq 失败/配置缺失)输出空 events 的降级片段,不 exit 非 0。
231
264
  adapter_check_detail() {
232
- local mode="file" src="" pairs rows line ev p a
233
- if _ccswitch_active; then
234
- mode="ccswitch"
235
- if ! _ccswitch_dump_common src; then
236
- printf '{"mode":"ccswitch","events":{},"plugin":null,"registeredPaths":[]}\n'
237
- return 0
238
- fi
239
- elif [ -f "$ADAPTER_HOOKS_FILE" ]; then
240
- src="$ADAPTER_HOOKS_FILE"
241
- fi
265
+ local source_mode src="" tmp="" pairs rows line ev p a
266
+ source_mode=$(_ccswitch_mode)
267
+ case "$source_mode" in
268
+ live)
269
+ src=$(_ccswitch_live_file)
270
+ [ -f "$src" ] || src=""
271
+ ;;
272
+ legacy)
273
+ if ! _ccswitch_dump_common tmp; then
274
+ printf '{"mode":"ccswitch","events":{},"plugin":null,"registeredPaths":[]}\n'
275
+ return 0
276
+ fi
277
+ src="$tmp"
278
+ ;;
279
+ *)
280
+ if [ -f "$ADAPTER_HOOKS_FILE" ]; then src="$ADAPTER_HOOKS_FILE"; fi
281
+ ;;
282
+ esac
242
283
  if [ -z "$src" ]; then
243
- printf '{"mode":"%s","events":{},"plugin":null,"registeredPaths":[]}\n' "$mode"
284
+ case "$source_mode" in
285
+ live) source_mode="ccswitch-live" ;;
286
+ legacy) source_mode="ccswitch" ;;
287
+ *) source_mode="file" ;;
288
+ esac
289
+ printf '{"mode":"%s","events":{},"plugin":null,"registeredPaths":[]}\n' "$source_mode"
244
290
  return 0
245
291
  fi
292
+ case "$source_mode" in
293
+ live) source_mode="ccswitch-live" ;;
294
+ legacy) source_mode="ccswitch" ;;
295
+ *) source_mode="file" ;;
296
+ esac
246
297
  # TSV:event\tscript(不去重、保留零事件——计数与去重交给下游 jq 聚合)
247
298
  pairs=$(jq -r '.hooks as $h |
248
299
  ["SessionStart","SessionEnd","UserPromptSubmit","PreToolUse","PostToolUse",
@@ -250,9 +301,7 @@ adapter_check_detail() {
250
301
  $events[] as $e |
251
302
  $h[$e][]?.hooks[]?.command // empty |
252
303
  select(contains("tmux-ai-status")) | split(" ")[0] | "\($e)\t\(.)"' "$src" 2>/dev/null)
253
- if [ "$src" != "$ADAPTER_HOOKS_FILE" ]; then
254
- rm -f "$src"
255
- fi
304
+ [ -z "$tmp" ] || rm -f "$tmp"
256
305
  # TSV:event\tscript\talive(0|1)。bash 3.2 无关联数组,逐行展开后由 jq 一次聚合。
257
306
  rows=""
258
307
  while IFS= read -r line; do
@@ -264,7 +313,7 @@ adapter_check_detail() {
264
313
  done <<EOF
265
314
  $pairs
266
315
  EOF
267
- jq -R -s --arg mode "$mode" --arg events "$_CLAUDE_EVENTS" '
316
+ jq -R -s --arg mode "$source_mode" --arg events "$_CLAUDE_EVENTS" '
268
317
  (reduce ($events | split(" ")[]) as $e
269
318
  ({}; .[$e] = {registered: 0, alive: 0, scriptPaths: []})) as $base
270
319
  | reduce (split("\n") | .[] | select(length > 0) | split("\t")) as $r (
@@ -279,68 +328,90 @@ EOF
279
328
  ' <<<"$rows"
280
329
  }
281
330
 
282
- # 安装:合并式更新,保留他人(masko 等)注册的 hook。cc-switch 模式只写库
283
- # (~/.claude/settings.json 与实例文件都会被 cc-switch 覆盖,写了也不持久)。
331
+ # 安装:合并式更新,保留他人(masko 等)注册的 hook。
332
+ # legacy 模式只写库(~/.claude/settings.json 与实例文件都会被旧版 cc-switch 覆盖);
333
+ # live 模式(4.0.2+)写 ~/.claude/settings.json —— 新版 cc-switch 以它为共享真源,
334
+ # 实例 settings 在 switch/漂移收敛时从该文件重新派生。
284
335
  adapter_install_hooks() {
285
336
  _install_require_jq
286
- if _ccswitch_active; then
287
- local tmp merged hooks_patch full_doc
288
- _ccswitch_warn_gui
289
- _ccswitch_dump_common tmp || return 1
290
- merged=$(_claude_apply_merge "$tmp")
291
- rm -f "$tmp"
292
- hooks_patch=$(jq -c '{hooks: (.hooks // {})}' <<<"$merged")
293
- full_doc=$(jq -c . <<<"$merged")
294
- _ccswitch_upsert_hooks "$hooks_patch" "$full_doc" || return 1
295
- echo "Claude hooks installed to cc-switch common config: $(_ccswitch_db)"
296
- echo "Events: $_CLAUDE_EVENTS"
297
- return 0
298
- fi
299
- [ -f "$ADAPTER_HOOKS_FILE" ] || _install_atomic_write '{}' "$ADAPTER_HOOKS_FILE"
300
- local updated
301
- updated=$(_claude_apply_merge "$ADAPTER_HOOKS_FILE")
302
- _install_atomic_write "$updated" "$ADAPTER_HOOKS_FILE"
303
- echo "Claude hooks installed to $ADAPTER_HOOKS_FILE"
337
+ local mode target updated
338
+ mode=$(_ccswitch_mode)
339
+ case "$mode" in
340
+ legacy)
341
+ local tmp merged hooks_patch full_doc
342
+ _ccswitch_warn_gui
343
+ _ccswitch_dump_common tmp || return 1
344
+ merged=$(_claude_apply_merge "$tmp")
345
+ rm -f "$tmp"
346
+ hooks_patch=$(jq -c '{hooks: (.hooks // {})}' <<<"$merged")
347
+ full_doc=$(jq -c . <<<"$merged")
348
+ _ccswitch_upsert_hooks "$hooks_patch" "$full_doc" || return 1
349
+ echo "Claude hooks installed to cc-switch common config (legacy): $(_ccswitch_db)"
350
+ echo "Events: $_CLAUDE_EVENTS"
351
+ return 0
352
+ ;;
353
+ live)
354
+ _ccswitch_warn_gui
355
+ target=$(_ccswitch_live_file)
356
+ ;;
357
+ *)
358
+ target="$ADAPTER_HOOKS_FILE"
359
+ ;;
360
+ esac
361
+ [ -f "$target" ] || _install_atomic_write '{}' "$target"
362
+ updated=$(_claude_apply_merge "$target")
363
+ _install_atomic_write "$updated" "$target"
364
+ echo "Claude hooks installed to $target"
304
365
  echo "Events: $_CLAUDE_EVENTS"
305
366
  }
306
367
 
307
368
  # 卸载:单次 jq pipeline 移除所有指向本插件的 hooks,保留他人条目。
308
- # cc-switch 模式:剥离前后对照生成 patch(被清空的事件键置 null = RFC 7396 删键),
369
+ # legacy 模式:剥离前后对照生成 patch(被清空的事件键置 null = RFC 7396 删键),
309
370
  # 顶层其他字段(model/env/…)由 json_patch 天然保留。
371
+ # live 模式:与文件模式同一套原子写(真源就是 ~/.claude/settings.json)。
310
372
  adapter_uninstall_hooks() {
311
373
  _install_require_jq
312
- if _ccswitch_active; then
313
- local tmp stripped cur_hooks final_hooks patch managed top_patch
314
- _ccswitch_warn_gui
315
- _ccswitch_dump_common tmp || return 1
316
- cur_hooks=$(jq -c '.hooks // {}' "$tmp")
317
- # 已无本插件条目 → no-op(否则会把保留的他人 hook 重写一遍)
318
- managed=$(jq '[to_entries[] | select(.value | any(.hooks[]?.command | contains("tmux-ai-status")))] | length' <<<"$cur_hooks")
319
- if [ "${managed:-0}" -eq 0 ]; then
374
+ local mode target updated
375
+ mode=$(_ccswitch_mode)
376
+ case "$mode" in
377
+ legacy)
378
+ local tmp stripped cur_hooks final_hooks patch managed top_patch
379
+ _ccswitch_warn_gui
380
+ _ccswitch_dump_common tmp || return 1
381
+ cur_hooks=$(jq -c '.hooks // {}' "$tmp")
382
+ # 已无本插件条目 → no-op(否则会把保留的他人 hook 重写一遍)
383
+ managed=$(jq '[to_entries[] | select(.value | any(.hooks[]?.command | contains("tmux-ai-status")))] | length' <<<"$cur_hooks")
384
+ if [ "${managed:-0}" -eq 0 ]; then
385
+ rm -f "$tmp"
386
+ echo "Claude hooks: nothing to uninstall (cc-switch common config)"
387
+ return 0
388
+ fi
389
+ stripped=$(_claude_apply_strip "$tmp")
390
+ final_hooks=$(jq -c '.hooks // {}' <<<"$stripped")
320
391
  rm -f "$tmp"
321
- echo "Claude hooks: nothing to uninstall (cc-switch common config)"
322
- return 0
323
- fi
324
- stripped=$(_claude_apply_strip "$tmp")
325
- final_hooks=$(jq -c '.hooks // {}' <<<"$stripped")
326
- rm -f "$tmp"
327
- patch=$(jq -cn --argjson cur "$cur_hooks" --argjson fin "$final_hooks" '
328
- reduce ($cur | keys[]) as $k ({};
329
- if ($fin | has($k)) then .[$k] = $fin[$k] else .[$k] = null end)')
330
- if [ "$patch" = "{}" ]; then
331
- echo "Claude hooks: nothing to uninstall (cc-switch common config)"
392
+ patch=$(jq -cn --argjson cur "$cur_hooks" --argjson fin "$final_hooks" '
393
+ reduce ($cur | keys[]) as $k ({};
394
+ if ($fin | has($k)) then .[$k] = $fin[$k] else .[$k] = null end)')
395
+ if [ "$patch" = "{}" ]; then
396
+ echo "Claude hooks: nothing to uninstall (cc-switch common config)"
397
+ return 0
398
+ fi
399
+ # patch 是事件级键,需包一层 {hooks: …} 才是顶层 patch(json_patch 只动 hooks)
400
+ top_patch=$(jq -cn --argjson p "$patch" '{hooks: $p}')
401
+ _ccswitch_upsert_hooks "$top_patch" "$top_patch" || return 1
402
+ echo "Claude hooks uninstalled from cc-switch common config (legacy)"
332
403
  return 0
333
- fi
334
- # patch 是事件级键,需包一层 {hooks: …} 才是顶层 patch(json_patch 只动 hooks)
335
- local top_patch
336
- top_patch=$(jq -cn --argjson p "$patch" '{hooks: $p}')
337
- _ccswitch_upsert_hooks "$top_patch" "$top_patch" || return 1
338
- echo "Claude hooks uninstalled from cc-switch common config"
339
- return 0
340
- fi
341
- [ -f "$ADAPTER_HOOKS_FILE" ] || { echo "Claude hooks: nothing to uninstall"; return 0; }
342
- local updated
343
- updated=$(_claude_apply_strip "$ADAPTER_HOOKS_FILE")
344
- _install_atomic_write "$updated" "$ADAPTER_HOOKS_FILE"
345
- echo "Claude hooks uninstalled from $ADAPTER_HOOKS_FILE"
404
+ ;;
405
+ live)
406
+ _ccswitch_warn_gui
407
+ target=$(_ccswitch_live_file)
408
+ ;;
409
+ *)
410
+ target="$ADAPTER_HOOKS_FILE"
411
+ ;;
412
+ esac
413
+ [ -f "$target" ] || { echo "Claude hooks: nothing to uninstall"; return 0; }
414
+ updated=$(_claude_apply_strip "$target")
415
+ _install_atomic_write "$updated" "$target"
416
+ echo "Claude hooks uninstalled from $target"
346
417
  }
@@ -168,13 +168,20 @@ _provider_tag_colour() {
168
168
  build_all_status() {
169
169
  ALL=""
170
170
  cur_sess=""
171
- local _ai_names
171
+ local _ai_names _fmt _n
172
172
  _ai_names=$(_collect_ai_process_names)
173
- while IFS='|' read -r pane_id session_name win_idx pane_idx claude_status _attached _created _entered cur_cmd prov_name prov_id; do
173
+ # 动态追加上 monitor 的 seen 标记(无 SessionEnd 工具的进程树探测结果)。
174
+ # switch/volta-shim 启动的 pane 前台命令不是 codex,必须靠该标记补 idle `-`。
175
+ _fmt="#{pane_id}|#{session_name}|#{window_index}|#{pane_index}|#{@ai_pane_status}|#{session_attached}|#{session_created}|#{@ai_entered_time}|#{pane_current_command}|#{@ai_provider}|#{@ai_provider_id}"
176
+ for _n in $_ai_names; do
177
+ _fmt="$_fmt|#{@ai_monitor_seen_$_n}"
178
+ done
179
+ while IFS='|' read -r pane_id session_name win_idx pane_idx claude_status _attached _created _entered cur_cmd prov_name prov_id _seen_flags; do
174
180
  local pane_status="$claude_status"
175
181
  if [ -z "$pane_status" ]; then
176
182
  # 无显式状态:若前台是 AI 进程(codex 首个 turn 前空窗期),补 idle。
177
183
  # 进程名集合从所有 adapter 汇总,前缀匹配(codex-aarch64-* 等)。
184
+ # 前台命令是 switch/volta-shim 时改为读 monitor 的进程树标记。
178
185
  local _cmd_base _n _is_ai=0
179
186
  _cmd_base=$(basename "$cur_cmd" 2>/dev/null)
180
187
  for _n in $_ai_names; do
@@ -182,6 +189,9 @@ build_all_status() {
182
189
  "$_n"|"$_n"-*|"$_n"_*) _is_ai=1; break ;;
183
190
  esac
184
191
  done
192
+ if [ "$_is_ai" = "0" ]; then
193
+ case "$_seen_flags" in *1*) _is_ai=1 ;; esac
194
+ fi
185
195
  [ "$_is_ai" = "1" ] && pane_status="-" || continue
186
196
  fi
187
197
  local session_block="#[bg=#6272A4,fg=#F8F8F2] ${session_name} #[bg=default,fg=default]"
@@ -212,7 +222,7 @@ build_all_status() {
212
222
  # 自指 switch 由 handler 的 per-client prev 对比滤除)
213
223
  # ② session_created(无进入记录时回退创建序)。不用 session_last_attached——
214
224
  # tmux 的 server_client_set_session 无条件刷新该时间戳(自指 switch 也算),污染排序。
215
- done < <(tmux list-panes -a -F "#{pane_id}|#{session_name}|#{window_index}|#{pane_index}|#{@ai_pane_status}|#{session_attached}|#{session_created}|#{@ai_entered_time}|#{pane_current_command}|#{@ai_provider}|#{@ai_provider_id}" 2>/dev/null | awk -F'|' -v OFS='|' '$6>0{if($8=="")$8=$7; print}' | sort -t'|' -k8,8n -k7,7n -k2,2 -k3,3n -k4,4n)
225
+ done < <(tmux list-panes -a -F "$_fmt" 2>/dev/null | awk -F'|' -v OFS='|' '$6>0{if($8=="")$8=$7; print}' | sort -t'|' -k8,8n -k7,7n -k2,2 -k3,3n -k4,4n)
216
226
  }
217
227
 
218
228
  # --- Pending 集合管理(PreToolUse / PermissionRequest 配对)---
@@ -2,9 +2,11 @@
2
2
  # tmux-ai-monitor — tmux server 级单例监控器
3
3
  #
4
4
  # 部分 AI CLI(codex、opencode)没有 SessionEnd hook,退出无法主动清空状态;codex 的
5
- # SessionStart 还会延迟到首个 turn。本监控器每秒根据 pane_current_command / 进程树刷新:
5
+ # SessionStart 还会延迟到首个 turn。本监控器每秒刷新:
6
6
  # - 前台启动此类工具、尚无 hook 状态时,显示 idle `-`
7
- # - 退出返回 shell 后,清除残留 pane 状态
7
+ # - cc-switch 的 `switch` 启动时前台命令是 volta-shim/node 而非工具名,改为
8
+ # (每 N 个 tick)探测 launcher pane 的进程树来识别,并写 seen 标记供聚合补 `-`
9
+ # - 退出返回 shell(或进程树已无该工具)后,清除残留 pane 状态
8
10
  # 泛化实现:扫描 adapters/*.sh 找出 ADAPTER_HAS_SESSION_END="false" 的工具及其
9
11
  # ADAPTER_PROCESS_NAMES,逐个工具做生命周期检测——新增同类工具的 adapter 无需改本文件。
10
12
  # `/new`(codex)等同工具内会话切换不改变前台进程,因此不会改写已有 `✓`。
@@ -15,6 +17,18 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
17
  _ADAPTER_DIR="${SCRIPT_DIR}/adapters"
16
18
  MODE="${1:-start}"
17
19
  MONITOR_INTERVAL="${TMUX_AI_MONITOR_INTERVAL:-1}"
20
+ # 进程树探测节流:launcher pane 每 N 个 tick 做一次 BFS(run 循环内 _TICK 持续累加)。
21
+ _MONITOR_PROBE_EVERY=5
22
+ _MONITOR_TICK=0
23
+
24
+ # 常见 launcher:switch/volta/npm 系脚本在前台时,目标 CLI 位于其子进程树中,
25
+ # pane_current_command 不会直接等于 codex/opencode(cc-switch 的 switch 启动即如此)。
26
+ _is_launcher_command() {
27
+ case "$(basename "${1:-}" 2>/dev/null)" in
28
+ switch|volta|volta-shim|node|npx|pnpx|pnpm|yarn|bun|bunx|deno) return 0 ;;
29
+ esac
30
+ return 1
31
+ }
18
32
 
19
33
  _tmux_server_pid() {
20
34
  tmux display-message -p '#{pid}' 2>/dev/null
@@ -86,15 +100,16 @@ _pane_has_tool_process() {
86
100
  return 1
87
101
  }
88
102
 
89
- # 对每个无 SessionEnd 的工具名做生命周期检测:前台命令匹配 → 标记见过;
90
- # 曾标记但前台已切走且进程树也无该工具 → 清空 pane 状态(该工具已退出)。
91
- # seen 标记按工具名区分(@ai_monitor_seen_<name>),避免多工具互相踩踏清理语义。
103
+ # 对每个无 SessionEnd 的工具名做生命周期检测:前台命令匹配或 launcher 进程树命中
104
+ # → 标记见过;曾标记但前台已切走且进程树也无该工具 → 清空 pane 状态(该工具已退出)。
105
+ # seen 标记按工具名区分(@ai_monitor_seen_<name>),避免多工具互相踩踏清理语义;
106
+ # 该标记同时是 build_all_status 给 launcher pane 补 idle `-` 的依据。
92
107
  _monitor_no_session_end_lifecycle() {
93
108
  local tools name pane_id current_command sanitized pane_dir
94
109
  tools=$(_collect_no_session_end_tools)
95
110
  [ -n "$tools" ] || return 0
96
111
  for name in $tools; do
97
- while IFS='|' read -r pane_id current_command seen; do
112
+ while IFS='|' read -r pane_id current_command seen attached; do
98
113
  if [ -n "$current_command" ] && _is_tool_command "$(basename "$current_command" 2>/dev/null)" "$name"; then
99
114
  [ "$seen" = "1" ] || tmux set-option -pt "$pane_id" "@ai_monitor_seen_${name}" 1 2>/dev/null || true
100
115
  elif [ "$seen" = "1" ] && ! _pane_has_tool_process "$pane_id" "$name"; then
@@ -103,8 +118,17 @@ _monitor_no_session_end_lifecycle() {
103
118
  sanitized="${pane_id//[^a-zA-Z0-9]/_}"
104
119
  pane_dir="${_STATUS_DIR}/${sanitized}"
105
120
  rm -rf "$pane_dir" 2>/dev/null
121
+ elif [ "$seen" != "1" ] \
122
+ && [ "${attached:-0}" -gt 0 ] \
123
+ && [ $((_MONITOR_TICK % _MONITOR_PROBE_EVERY)) -eq 0 ] \
124
+ && _is_launcher_command "$current_command" \
125
+ && _pane_has_tool_process "$pane_id" "$name"; then
126
+ # launcher(switch/volta-shim/node)启动:前台命令不是工具名,
127
+ # 但进程树里有。标记 seen 后 build_all_status 才会补 idle `-`,
128
+ # 工具退出时也才会走上面的清理分支(否则残留状态永不清)。
129
+ tmux set-option -pt "$pane_id" "@ai_monitor_seen_${name}" 1 2>/dev/null || true
106
130
  fi
107
- done < <(tmux list-panes -a -F "#{pane_id}|#{pane_current_command}|#{@ai_monitor_seen_${name}}" 2>/dev/null)
131
+ done < <(tmux list-panes -a -F "#{pane_id}|#{pane_current_command}|#{@ai_monitor_seen_${name}}|#{session_attached}" 2>/dev/null)
108
132
  done
109
133
  }
110
134
 
@@ -118,6 +142,7 @@ _monitor_tick() {
118
142
  tmux set-option -g @ai_all_status "$ALL" 2>/dev/null || return 1
119
143
  tmux refresh-client -S 2>/dev/null || true
120
144
  fi
145
+ _MONITOR_TICK=$((_MONITOR_TICK + 1))
121
146
  return 0
122
147
  }
123
148
 
@@ -29,8 +29,9 @@ function buildTools(scriptsDir) {
29
29
  installer: join(scriptsDir, 'install-claude-hooks.sh'),
30
30
  hookMarker: 'tmux-ai-status',
31
31
  minHookCount: 10,
32
- // cc-switch 存在时 hooks 真源在其 db(通用配置),文件判定失效 → 由 bash 侧
33
- // wrapper check 判定(内部走 sqlite);非 cc-switch 环境仍走本地文件计数。
32
+ // cc-switch 存在时 hooks 真源随版本变化(4.0.2+ live = ~/.claude/settings.json,
33
+ // 旧版 legacy = db 通用配置),文件判定失效 → 由 bash 侧 wrapper check 判定
34
+ // (内部按 db user_version 选真源);非 cc-switch 环境仍走本地文件计数。
34
35
  checkViaWrapper: true,
35
36
  ccSwitchDb: join(HOME, '.cc-switch', 'cc-switch.db'),
36
37
  },
@@ -11,7 +11,8 @@ const WARN = '⚠';
11
11
 
12
12
  const MODE_LABEL = {
13
13
  file: '配置文件',
14
- ccswitch: 'cc-switch db(通用配置)',
14
+ 'ccswitch-live': 'cc-switch 4.0+(~/.claude/settings.json)',
15
+ ccswitch: 'cc-switch 旧版 db(通用配置)',
15
16
  plugin: 'opencode plugin 文件',
16
17
  unknown: '未知',
17
18
  };
package/src/detect.js CHANGED
@@ -82,7 +82,8 @@ export async function checkEnv({ probeCommand = probe } = {}) {
82
82
  pkg: 'node',
83
83
  }));
84
84
 
85
- // sqlite3:仅 cc-switch 环境需要(hooks 真源在其 db 通用配置,装/卸/检查走 sqlite)
85
+ // sqlite3:cc-switch 环境用于读 db PRAGMA user_version 判定 live/legacy 真源;
86
+ // legacy 模式(真源在 db 通用配置)的装/卸/检查还依赖它写入。
86
87
  const ccSwitchDb = join(homedir(), '.cc-switch', 'cc-switch.db');
87
88
  if (existsSync(ccSwitchDb)) {
88
89
  const sqlite3 = await probeCommand('sqlite3', ['--version'], /(\d+\.\d+)/);