@nathan33/nao-skill 0.13.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,22 +3,31 @@
3
3
  # nao-fleet.sh — 按角色一键拉起 pi 会话窗口(nao 团队工具箱)
4
4
  #
5
5
  # 用法
6
- # nao-fleet.sh check [--strict] [-v] 静态体检:roles.yaml/缩进/EOL/角色卡/交叉引用/PR 模板/qq-notify/白名单/布局
6
+ # nao-fleet.sh check [--strict] [-v] 静态体检:roles.yaml/缩进/EOL/角色卡/交叉引用/PR 模板/白名单/布局
7
7
  # 默认单行摘要(含 warn 计数);-v 展开完整报告;失败始终展开
8
8
  # nao-fleet.sh status 角色会话在线状态(按终端标题/名册判定;权威名单见 intercom list)
9
9
  # nao-fleet.sh ensure <别名>[@<repo>] [更多...] 拉起角色窗口(默认工作区=roles.yaml workspace)
10
10
  # nao-fleet.sh ensure -m <model> <别名>... 显式指定模型(须命中白名单)
11
11
  # nao-fleet.sh ensure --task <编号> <别名>[@<repo>] 任务派生会话:--name <别名>-<编号>(并行隔离,避免同名冲突)
12
12
  # nao-fleet.sh ensure --force <别名>... 忽略"已在运行"判重
13
+ # nao-fleet.sh ensure --background <别名>... 无图形会话时显式允许后台 detached(否则 exit 2)
13
14
  # nao-fleet.sh close <别名|派生会话名> [--task <编号>] [--force]
14
15
  # 回收已完成会话(派生名如 rd-be-T1;跨仓定位;定位失败/名册报在线但无句柄 ⇒ 非 0 + 诊断)
15
- # (闸门:在跑 turn / tasks-state 未推进 → 拒绝,--force 跳过)
16
+ # (闸门:在跑 turn / tasks-state 未推进 → 拒绝,--force 跳过;
17
+ # 常驻缺席而其派生在场 ⇒ 目标歧义拒绝,T516 AC3)
18
+ # nao-fleet.sh cleanup -S <socket> 终结实验场 tmux server(**强制**显式 -S socket)
19
+ # 不带 -S / 命中生产默认 socket ⇒ exit 2 + 单行拒绝(真 server 不受影响)
20
+ # ⚠ 生产默认 socket 判定 = $TMUX 句柄 + /tmp/tmux-<uid>/default(+$TMPDIR 变体),
21
+ # **与 TMUX_TMPDIR 无关**(T516 事故:夹具设 TMUX_TMPDIR 曾令真默认 socket 被放行而连坐自灭)
22
+ # ⚠ 实验纪律:一律 `env -u TMUX` + `-S <隔离 socket>`(T515 血训:
23
+ # tmux 客户端在 $TMUX 已设时无视 TMUX_TMPDIR,裸 tmux/kill-server 会连坐真 server);
24
+ # 验证脚本勿在承载自己会话的 tmux 内执行(清 TMUX_PANE 会令 F1 自 pane 闸门失效)
16
25
  #
17
26
  # 角色别名 → 角色卡:见 .agents/roles.yaml(单一事实来源)
18
27
  # 当前:pm / arch-designer(arch) / rd-fe / rd-be / qa / rd-infra(infra)
19
28
  #
20
29
  # 环境变量
21
- # NAO_TERMINAL=ghostty|ptyxis|tmux|screen 强制宿主
30
+ # NAO_TERMINAL=tmux|foot|ghostty|ptyxis|screen 强制宿主(非法/命令缺失 ⇒ exit 2)
22
31
  # NAO_TMUX_LAYOUT=main-row2|main-col|grid tmux 布局(默认 main-row2)
23
32
  # NAO_TMUX_MAIN_WIDTH=<10..90> 主 pane 宽度百分比(默认 35;main-row2/main-col 共用)
24
33
  # NAO_TMUX_MIN_PANE_WIDTH=<10..80> 最小非主 pane 列宽守卫(默认 30;低于则回退)
@@ -38,6 +47,11 @@
38
47
  # pi-intercom 官方 cli.ts(无 tmuxPane),再不可用回退 tmux/screen 会话名;
39
48
  # 仍无法确认「不在线」时 close 不做静默 no-op(exit 非 0)。
40
49
  #
50
+ # 宿主阶梯(非 tmux 用户;ADR 2026-10-12-non-tmux-host-spawn)
51
+ # ① NAO_TERMINAL 显式 → ② $TMUX 内分屏 → ③ tmux+图形:tmux 会话 + 可见窗口 attach
52
+ # → ④ 图形无 tmux:xdg-terminal-exec --app-id=org.nao.<角色> --dir=<repo> 直开窗
53
+ # → ⑤ 无图形:仅 --background 时 detached tmux/screen → ⑥ 否则 exit 2 + 一行 DEGRADED 诊断
54
+ #
41
55
  # tmux 宿主行为
42
56
  # - 已在 tmux 内($TMUX 存在):当前窗口分屏拉起,不新建窗口。
43
57
  # - 不在 tmux 内:创建 detached 会话 nao-<角色>,需 tmux attach -t nao-<角色>。
@@ -78,10 +92,20 @@ VERBOSE=false
78
92
  CLOSE_BUSY_PATTERN="${NAO_CLOSE_BUSY_PATTERN:-Working \(|Thinking\.\.\.|to interrupt|to cancel|Retrying \(|Compacting|Summarizing branch}"
79
93
  # 任务状态文件(相对当前目录;供残留检测与回收闸门)
80
94
  TASKS_STATE="${NAO_TASKS_STATE:-docs/tasks-state.md}"
95
+ # T513-S2 F2:窗口/分离宿主派生前清 TMUX/TMUX_PANE —— 防子进程继承调用者的 pane 句柄,
96
+ # 导致 pi-intercom 按调用者 pane 错注册名册、close 反噬调用者(见 ADR 不变量)。
97
+ # ⚠️ 只用于「非 tmux 窗口/screen」宿主:tmux 自身的 split-window/new-session/attach
98
+ # 会为新 pane 注入正确的 TMUX_PANE(实测 -L 隔离 server:外层 TMUX_PANE=%FAKE 被覆盖为 %0),
99
+ # 对这些路径前缀剥离反而会抹掉正确句柄。
100
+ TMUX_ENV_SCRUB="env -u TMUX -u TMUX_PANE"
81
101
 
82
102
  log() { printf '\033[1;32m[fleet]\033[0m %s\n' "$*"; }
83
103
  warn() { printf '\033[1;33m[fleet]\033[0m %s\n' "$*" >&2; }
84
104
  die() { printf '\033[1;31m[fleet]\033[0m %s\n' "$*" >&2; exit 1; }
105
+ # 环境不足/宿主不可运行统一出口:exit 2,单行 `DEGRADED: <诊断>;恢复:<命令>`(对齐 0.12.0 D5 口径)
106
+ die_env() { printf 'DEGRADED: %s;恢复:%s\n' "$1" "$2" >&2; exit 2; }
107
+ # 宿主存在但动作失败(拉起未成):exit 1(BR3);不清零/不声称已回收(回收结果由各派生函数自报)
108
+ die_spawn() { printf '\033[1;31m[fleet]\033[0m %s\n' "$*" >&2; exit 1; }
85
109
 
86
110
  # ---------------------------------------------------------------------------
87
111
  # roles.yaml 解析(严格子集:顶层 roles:,2 空格角色条目 / 4 空格字段,禁 Tab)
@@ -240,50 +264,6 @@ check_alias_resolution() {
240
264
  return $rc
241
265
  }
242
266
 
243
- # qq-notify 主动推送器契约:存在 + 可执行 + EOL 全 LF + 无 Tab + node --check 语法
244
- check_qq_notify() {
245
- local rc=0 f="$SKILLS_DIR/.agents/scripts/qq-notify"
246
- if [[ ! -f "$f" ]]; then
247
- printf ' ✗ 缺失: .agents/scripts/qq-notify\n'
248
- return 1
249
- fi
250
- printf ' ✓ 存在: .agents/scripts/qq-notify\n'
251
- if [[ -x "$f" ]]; then
252
- printf ' ✓ 可执行位已设置\n'
253
- else
254
- printf ' ✗ 缺少可执行位(修复:chmod +x .agents/scripts/qq-notify)\n'; rc=1
255
- fi
256
- if grep -Iq $'\r' "$f"; then
257
- printf ' ✗ 含 CR(须转 LF;CRLF 会破坏 shebang/管道)\n'; rc=1
258
- else
259
- printf ' ✓ 行尾 LF\n'
260
- fi
261
- if grep -Iq $'\t' "$f"; then
262
- printf ' ✗ 含 Tab(禁止 Tab 缩进)\n'; rc=1
263
- else
264
- printf ' ✓ 无 Tab\n'
265
- fi
266
- # shell 转交守卫:禁止 `bash/sh <本脚本>` 逐行解释注释(防误执行示例/误发)
267
- local l1 l2
268
- l1="$(head -n 1 "$f")"
269
- l2="$(sed -n '2p' "$f")"
270
- if [[ "$l1" == '#!/bin/sh' ]] && [[ "$l2" == *'exec node "$0" "$@"'* ]]; then
271
- printf ' ✓ shell 转交守卫(#!/bin/sh + exec node)\n'
272
- else
273
- printf ' ✗ 缺少 shell 转交守卫(第 1 行须 #!/bin/sh、第 2 行须含 exec node "$0" "$@")\n'; rc=1
274
- fi
275
- if command -v node >/dev/null 2>&1; then
276
- if node --check "$f" >/dev/null 2>&1; then
277
- printf ' ✓ node --check 语法通过\n'
278
- else
279
- printf ' ✗ node --check 语法失败\n'; rc=1
280
- fi
281
- else
282
- printf ' ! node 未安装(跳过语法校验)\n'
283
- fi
284
- return $rc
285
- }
286
-
287
267
  # CodeGraph 索引健康(ensure 拉起前 / check 用;缺失或过期仅 warn,不阻塞)
288
268
  check_codegraph() {
289
269
  local repo="$1" name st
@@ -313,13 +293,21 @@ resolve_role() {
313
293
  PROMPT="${ROLE_CARDS[$id]}"
314
294
  }
315
295
 
316
- detect_host() {
317
- if [[ -n "${NAO_TERMINAL:-}" ]]; then echo "$NAO_TERMINAL"; return; fi
318
- if [[ -n "${TMUX:-}" ]] && command -v tmux >/dev/null 2>&1; then echo tmux; return; fi
319
- for h in ghostty ptyxis tmux; do
320
- command -v "$h" >/dev/null 2>&1 && { echo "$h"; return; }
321
- done
322
- echo screen
296
+ # 宿主阶梯(ADR 实施闸门 · T513b/c)——由「枚举模拟器」改为「环境查询 + 显式覆盖」:
297
+ # ① NAO_TERMINAL 显式(档值 ∈ {tmux,foot,ghostty,ptyxis,screen};命令缺失/非法 ⇒ exit 2)
298
+ # ② $TMUX 存在 ⇒ 当前窗口 split-window(逐值零回归)
299
+ # ③ 非 tmux + tmux 可用 + 图形 ⇒ new-session -d + xdg-terminal-exec 开窗 attach(默认 socket)
300
+ # ④ 无 tmux + 图形 ⇒ xdg-terminal-exec --app-id=org.nao.<角色> --dir=<repo> 直开窗
301
+ # ⑤ 无图形 + 显式 --background ⇒ detached tmux/screen
302
+ # ⑥ 否则 exit 2 + 单行 DEGRADED 诊断 + 可复制恢复命令
303
+ NAO_TERMINAL_ALLOWED=(tmux foot ghostty ptyxis screen)
304
+ NAO_TERMINAL_LIST='tmux|foot|ghostty|ptyxis|screen' # 文案用(与 ALLOWED 同步)
305
+
306
+ # 图形会话探针(ADR 门禁):有显示变量且 `xdg-terminal-exec --print-id` 成功
307
+ xdg_launcher_ok() {
308
+ command -v xdg-terminal-exec >/dev/null 2>&1 || return 1
309
+ [[ -n "${WAYLAND_DISPLAY:-}${DISPLAY:-}" ]] || return 1
310
+ xdg-terminal-exec --print-id >/dev/null 2>&1
323
311
  }
324
312
 
325
313
  # ---------------------------------------------------------------------------
@@ -481,8 +469,12 @@ pi_panes() {
481
469
  | awk -F'\t' '{ c=$2; sub(/^.*\//,"",c); if (c=="pi") print $1 "\t" $3 }' || true
482
470
  }
483
471
 
484
- # 兜底:本仓 pi pane 中「未被其它会话认领」且唯一者
485
- # 认领来源 = 标题契约命中的 pane + 名册 tmuxPane(二者任一即视为已识别)
472
+ # 兜底:本仓 pi pane 中「未被其它会话认领」且**跨全部角色唯一**者。
473
+ # ⚠ 层③ 误配根因(T513f):新 pane 尚未被标题/名册认领时会被误判给别的角色
474
+ # (实测 ensure qa rd-infra ⇒ rd-infra 被 qa 的新 pane 命中而误判「已在运行」跳过)。
475
+ # 收紧(不猜):① 本仓未认领 pi pane 必须**跨全部角色唯一**(>1 ⇒ warn + 不返回);
476
+ # ② 还须叠加「名册 cwd 匹配」(目标会话确实登记在本仓)才可返回。
477
+ # 层①标题契约 / 层②名册 tmuxPane 语义不变(T512-① 属独立课题)。
486
478
  fallback_pane_for() {
487
479
  local name="$1" repo="$2" abs="" cp j v
488
480
  command -v tmux >/dev/null 2>&1 || return 0
@@ -504,13 +496,15 @@ fallback_pane_for() {
504
496
  [[ -n "${claimed[$pid]:-}" ]] && continue
505
497
  cand+=("$pid")
506
498
  done < <(pi_panes)
507
- if (( ${#cand[@]} == 1 )); then
508
- printf '%s\n' "${cand[0]}"; return 0
509
- fi
499
+ # ① 跨全部角色唯一(歧义不猜)
510
500
  if (( ${#cand[@]} > 1 )); then
511
- warn "标题被改写且名册无 pane 记录:$abs 内有多个未认领 pi pane(${cand[*]})→ 无法唯一确定 $name"
501
+ warn "标题被改写且名册无 pane 记录:$abs 内有多个未认领 pi pane(${cand[*]})→ 无法唯一确定 $name(不认领)"
502
+ return 0
512
503
  fi
513
- return 0
504
+ (( ${#cand[@]} == 0 )) && return 0
505
+ # ② 名册 cwd 匹配(目标会话须确实登记在本仓),否则绝不认领本仓 pane
506
+ intercom_online "$name" "$abs" || return 0
507
+ printf '%s\n' "${cand[0]}"; return 0
514
508
  }
515
509
 
516
510
  find_pane_for() {
@@ -543,6 +537,8 @@ pi_pids_for() {
543
537
  local pane root pid
544
538
  pane="$(find_pane_for "$1" "${2:-}")"
545
539
  [[ -n "$pane" ]] || return 0
540
+ # F1 纵深(T513-S2):绝不据「调用者自己的 pane」反查 pid,否则会 kill 到自身进程。
541
+ [[ -n "${TMUX_PANE:-}" && "$pane" == "$TMUX_PANE" ]] && return 0
546
542
  root="$(tmux display-message -p -t "$pane" '#{pane_pid}' 2>/dev/null)"
547
543
  [[ -n "$root" ]] || return 0
548
544
  { printf '%s\n' "$root"; pgrep -P "$root" 2>/dev/null || true; } | while IFS= read -r pid; do
@@ -551,12 +547,86 @@ pi_pids_for() {
551
547
  done
552
548
  }
553
549
 
550
+ # ---- ④ 路径句柄(app-id/class;BR7/AC8)-------------------------------------
551
+ # 窗口 client pid:Hyprland `hyprctl clients -j` 主路;sway 树后备(仅存在性探测;NFR4/NFR5)
552
+ app_client_pid() { # <name> → echo 窗口进程 pid(无则空)
553
+ local name="$1" cls="org.nao.$name" json=""
554
+ if command -v hyprctl >/dev/null 2>&1; then
555
+ json="$(hyprctl clients -j 2>/dev/null || true)"
556
+ elif command -v swaymsg >/dev/null 2>&1; then
557
+ json="$(swaymsg -t get_tree 2>/dev/null || true)"
558
+ fi
559
+ [[ -n "$json" ]] || return 0
560
+ printf '%s' "$json" | node -e '
561
+ let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{
562
+ try{const a=JSON.parse(s);const arr=Array.isArray(a)?a:[a];
563
+ const walk=x=>{for(const n of (x||[])){const c=n.class||n.initialClass||n.app_id;
564
+ if(c===process.argv[1]&&n.pid!=null){process.stdout.write(String(n.pid));return;}
565
+ walk(n.nodes);walk(n.floating_nodes);}};
566
+ walk(arr);}catch(e){}
567
+ });' "$cls" 2>/dev/null || true
568
+ return 0
569
+ }
570
+
571
+ # 深度优先找 comm=pi 的后代(窗口进程往往是终端,pi 在其下)
572
+ pi_descendant_of() { # <root pid> → echo 首个 pi 后代 pid
573
+ local root="$1" c r
574
+ for c in $(pgrep -P "$root" 2>/dev/null); do
575
+ [[ "$(ps -o comm= -p "$c" 2>/dev/null | tr -d ' ')" == "pi" ]] && { printf '%s\n' "$c"; return 0; }
576
+ r="$(pi_descendant_of "$c")"
577
+ [[ -n "$r" ]] && { printf '%s\n' "$r"; return 0; }
578
+ done
579
+ return 0
580
+ }
581
+
582
+ # app-id/class 句柄 → 校验 comm=pi 后的 pi pid(BR7:取 pid 后校验 comm=pi)
583
+ app_pid_for() { # <name> → echo pi pid(无句柄/不可校验则空)
584
+ local name="$1" pid d
585
+ pid="$(app_client_pid "$name")"
586
+ [[ -n "$pid" ]] || return 0
587
+ if [[ "$(ps -o comm= -p "$pid" 2>/dev/null | tr -d ' ')" == "pi" ]]; then printf '%s\n' "$pid"; return 0; fi
588
+ d="$(pi_descendant_of "$pid")"
589
+ [[ -n "$d" ]] && printf '%s\n' "$d"
590
+ return 0
591
+ }
592
+
593
+ # ④ 路径 class 探针存在性由 app_class_visible() 返回码 2 表达(无独立 helper,避免死代码)
594
+
595
+ # 派生会话在场检测(T516 AC3):visible_session_names 中形如 <name>-<非空后缀> 者。
596
+ # 用于 close <常驻名> 时判目标歧义:常驻精确句柄缺席而其派生在场 ⇒ 拒绝 + 指路(禁静默谎报「未运行」)。
597
+ derived_in_field() { # <name> → 派生名列表(每行一个;空 = 无)
598
+ local n
599
+ while IFS= read -r n; do
600
+ [[ "$n" == "$1"-?* ]] && printf '%s\n' "$n"
601
+ done < <(visible_session_names)
602
+ }
603
+
604
+ # screen 会话精确令牌(T516 F1/F2 · arch 评审):从 `screen -ls` 取**精确** nao-<名> 会话的 `pid.name` 令牌。
605
+ # 与 running()/close 检测同一锚定(名字后必须是空白或行尾;`\b` 在派生名的 "-" 处仍成立,故不用)。
606
+ # 检测与销毁共用本 helper ⇒ 检测锚与销毁目标不会漂移;**取不到令牌即空,调用方不得 quit**(宁不回收,也不误伤派生)。
607
+ screen_token_for() { # <name> → echo pid.name(无精确匹配则空;恒 rc 0)
608
+ local name="$1"
609
+ command -v screen >/dev/null 2>&1 || return 0
610
+ # 左锚:`[0-9]+\.nao-` 必须紧接 pid 点(弃 `[^[:space:]]*` —— 旧式允许 `pid.Xnao-<名>` 型非目标会话被当精确令牌,
611
+ # QA 波4 (e) 红:误杀非目标 + 幻影成功 + 误报在线)。右锚 `([[:space:]]|$)` 同时排除派生 `nao-<名>-<任务>`。
612
+ screen -ls 2>/dev/null \
613
+ | grep -oE '[0-9]+\.nao-'"$name"'([[:space:]]|$)' \
614
+ | sed -E 's/[[:space:]]+$//' \
615
+ | head -1 || true
616
+ return 0
617
+ }
618
+ screen_present() { # <name> → 0 = 精确 screen 会话在场
619
+ [[ -n "$(screen_token_for "$1")" ]]
620
+ }
621
+
554
622
  running() {
555
623
  local name="$1" repo="${2:-}"
556
624
  if intercom_online "$name" "$repo"; then return 0; fi
557
625
  if [[ -n "$(find_pane_for "$name" "$repo")" ]]; then return 0; fi
558
- if command -v tmux >/dev/null 2>&1 && tmux has-session -t "nao-$name" 2>/dev/null; then return 0; fi
559
- if command -v screen >/dev/null 2>&1 && screen -ls 2>/dev/null | grep -q "[0-9]\.nao-$name\b"; then return 0; fi
626
+ if command -v tmux >/dev/null 2>&1 && tmux has-session -t "=nao-$name" 2>/dev/null; then return 0; fi
627
+ # T516 #1(=锚):tmux 会话名唯一前缀匹配曾误命中派生 nao-<名>-<任务>(T515 V4 实证)⇒ 一律 =nao-<名>;
628
+ # screen 同理:锚定收敛到 screen_present()(同一 helper 服务检测与销毁,见 screen_token_for)。
629
+ if command -v screen >/dev/null 2>&1 && screen_present "$name"; then return 0; fi
560
630
  return 1
561
631
  }
562
632
 
@@ -802,7 +872,9 @@ apply_tmux_layout() {
802
872
  fi
803
873
  # main-row2
804
874
  local win_w win_h
805
- read -r win_w win_h < <(tmux display-message -p '#{window_width} #{window_height}' 2>/dev/null)
875
+ # D7(T513-S3):display-message 无输出时 `read` 返回 1,set -e 会硬中断整轮 ensure
876
+ # ⇒ 守卫为 true(读不到尺寸即跳过布局,不硬崩)。
877
+ read -r win_w win_h < <(tmux display-message -p '#{window_width} #{window_height}' 2>/dev/null) || true
806
878
  [[ -n "$win_w" && -n "$win_h" ]] || return 0
807
879
 
808
880
  local -a pane_ids=()
@@ -883,27 +955,262 @@ build_system_prompt() {
883
955
  echo "$f"
884
956
  }
885
957
 
958
+ # ---- 派生 pid 传递(T513-S5 · M2 设计修复)--------------------------------
959
+ # 父进程建「匿名管道」(在 mktemp -d 内 mkfifo,打开 fd 后立即 rm -rf 该目录 ⇒ 仅 fd 存活)
960
+ # → 子进程经**继承 fd** 写自身 pid → 父进程读。子进程**不打开任何路径**,因此
961
+ # 「父先清理、子后写重建」的竞态与 /tmp 残留从设计上不可能(无文件系统对象可残留);
962
+ # 失败时父仍能拿到 pid ⇒ M1 回收能力保留。
963
+ # ⚠ 管道以 O_RDWR 打开(exec {fd}<>):父读端提前关闭时,子进程写不会 SIGPIPE(实测)。
964
+ # ⚠ 实测 foot 等终端保留继承 fd,故 ④ 路径的 M1 仍可拿到 pid。
965
+ PID_PIPE_FD=""
966
+ open_pid_pipe() {
967
+ local d
968
+ d="$(mktemp -d "/tmp/nao-fleet-pidpipe-XXXXXX")" || return 1
969
+ mkfifo "$d/p" 2>/dev/null || { rm -rf -- "$d"; return 1; }
970
+ # 不得在 exec 的 fd 分配上加 `2>/dev/null`:exec 重定向会**持久**作用于当前 shell(会静默后续所有 warn/xtrace)。
971
+ if ! exec {PID_PIPE_FD}<>"$d/p"; then rm -rf -- "$d"; return 1; fi
972
+ rm -rf -- "$d" # 立即 unlink:路径消失,仅 fd 存活
973
+ return 0
974
+ }
975
+ close_pid_pipe() {
976
+ if [[ -n "${PID_PIPE_FD:-}" ]]; then exec {PID_PIPE_FD}>&- || true; fi
977
+ PID_PIPE_FD=""
978
+ return 0
979
+ }
980
+ # 读子进程 pid(有界轮询;echo pid / 超时 rc=1)
981
+ wait_for_pid_pipe() { # [tries]
982
+ local tries="${1:-30}" pid=""
983
+ [[ -n "${PID_PIPE_FD:-}" ]] || return 1
984
+ while (( tries-- > 0 )); do
985
+ if IFS= read -r -t 0.1 -u "$PID_PIPE_FD" pid 2>/dev/null && [[ -n "$pid" ]]; then
986
+ printf '%s' "$pid"; return 0
987
+ fi
988
+ done
989
+ return 1
990
+ }
991
+ # 子进程包装命令:把自身 $$ 写入继承 fd(写成后 exec 目标 ⇒ pid 不变,即最终 pi/宿主 pid)
992
+ pid_pipe_wrapper() { printf 'echo $$ >&%s; exec "$@"' "$PID_PIPE_FD"; }
993
+
994
+ # 非 tmux 窗口宿主(ghostty/ptyxis/foot)派生:P4 根因 = `setsid -f <缺失命令>` 仍 rc=0,
995
+ # 故不能以 setsid 的 rc 当成功 —— 必须验证子进程 pid 存活(BR2)。
996
+ # pid 经继承管道回传(M2:无 pidfile ⇒ 无残留);失败即回收 pid(不产生孤儿窗口,AC6)。
997
+ spawn_fork_window() { # <name> <cmd...>
998
+ local name="$1"; shift
999
+ local pid=""
1000
+ open_pid_pipe || return 1
1001
+ if ! setsid -f bash -c "$(pid_pipe_wrapper)" _ "$@" >/dev/null 2>&1; then
1002
+ close_pid_pipe; return 1
1003
+ fi
1004
+ pid="$(wait_for_pid_pipe)" || pid=""
1005
+ close_pid_pipe
1006
+ if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then return 0; fi
1007
+ [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true
1008
+ return 1
1009
+ }
1010
+
1011
+ # screen 宿主派生:以「会话存在」为「已拉起」前置;未出现即回收会话(AC6)。
1012
+ spawn_screen() { # <name> <inner>
1013
+ local name="$1" inner="$2" tries=30 stok
1014
+ screen -dmS "nao-$name" $TMUX_ENV_SCRUB bash -lc "$inner" >/dev/null 2>&1 || return 1
1015
+ while (( tries-- > 0 )); do
1016
+ if screen_present "$name"; then return 0; fi
1017
+ sleep 0.1
1018
+ done
1019
+ # F1(arch 评审):失败清理**只 quit 锚定令牌**指向的会话;取不到令牌即不 quit(不对 nao-<名> 前缀下手)。
1020
+ stok="$(screen_token_for "$name")"
1021
+ [[ -n "$stok" ]] && screen -S "$stok" -X quit >/dev/null 2>&1 || true
1022
+ return 1
1023
+ }
1024
+
1025
+ # ---- 阶梯③/④ 原子件:可见窗口 + 限时句柄校验 -----------------------------
1026
+ # tmux 会话是否有客户端 attach(ADR:会话在、窗口无 ⇒ 降级④;防隐形会话)
1027
+ wait_for_tmux_client() { # <sess> [tries]
1028
+ local sess="$1" tries="${2:-50}" out
1029
+ while (( tries-- > 0 )); do
1030
+ out="$(tmux list-clients -t "=$sess" 2>/dev/null || true)"
1031
+ [[ -n "$out" ]] && return 0
1032
+ sleep 0.1
1033
+ done
1034
+ return 1
1035
+ }
1036
+
1037
+ # 开可见窗口(只启动、不校验):xdg-terminal-exec;--app-id 含角色名,--dir 为 repo
1038
+ # 默认 socket(无 -L);setsid -f 的 rc 不作成功依据,由调用方做句柄前置校验(BR2)。
1039
+ open_xdg_window() { # <name> <repo> <cmd...>
1040
+ local name="$1" repo="$2"; shift 2
1041
+ command -v xdg-terminal-exec >/dev/null 2>&1 || return 1
1042
+ setsid -f xdg-terminal-exec --app-id="org.nao.$name" --dir="$repo" -- "$@" >/dev/null 2>&1 || return 1
1043
+ return 0
1044
+ }
1045
+
1046
+ # 窗口 class 可见性(④ 句柄):Hyprland 主路;其它合成器/X11 仅存在性探测(NFR4;覆盖限制 NFR5)
1047
+ # 返回 0=可见 / 1=不可见 / 2=无查询器(不可校验)
1048
+ app_class_visible() { # <name>
1049
+ local name="$1" cls="org.nao.$name" out
1050
+ if command -v hyprctl >/dev/null 2>&1; then
1051
+ out="$(hyprctl clients -j 2>/dev/null || true)"
1052
+ [[ "$out" == *"$cls"* ]] && return 0
1053
+ return 1
1054
+ fi
1055
+ if command -v swaymsg >/dev/null 2>&1; then
1056
+ out="$(swaymsg -t get_tree 2>/dev/null || true)"
1057
+ [[ "$out" == *"$cls"* ]] && return 0
1058
+ return 1
1059
+ fi
1060
+ if command -v xdotool >/dev/null 2>&1; then
1061
+ xdotool search --class "$cls" >/dev/null 2>&1 && return 0
1062
+ return 1
1063
+ fi
1064
+ if command -v wmctrl >/dev/null 2>&1; then
1065
+ out="$(wmctrl -lx 2>/dev/null || true)"
1066
+ [[ "$out" == *"$cls"* ]] && return 0
1067
+ return 1
1068
+ fi
1069
+ return 2
1070
+ }
1071
+
1072
+ wait_for_app_class() { # <name> [tries] → 0 可见 / 1 未出现 / 2 无查询器
1073
+ local name="$1" tries="${2:-50}" rc
1074
+ while (( tries-- > 0 )); do
1075
+ rc=0; app_class_visible "$name" || rc=$?
1076
+ (( rc == 0 )) && return 0
1077
+ (( rc == 2 )) && return 2
1078
+ sleep 0.1
1079
+ done
1080
+ return 1
1081
+ }
1082
+
1083
+ # ④ 直开窗:启动 + 限时 class 出现校验;失败尽力回收(M1:不假称已清理)
1084
+ # pid 经继承管道回传(wrapper exec 后即 pi)⇒ 终端单实例/daemon 化形态下仍能可靠定位 pi,
1085
+ # 不依赖窗口生命周期;无 pidfile ⇒ 父先返回也不会被「子进程晚写」残留(M2 设计修复)。
1086
+ spawn_xdg_window() { # <name> <repo> <cmd...>
1087
+ local name="$1" repo="$2"; shift 2
1088
+ local pid="" vrc=0
1089
+ open_pid_pipe || return 1
1090
+ if ! open_xdg_window "$name" "$repo" bash -c "$(pid_pipe_wrapper)" _ "$@"; then
1091
+ close_pid_pipe; return 1
1092
+ fi
1093
+ wait_for_app_class "$name" || vrc=$?
1094
+ # 成功 / D6 接受:无需 pid,直接关管道(子进程 O_RDWR 写不会 SIGPIPE;无路径 ⇒ 无残留)
1095
+ if (( vrc == 0 )); then close_pid_pipe; return 0; fi
1096
+ if (( vrc == 2 )); then
1097
+ warn "无 class 探针(hyprctl/swaymsg/xdotool/wmctrl 均缺):未能校验 $name 窗口可见性(D6)"
1098
+ close_pid_pipe; return 0
1099
+ fi
1100
+ # 有探针但限时未出现 class ⇒ 尽力回收 pi,如实报告回收结果(M1)
1101
+ pid="$(wait_for_pid_pipe 10)" || pid=""
1102
+ close_pid_pipe
1103
+ if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
1104
+ kill "$pid" 2>/dev/null || true
1105
+ warn "④ 校验失败:已尽力回收 $name 的 pi 进程(pid=$pid);若窗口仍在请手动关闭"
1106
+ else
1107
+ warn "④ 校验失败:未捕获 $name 的 pi 进程 pid,已开窗口可能仍在,请手动关闭"
1108
+ fi
1109
+ return 1
1110
+ }
1111
+
1112
+ # ③ tmux 会话 + 可见窗口 attach:new-session -d 后开窗 attach,限时校验 list-clients;
1113
+ # 无客户端 ⇒ kill-session 并返回非 0(调用方降级④;用户裁量①:降级而非 exit 1)。
1114
+ spawn_tmux_attach() { # <name> <repo> <inner>
1115
+ local name="$1" repo="$2" inner="$3" sess="nao-$name"
1116
+ tmux new-session -d -s "$sess" "bash -lc $(printf %q "$inner")" >/dev/null 2>&1 || return 1
1117
+ if ! open_xdg_window "$name" "$repo" tmux attach -t "$sess"; then
1118
+ tmux kill-session -t "=$sess" >/dev/null 2>&1 || true
1119
+ return 1
1120
+ fi
1121
+ if wait_for_tmux_client "$sess"; then
1122
+ log "tmux 会话 $sess 已创建 + 可见窗口 attach(默认 socket;pane 句柄完整)"
1123
+ return 0
1124
+ fi
1125
+ tmux kill-session -t "=$sess" >/dev/null 2>&1 || true
1126
+ return 1
1127
+ }
1128
+
1129
+ # 是否有 tmux pane 句柄(L6:status 降级标注按「④ 路径」而非「tmux 命令缺失」判断)
1130
+ # in tmux|NAO_TERMINAL=tmux|未显式指定且本机有 tmux(将走 ②/③)⇒ 有 pane 句柄
1131
+ has_tmux_handle() {
1132
+ [[ -n "${TMUX:-}" ]] && return 0
1133
+ [[ "${NAO_TERMINAL:-}" == tmux ]] && return 0
1134
+ [[ -z "${NAO_TERMINAL:-}" ]] && command -v tmux >/dev/null 2>&1 && return 0
1135
+ return 1
1136
+ }
1137
+
1138
+ # 宿主阶梯判定(ADR 实施闸门):echo 动作标签;rc 11=档值非法 / 12=命令缺失 / 1=无可用宿主
1139
+ resolve_action() {
1140
+ if [[ -n "${NAO_TERMINAL:-}" ]]; then
1141
+ local h ok=0
1142
+ for h in "${NAO_TERMINAL_ALLOWED[@]}"; do [[ "$h" == "$NAO_TERMINAL" ]] && { ok=1; break; }; done
1143
+ (( ok )) || return 11
1144
+ command -v "$NAO_TERMINAL" >/dev/null 2>&1 || return 12
1145
+ printf 'nao:%s\n' "$NAO_TERMINAL"; return 0
1146
+ fi
1147
+ if [[ -n "${TMUX:-}" ]]; then
1148
+ if command -v tmux >/dev/null 2>&1; then printf 'tmux-in\n'; return 0; fi
1149
+ # L1(PM 裁定):$TMUX 已设但 tmux 命令缺失 ⇒ warn + 继续(不因升级中途变量残留卡死)
1150
+ warn "TMUX 已设置但 tmux 命令缺失:忽略 tmux 句柄,按环境继续选择宿主"
1151
+ fi
1152
+ if command -v tmux >/dev/null 2>&1 && xdg_launcher_ok; then printf 'tmux-attach\n'; return 0; fi
1153
+ if xdg_launcher_ok; then printf 'xdg-window\n'; return 0; fi
1154
+ if [[ "${BACKGROUND:-false}" == true ]]; then
1155
+ if command -v tmux >/dev/null 2>&1; then printf 'tmux-bg\n'; return 0; fi
1156
+ if command -v screen >/dev/null 2>&1; then printf 'screen-bg\n'; return 0; fi
1157
+ fi
1158
+ return 1
1159
+ }
1160
+
886
1161
  spawn_one() {
887
- local name="$1" repo="$2" model="$3" card prompt_file host inner
1162
+ local name="$1" repo="$2" model="$3" card prompt_file inner action rrc=0 spawn_rc=0 htag
888
1163
  card="$PROMPTS_DIR/$PROMPT"
889
1164
  [[ -f "$card" ]] || die "角色卡不存在: $card"
890
1165
  [[ -d "$repo" ]] || die "工作区不存在: $repo"
891
1166
 
1167
+ # 宿主阶梯判定先于任何资源创建:环境不足 ⇒ exit 2,失败路径零残留(BR3/AC3/AC4/AC6)
1168
+ action="$(resolve_action)" || rrc=$?
1169
+ case "$rrc" in
1170
+ 0) ;;
1171
+ 11) die_env "不支持的宿主:NAO_TERMINAL=$NAO_TERMINAL(可选 ${NAO_TERMINAL_ALLOWED[*]})" \
1172
+ "改用受支持宿主:\`NAO_TERMINAL=<$NAO_TERMINAL_LIST> nao-fleet.sh ensure $name\`" ;;
1173
+ 12) die_env "宿主不可用:NAO_TERMINAL=$NAO_TERMINAL 未找到命令 $NAO_TERMINAL" \
1174
+ "先用 \`command -v $NAO_TERMINAL\` 确认安装,或改用已安装宿主(如 \`NAO_TERMINAL=tmux nao-fleet.sh ensure $name\`)" ;;
1175
+ *) die_env "无可用宿主:无 tmux / 无图形窗口宿主(xdg-terminal-exec)且未显式 --background" \
1176
+ "安装或指定已安装宿主:\`NAO_TERMINAL=<$NAO_TERMINAL_LIST> nao-fleet.sh ensure $name\`;无图形会话时用 \`nao-fleet.sh ensure --background $name\`" ;;
1177
+ esac
1178
+
892
1179
  prompt_file="$(build_system_prompt "$card")"
893
1180
  inner="cd $(printf %q "$repo") && exec pi --name $(printf %q "$name")"
894
1181
  [[ -n "$model" ]] && inner+=" --model $(printf %q "$model")"
895
1182
  inner+=" --append-system-prompt $(printf %q "$prompt_file")"
896
1183
  ( sleep 60; rm -f -- "$prompt_file" ) & disown 2>/dev/null || true
897
1184
 
898
- host="$(detect_host)"
899
- case "$host" in
900
- ghostty) setsid -f ghostty -e bash -lc "$inner" >/dev/null 2>&1 ;;
901
- ptyxis) setsid -f ptyxis -- bash -lc "$inner" >/dev/null 2>&1 ;;
902
- tmux) spawn_tmux "$name" "$inner" ;;
903
- screen) screen -dmS "nao-$name" bash -lc "$inner" ;;
904
- *) die "不支持的宿主: $host" ;;
1185
+ # 拉起 + 句柄前置校验;失败不打印「已拉起」,清理本轮资源后 exit 1(BR2/BR3/AC6)
1186
+ case "$action" in
1187
+ tmux-in|nao:tmux|tmux-bg) spawn_tmux "$name" "$inner" ;;
1188
+ tmux-attach)
1189
+ if ! spawn_tmux_attach "$name" "$repo" "$inner"; then
1190
+ warn "tmux 会话 attach 限时内无客户端 → 降级为 xdg-terminal-exec 直开窗(阶梯④;用户裁量①)"
1191
+ spawn_xdg_window "$name" "$repo" $TMUX_ENV_SCRUB bash -lc "$inner" || spawn_rc=1
1192
+ fi ;;
1193
+ xdg-window) spawn_xdg_window "$name" "$repo" $TMUX_ENV_SCRUB bash -lc "$inner" || spawn_rc=1 ;;
1194
+ screen-bg|nao:screen) spawn_screen "$name" "$inner" || spawn_rc=1 ;;
1195
+ nao:foot) spawn_fork_window "$name" $TMUX_ENV_SCRUB foot --app-id="org.nao.$name" --working-directory="$repo" -e bash -lc "$inner" || spawn_rc=1 ;;
1196
+ nao:ghostty) spawn_fork_window "$name" $TMUX_ENV_SCRUB ghostty -e bash -lc "$inner" || spawn_rc=1 ;;
1197
+ nao:ptyxis) spawn_fork_window "$name" $TMUX_ENV_SCRUB ptyxis -- bash -lc "$inner" || spawn_rc=1 ;;
1198
+ *) spawn_rc=1 # 理论上不可达(resolve_action 标签全覆盖);防御性不假成功
1199
+ esac
1200
+ if (( spawn_rc != 0 )); then
1201
+ rm -f -- "$prompt_file" 2>/dev/null || true
1202
+ die_spawn "宿主 $action 未能拉起 $name(动作失败,exit 1;回收结果见上方提示)"
1203
+ fi
1204
+ case "$action" in
1205
+ tmux-in|nao:tmux) htag="tmux" ;;
1206
+ tmux-attach) htag="tmux+window" ;;
1207
+ xdg-window) htag="xdg-terminal-exec" ;;
1208
+ tmux-bg) htag="tmux(detached)" ;;
1209
+ screen-bg|nao:screen) htag="screen" ;;
1210
+ nao:*) htag="${action#nao:}" ;;
1211
+ *) htag="$action" ;;
905
1212
  esac
906
- log "[$host] 已拉起 $name @ $repo${model:+(model=$model)}$([[ "$host" == tmux ]] && echo "(layout=$TMUX_LAYOUT)")"
1213
+ log "[$htag] 已拉起 $name @ $repo${model:+(model=$model)}$([[ "$action" == tmux-in || "$action" == nao:tmux ]] && echo "(layout=$TMUX_LAYOUT)")"
907
1214
  }
908
1215
 
909
1216
  # ---------------------------------------------------------------------------
@@ -930,9 +1237,6 @@ cmd_check() {
930
1237
  fi
931
1238
  check_eol || rc=1
932
1239
 
933
- echo "== 主动推送器(qq-notify,可选能力)=="
934
- check_qq_notify || rc=1
935
-
936
1240
  echo "== 角色清单 roles.yaml =="
937
1241
  load_manifest
938
1242
  printf ' ✓ 解析成功,%d 个角色: %s\n' "${#ROLE_ORDER[@]}" "${ROLE_ORDER[*]}"
@@ -1081,11 +1385,37 @@ cmd_check() {
1081
1385
  done
1082
1386
  fi
1083
1387
 
1388
+ echo "== 宿主阶梯(BR1;非 tmux 用户)=="
1389
+ echo " ① NAO_TERMINAL → ② tmux 内分屏 → ③ tmux 会话+可见窗口 attach → ④ xdg-terminal-exec 直开窗 → ⑤ --background → ⑥ exit 2"
1390
+ if [[ -n "${NAO_TERMINAL:-}" ]]; then
1391
+ local okh=0 h
1392
+ for h in "${NAO_TERMINAL_ALLOWED[@]}"; do [[ "$h" == "$NAO_TERMINAL" ]] && { okh=1; break; }; done
1393
+ if (( ! okh )); then
1394
+ printf ' ✗ NAO_TERMINAL=%-10s 非法(可选 %s)\n' "$NAO_TERMINAL" "${NAO_TERMINAL_ALLOWED[*]}"; rc=1
1395
+ elif ! command -v "$NAO_TERMINAL" >/dev/null 2>&1; then
1396
+ printf ' ✗ NAO_TERMINAL=%-10s 命令缺失(ensure 将 exit 2)\n' "$NAO_TERMINAL"; rc=1
1397
+ else
1398
+ printf ' ✓ NAO_TERMINAL=%-10s 合法且可用\n' "$NAO_TERMINAL"
1399
+ fi
1400
+ else
1401
+ echo ' · NAO_TERMINAL 未设置(按环境自动选择阶梯)'
1402
+ fi
1403
+ if command -v xdg-terminal-exec >/dev/null 2>&1; then
1404
+ echo ' ✓ xdg-terminal-exec 可用(阶梯③/④ 开可见窗口)'
1405
+ else
1406
+ echo ' · xdg-terminal-exec 缺失(阶梯③/④ 不可用;无图形会话需 --background)'
1407
+ fi
1408
+ if command -v tmux >/dev/null 2>&1; then
1409
+ echo ' ✓ tmux 可用(阶梯②/③/⑤)'
1410
+ else
1411
+ echo ' · tmux 缺失(阶梯②/③/⑤ 不可用)'
1412
+ fi
1413
+
1084
1414
  echo "== tmux 布局 =="
1085
1415
  if [[ -n "${TMUX:-}" ]]; then
1086
1416
  echo " 当前在 tmux 内(会话 $(tmux display-message -p '#S' 2>/dev/null || echo '?'))"
1087
1417
  else
1088
- echo " (当前不在 tmux 内;若宿主命中 tmux 会创建 detached 会话)"
1418
+ echo " (当前不在 tmux 内:宿主阶梯将按 ③/④/⑤ 选择可见窗口或 --background)"
1089
1419
  fi
1090
1420
  case "$TMUX_LAYOUT" in
1091
1421
  main-row2|main-col|grid)
@@ -1119,6 +1449,10 @@ cmd_status() {
1119
1449
  local a
1120
1450
  intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存(后续子 shell 复用)
1121
1451
  echo "== 角色会话在线状态(权威名单见 intercom({action:'list'});本表按终端标题/名册判定)=="
1452
+ # ④ 路径降级标注(BR6/D2/L6):无 tmux pane 句柄(含 NAO_TERMINAL=foot 等)时 turn 判定仅靠名册;恰好一行
1453
+ if ! has_tmux_handle; then
1454
+ echo ' (turn 判定来源 = 名册;非 tmux 宿主降级:无 tmux pane 句柄,在跑 turn 判定仅供参考)'
1455
+ fi
1122
1456
  for a in "${ROLE_ORDER[@]}"; do
1123
1457
  if running "$a" "${ROLE_WS[$a]:-}"; then
1124
1458
  printf ' ✓ %-14s 在线(--name %s;卡片 %s)\n' "$a" "$a" "${ROLE_CARDS[$a]}"
@@ -1163,7 +1497,7 @@ cmd_status() {
1163
1497
  cmd_close() {
1164
1498
  local force="$1" task="$2" target="${3:-}"
1165
1499
  [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1 / qa-T1)"
1166
- local name repo cls bus pane pids st tgt a best=""
1500
+ local name repo cls bus pane pids st tgt a best="" stok
1167
1501
  intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存
1168
1502
 
1169
1503
  # ---- 目标解析:别名/canonical id | 派生会话名 <角色>-<编号> ----
@@ -1208,13 +1542,35 @@ cmd_close() {
1208
1542
 
1209
1543
  # ---- 宿主句柄定位(跨仓):pane 按标题任意仓 basename / 名册 tmuxPane 不限定 cwd ----
1210
1544
  pane="$(find_pane_for_any_repo "$name")"
1545
+ # F1 不变量(T513-S2):close 任何路径不得回收调用者自己的 pane/窗口。
1546
+ # 根因:派生窗口若继承 $TMUX_PANE,子进程按该 pane 注册名册 ⇒ 层② 命中调用者自身;
1547
+ # 本闸门是纵深防御——无论上游解析为何,命中即拒绝(不 kill-pane、不进入 pi_pids_for 兜底)。
1548
+ if [[ -n "$pane" && -n "${TMUX_PANE:-}" && "$pane" == "$TMUX_PANE" ]]; then
1549
+ warn "拒绝回收 $name:解析出的 pane $pane 与调用者自己的 pane(\$TMUX_PANE=$TMUX_PANE)相同"
1550
+ warn " 名册 tmuxPane 可能因派生时继承 TMUX_PANE 而错注册;请用 intercom({action:'list'}) 核对目标会话后再处理"
1551
+ return 1
1552
+ fi
1211
1553
  if [[ -z "$pane" ]]; then
1212
1554
  # nao-<会话名> detached tmux / screen 会话(不依赖 repo)
1213
- if command -v tmux >/dev/null 2>&1 && tmux has-session -t "nao-$name" 2>/dev/null; then
1214
- if tmux kill-session -t "nao-$name" 2>/dev/null; then log "已回收 $name(tmux 会话 nao-$name)@ $repo"; return 0; fi
1555
+ if command -v tmux >/dev/null 2>&1 && tmux has-session -t "=nao-$name" 2>/dev/null; then
1556
+ if tmux kill-session -t "=nao-$name" 2>/dev/null; then log "已回收 $name(tmux 会话 nao-$name)@ $repo"; return 0; fi
1215
1557
  fi
1216
- if command -v screen >/dev/null 2>&1 && screen -ls 2>/dev/null | grep -q "nao-$name"; then
1217
- if screen -S "nao-$name" -X quit 2>/dev/null; then log "已回收 $name(screen 会话 nao-$name)@ $repo"; return 0; fi
1558
+ # F2(arch 评审):销毁目标 = `screen -ls` 锚定行取出的 `pid.name` 精确令牌;取不到即不 quit
1559
+ # (此前 `screen -S nao-<名> -X quit` 是前缀目标,精确+派生并存时无「只杀精确」保障)。
1560
+ stok="$(screen_token_for "$name")"
1561
+ if [[ -n "$stok" ]] && screen -S "$stok" -X quit 2>/dev/null; then
1562
+ log "已回收 $name(screen 会话 $stok)@ $repo"; return 0
1563
+ fi
1564
+ # ④ 路径句柄(BR7/AC8):app-id/class → pid(校验 comm=pi)→ kill 优先;
1565
+ # 有句柄即回收,不再依赖名册(名册可能滞后)。
1566
+ local app_pid
1567
+ app_pid="$(app_pid_for "$name")"
1568
+ if [[ -n "$app_pid" ]]; then
1569
+ if kill "$app_pid" 2>/dev/null; then
1570
+ log "已回收 $name(app-id=org.nao.$name · pid=$app_pid)@ $repo"; return 0
1571
+ fi
1572
+ warn "已定位 $name 的 app-id 句柄 pid=$app_pid 但回收失败(进程可能刚好退出;核对后可忽略)"
1573
+ return 1
1218
1574
  fi
1219
1575
  # 无任何可杀句柄:名册若报在线 ⇒ 定位失败,必须非静默(禁止「未运行」而 pane 仍在)
1220
1576
  if intercom_online "$name"; then
@@ -1223,6 +1579,15 @@ cmd_close() {
1223
1579
  warn " 请用 intercom({action:'list'}) 人工核对;确认已退出后忽略,或按 pane_id 手工处理"
1224
1580
  return 1
1225
1581
  fi
1582
+ # T516 AC3(#1 错定缺陷的负向闸):常驻精确句柄全缺席而其派生在场 ⇒ 目标歧义,
1583
+ # 拒绝并指路(不得落入 offline_verifiable 的「未运行(无需回收)」静默 no-op)。
1584
+ local -a dfield=()
1585
+ mapfile -t dfield < <(derived_in_field "$name")
1586
+ if (( ${#dfield[@]} > 0 )); then
1587
+ warn "拒绝回收 $name:常驻会话 nao-$name 不存在,但其派生在场:${dfield[*]}"
1588
+ warn " 目标歧义(tmux 会话名前缀匹配曾误回收派生,T515 #1):如需回收派生用 close <派生名>(如 close ${dfield[0]});如需拉起常驻用 ensure $tgt"
1589
+ return 1
1590
+ fi
1226
1591
  if offline_verifiable; then
1227
1592
  log "$name 未运行(无需回收)"; return 0
1228
1593
  fi
@@ -1266,6 +1631,57 @@ cmd_close() {
1266
1631
  warn "已定位到 $name 的 pane $pane 但回收失败(pane 可能刚好退出;核对 pane 已消失后可忽略)"; return 1
1267
1632
  }
1268
1633
 
1634
+ # ---------------------------------------------------------------------------
1635
+ # 实验场安全清理入口(T516 #2 · D2 形态 A):终结指定实验场 tmux server。
1636
+ # 守卫:① 无 -S ⇒ exit 2(默认 socket 是生产名册所在,禁止默认清理);
1637
+ # ② -S 指向默认 socket ⇒ exit 2(防呆:显式传入也不放行);
1638
+ # ③ socket 不存在 ⇒ 幂等成功(rc 0);④ kill 失败 ⇒ 非 0 + 诊断,不谎报。
1639
+ # 生产默认 socket 候选。T516 事故修正:判定**不得**依赖 `${TMUX_TMPDIR:-/tmp}` ——
1640
+ # 实验夹具为让 fleet 内部裸 `tmux` 落到夹具 server 会把 TMUX_TMPDIR 指向隔离目录,
1641
+ # 若据此判定,真实默认 socket 会被误判为「非默认」而遭清理 ⇒ 真 server 连坐自灭
1642
+ # (2026-10-13 AC5e 负向用例实测杀掉调用者自己的 server)。恒含常量 /tmp/tmux-<uid>/default
1643
+ # (id -u 失败也是安全路径);回归脚本护栏用同一常量**独立复算**(双保险,互不依赖)。
1644
+ prod_sock_candidates() { # 每行一个候选
1645
+ local uid; uid="$(id -u)"
1646
+ [[ -n "${TMUX:-}" ]] && printf '%s\n' "${TMUX%%,*}"
1647
+ printf '%s\n' "/tmp/tmux-$uid/default" "${TMPDIR:-/tmp}/tmux-$uid/default"
1648
+ }
1649
+ cmd_cleanup() {
1650
+ local sock="${CLEANUP_SOCK:-}"
1651
+ if [[ -z "$sock" ]]; then
1652
+ printf '[fleet] cleanup 拒绝:未指定 -S <socket>。生产默认 socket 是生产名册所在,禁止默认清理;实验场清理用:env -u TMUX nao-fleet.sh cleanup -S <隔离socket>\n' >&2
1653
+ return 2
1654
+ fi
1655
+ # F3-B3(arch 评审 · 低):`-S` 若为符号链接/未归一化路径(`/tmp//…`、symlink)可绕过字符串比对
1656
+ # ⇒ 比对前 readlink -f 归一化;readlink -f 不可用/不可解析时退回原串(保持原语义、无新依赖)。
1657
+ local sock_cmp
1658
+ sock_cmp="$(readlink -f -- "$sock" 2>/dev/null || true)"
1659
+ [[ -n "$sock_cmp" ]] || sock_cmp="$sock"
1660
+ local -a prod=() p
1661
+ mapfile -t prod < <(prod_sock_candidates | awk '!seen[$0]++')
1662
+ for p in "${prod[@]:-}"; do
1663
+ if [[ "$sock_cmp" == "$p" ]]; then
1664
+ printf '[fleet] cleanup 拒绝:-S 指向生产默认 socket(%s);请用隔离 socket(须不同于生产候选:%s)\n' "$sock" "${prod[*]}" >&2
1665
+ return 2
1666
+ fi
1667
+ done
1668
+ [[ -n "${TMUX:-}" ]] && warn "当前在 tmux 客户端内:本次 -S 已显式(安全),但实验纪律建议先 env -u TMUX 再执行"
1669
+ if [[ ! -S "$sock" ]]; then
1670
+ log "socket 不存在(无 server,幂等清理):$sock"; return 0
1671
+ fi
1672
+ # 陈旧 socket(kill-server 后残留的 socket 文件,无 server 监听):清除文件即幂等成功
1673
+ # (实测 tmux 3.7c 在 -S 自定义路径下 kill-server 不卸载 socket 文件 ⇒ 二次 cleanup 需兜底)
1674
+ if ! tmux -S "$sock" ls >/dev/null 2>&1; then
1675
+ rm -f "$sock" 2>/dev/null || true
1676
+ log "socket 无 server 监听(残留),已清除:$sock"; return 0
1677
+ fi
1678
+ if tmux -S "$sock" kill-server 2>/dev/null; then
1679
+ log "已终结实验场 tmux server:$sock"; return 0
1680
+ fi
1681
+ warn "cleanup 失败:tmux -S $sock kill-server 非 0(server 可能刚好退出;核对 socket 后重试)"
1682
+ return 1
1683
+ }
1684
+
1269
1685
  # ---------------------------------------------------------------------------
1270
1686
  cmd_ensure() {
1271
1687
  local force="$1" model="$2" task="$3"; shift 3
@@ -1305,12 +1721,14 @@ usage() {
1305
1721
  }
1306
1722
 
1307
1723
  # ---- 入口 ----
1308
- CMD=""; FORCE=false; STRICT=false; MODEL=""; TASK=""; TARGETS=()
1724
+ CMD=""; FORCE=false; STRICT=false; MODEL=""; TASK=""; BACKGROUND=false; CLEANUP_SOCK=""; TARGETS=()
1309
1725
  while [[ $# -gt 0 ]]; do
1310
1726
  case "$1" in
1311
1727
  check) CMD="check"; shift ;;
1312
1728
  status) CMD="status"; shift ;;
1313
1729
  close) CMD="close"; shift ;;
1730
+ cleanup) CMD="cleanup"; shift ;;
1731
+ -S) CLEANUP_SOCK="${2:-}"; [[ -n "$CLEANUP_SOCK" ]] || die "-S 需要socket路径参数"; shift 2 ;;
1314
1732
  ensure) CMD="ensure"; shift ;;
1315
1733
  -m|--model)
1316
1734
  MODEL="${2:-}"
@@ -1318,6 +1736,7 @@ while [[ $# -gt 0 ]]; do
1318
1736
  check_model "$MODEL" || die "模型 '$MODEL' 不在白名单内。允许: $MODEL_WHITELIST(可通过 NAO_MODEL_WHITELIST 覆盖)"
1319
1737
  shift 2 ;;
1320
1738
  --force) FORCE=true; shift ;;
1739
+ --background) BACKGROUND=true; shift ;;
1321
1740
  --task) TASK="${2:-}"; [[ -n "$TASK" ]] || die "--task 需要任务编号(如 T1)"; [[ "$TASK" =~ ^[A-Za-z0-9_-]+$ ]] || die "--task 非法: $TASK(仅字母/数字/-/_)"; shift 2 ;;
1322
1741
  --strict) STRICT=true; shift ;;
1323
1742
  -v|--verbose) VERBOSE=true; shift ;;
@@ -1330,6 +1749,14 @@ case "$CMD" in
1330
1749
  check) : ;; # cmd_check 自行先做文本契约体检,再 load_manifest(缩进违例时也能先出报告)
1331
1750
  ensure|status|close) load_manifest ;;
1332
1751
  esac
1752
+ case "$CMD" in
1753
+ cleanup) : ;; # cleanup 不需角色清单
1754
+ esac
1755
+
1756
+ # -S 仅 cleanup 消费:其他子命令带上 -S 视为用法错误(避免静默吞参)
1757
+ if [[ -n "${CLEANUP_SOCK:-}" && "$CMD" != "cleanup" ]]; then
1758
+ die "-S 仅用于 cleanup 子命令(实验场安全清理):nao-fleet.sh cleanup -S <socket>"
1759
+ fi
1333
1760
 
1334
1761
  case "$CMD" in
1335
1762
  check)
@@ -1359,6 +1786,7 @@ case "$CMD" in
1359
1786
  ;;
1360
1787
  status) cmd_status ;;
1361
1788
  close) cmd_close "$FORCE" "$TASK" "${TARGETS[0]:-}" ;;
1789
+ cleanup) cmd_cleanup ;;
1362
1790
  ensure) cmd_ensure "$FORCE" "$MODEL" "$TASK" "${TARGETS[@]}" ;;
1363
1791
  *) usage ;;
1364
1792
  esac