@nathan33/nao-skill 0.9.3 → 0.9.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.
@@ -28,6 +28,7 @@ description: pi-intercom 多会话协作协议(常驻引用)
28
28
  - **任务派生**:`ensure --task <编号> <别名>[@<repo>]` 创建 `<角色>-<编号>` 独立会话(如 `rd-be-T1`),并行隔离、互不排队/打断,避免多任务同名冲突;任务闭环即**回收**:`nao-fleet.sh close --task <编号> <别名>`(内置在跑 turn / tasks-state 闸门)。
29
29
 
30
30
  - **判重 / 在线判定**:脚本按 pi 自设的**终端标题**(`π - <会话名> - <仓库名>`)+ `pi-intercom` 名册判在线(**不用 `pgrep --name`**:pi 启动后 argv 被改写为 `pi`,会恒失配);已运行则跳过并 warn。确需重开加 `--force`。
31
+ - **标题被改写时的 pane 定位**(v0.9.4):终端标题可被外部改写(实测变为 `pi:c`),此时 `status`/`close`/`ensure` 按三层后备定位 pane:① 标题契约 → ② pi-intercom 名册 `tmuxPane`(注册时读 `$TMUX_PANE`,与标题改名无关,权威)→ ③ 本仓「未被认领 pi pane」唯一兜底。三层均未命中才认「无法确认」并 exit 非 0(不静默 no-op)。`close` 的在跑 turn 判定叠加名册活动状态(thinking / tool:* 等)与 pane 末 12 行状态行双重信号。
31
32
  - **tmux 宿主**:`$TMUX` 存在时在当前窗口分屏拉起。布局三选一(`NAO_TMUX_LAYOUT`):默认 `main-row2`
32
33
  (首 pane 全高占左,后续每角色往右开列、每列上下 2 个);`main-col`(其余在右列竖排);`grid`
33
34
  (等大网格)。**窄列守卫**:`main-row2` 最窄非主 pane < `NAO_TMUX_MIN_PANE_WIDTH`(默认 30)时
@@ -0,0 +1,59 @@
1
+ // intercom-probe.mts — nao-fleet.sh 的 pi-intercom 名册探针(含 tmuxPane)
2
+ //
3
+ // 用法:node <tsx cli.mjs> intercom-probe.mts <pi-agent-dir>
4
+ // 输出(stdout):{"ok":true,"sessions":[{"name","id","model","status","cwd","tmuxPane","pid"},…]}
5
+ //
6
+ // 为什么需要它(依据):
7
+ // pi 启动后用 OSC 0 把终端标题设为 "π - <会话名> - <cwd basename>" 并改写 argv,
8
+ // nao-fleet 旧实现只按终端标题定位 pane ⇒ 标题一旦被改写(实测变为 "pi:c"),
9
+ // close/status/ensure 全部失配。而 pi-intercom 在会话注册时读取 $TMUX_PANE
10
+ // (types.ts: tmuxPane —— 进程生命周期内不可变,与窗口/标题改名无关),这是
11
+ // 标题被改写后仍能精确定位 pane 的权威依据。
12
+ // 官方 CLI(cli.ts list --json)的 sessionRow 未透出 tmuxPane,故此处直接用同一
13
+ // 包内的 IntercomClient 读取完整 SessionInfo。
14
+ //
15
+ // 兼容性:pi-intercom 内部 API 若变化 → 本探针非 0 退出,nao-fleet 回退官方 CLI
16
+ // (无 tmuxPane)再叠加「仓库内未认领 pi pane 唯一兜底」,不静默 no-op。
17
+ import { join } from "node:path";
18
+
19
+ const agentDir = process.argv[2];
20
+ if (!agentDir) {
21
+ process.stderr.write("usage: intercom-probe.mts <pi-agent-dir>\n");
22
+ process.exit(2);
23
+ }
24
+ const mod = join(agentDir, "npm/node_modules/pi-intercom");
25
+ const PROBE_NAME = "nao-fleet-probe";
26
+
27
+ try {
28
+ const { IntercomClient } = await import(join(mod, "broker/client.ts"));
29
+ const { spawnBrokerIfNeeded } = await import(join(mod, "broker/spawn.ts"));
30
+ const client = new IntercomClient();
31
+ await spawnBrokerIfNeeded();
32
+ const now = Date.now();
33
+ await client.connect({
34
+ cwd: process.cwd(),
35
+ model: "nao-fleet-probe",
36
+ pid: process.pid,
37
+ startedAt: now,
38
+ lastActivity: now,
39
+ name: PROBE_NAME,
40
+ status: "idle",
41
+ });
42
+ const sessions = await client.listSessions();
43
+ const rows = sessions
44
+ .filter((s) => s.id !== client.sessionId)
45
+ .map((s) => ({
46
+ name: s.name ?? "",
47
+ id: s.id,
48
+ model: s.model,
49
+ status: s.status ?? "?",
50
+ cwd: s.cwd,
51
+ tmuxPane: s.tmuxPane ?? null,
52
+ pid: s.pid ?? null,
53
+ }));
54
+ process.stdout.write(JSON.stringify({ ok: true, sessions: rows }, null, 2));
55
+ await client.disconnect().catch(() => {});
56
+ } catch (error) {
57
+ process.stderr.write(`intercom-probe failed: ${error instanceof Error ? error.message : String(error)}\n`);
58
+ process.exit(1);
59
+ }
@@ -26,12 +26,16 @@
26
26
  # NAO_CLOSE_BUSY_PATTERN=<ERE> close 的在跑 turn 判定正则(默认内置 pi 状态行标记)
27
27
  # NAO_TASKS_STATE=<path> 任务状态文件(默认 docs/tasks-state.md,供残留检测/回收闸门)
28
28
  #
29
- # 在线判定(status / close / ensure 判重共用)
29
+ # 在线判定与 pane 定位(status / close / ensure 判重共用)
30
30
  # pi 启动后用 OSC 0 把终端标题设为 "π - <会话名> - <cwd basename>" 并改写 argv
31
- # (/proc/<pid>/cmdline 只剩 "pi")⇒ 判定一律面向「终端标题 / pi-intercom 名册 /
32
- # tmux·screen 会话名」,不使用 `pgrep --name`(历史假阴性根因)。
33
- # 名册经 $PI_AGENT_DIR/npm/node_modules/pi-intercom/cli.ts(一次调用、进程内缓存),
34
- # 不可用时回退 tmux/screen;仍无法确认「不在线」时 close 不做静默 no-op(exit 非 0)。
31
+ # (/proc/<pid>/cmdline 只剩 "pi")⇒ 不使用 `pgrep --name`(历史假阴性根因)。
32
+ # ⚠️ 标题可能被外部改写(PM 实测变为 "pi:c")⇒ pane 定位按三层后备(find_pane_for):
33
+ # ① 终端标题契约(最快)
34
+ # ② pi-intercom 名册 tmuxPane(注册时读 $TMUX_PANE;与标题改名无关,权威)
35
+ # ③ 本仓「未被认领 pi pane」唯一兜底(启发式;歧义即 warn,不猜)
36
+ # 名册由 intercom-probe.mts(一次调用、进程内缓存,含 tmuxPane)取;探针不可用回退
37
+ # pi-intercom 官方 cli.ts(无 tmuxPane),再不可用回退 tmux/screen 会话名;
38
+ # 仍无法确认「不在线」时 close 不做静默 no-op(exit 非 0)。
35
39
  #
36
40
  # tmux 宿主行为
37
41
  # - 已在 tmux 内($TMUX 存在):当前窗口分屏拉起,不新建窗口。
@@ -295,12 +299,26 @@ detect_host() {
295
299
  # ① pi-intercom 名册(权威在线名单;跨 tmux/ghostty/ptyxis/screen 宿主)
296
300
  # ② tmux pane_title(pi 自设;与 pane_start_command 是否为空无关)
297
301
  # ③ tmux detached 会话 / screen 会话名 nao-<会话名>
302
+ # ⚠️ 标题可被外部改写(实测 "pi:c")⇒ find_pane_for 再叠三层后备:标题契约 →
303
+ # 名册 tmuxPane($TMUX_PANE,权威)→ 本仓未认领 pi pane 唯一兜底。
298
304
  # repo(可选):给出时要求标题 basename / 名册 cwd 一致,避免跨项目同名会话互串。
299
305
 
300
306
  PI_AGENT_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
301
307
  PI_TITLE="π"
308
+ # 含 tmuxPane 的名册探针(标题被改写时定位 pane 的权威依据)
309
+ INTERCOM_PROBE="$SKILLS_DIR/.agents/scripts/intercom-probe.mts"
302
310
  _INTERCOM_JSON=""; _INTERCOM_PROBED=0
303
311
 
312
+ # 以 15s 超时跑 tsx(有 timeout 才用;无则裸跑);stdout/stderr 原样透传
313
+ _node_tsx() {
314
+ local tsx="$1"; shift
315
+ if command -v timeout >/dev/null 2>&1; then
316
+ timeout 15 node "$tsx" "$@"
317
+ else
318
+ node "$tsx" "$@"
319
+ fi
320
+ }
321
+
304
322
  repo_abspath() { ( cd "$1" >/dev/null 2>&1 && pwd ) 2>/dev/null || true; }
305
323
  repo_basename() {
306
324
  local abs; abs="$(repo_abspath "$1")"
@@ -309,6 +327,8 @@ repo_basename() {
309
327
  }
310
328
 
311
329
  # intercom 名册 JSON(一次调用、进程内缓存);不可用返回 1(不 die)
330
+ # ① intercom-probe.mts(含 tmuxPane,标题被改写仍可定位 pane)
331
+ # ② pi-intercom 官方 cli.ts list --json(无 tmuxPane,仅 name/cwd/status)
312
332
  intercom_list_json() {
313
333
  if (( _INTERCOM_PROBED )); then
314
334
  [[ -n "$_INTERCOM_JSON" ]] && printf '%s' "$_INTERCOM_JSON"
@@ -317,13 +337,14 @@ intercom_list_json() {
317
337
  _INTERCOM_PROBED=1
318
338
  local dir="$PI_AGENT_DIR/npm/node_modules/pi-intercom"
319
339
  local tsx="$PI_AGENT_DIR/npm/node_modules/tsx/dist/cli.mjs"
320
- [[ -f "$dir/cli.ts" && -f "$tsx" ]] || return 1
340
+ [[ -f "$tsx" ]] || return 1
321
341
  command -v node >/dev/null 2>&1 || return 1
322
- local out
323
- if command -v timeout >/dev/null 2>&1; then
324
- out="$(timeout 15 node "$tsx" "$dir/cli.ts" list --json 2>/dev/null)" || return 1
325
- else
326
- out="$(node "$tsx" "$dir/cli.ts" list --json 2>/dev/null)" || return 1
342
+ local out=""
343
+ if [[ -f "$INTERCOM_PROBE" ]]; then
344
+ out="$(_node_tsx "$tsx" "$INTERCOM_PROBE" "$PI_AGENT_DIR" 2>/dev/null)" || out=""
345
+ fi
346
+ if [[ -z "$out" && -f "$dir/cli.ts" ]]; then
347
+ out="$(_node_tsx "$tsx" "$dir/cli.ts" list --json 2>/dev/null)" || out=""
327
348
  fi
328
349
  [[ -n "$out" ]] || return 1
329
350
  _INTERCOM_JSON="$out"
@@ -351,8 +372,13 @@ intercom_online() {
351
372
  return 1
352
373
  }
353
374
 
354
- # 会话所在的 tmux pane(pi 自设标题 "π - <会话名> - <repo basename>")
355
- find_pane_for() {
375
+ # 会话所在的 tmux pane。三层后备(标题可被外部改写,如 "pi:c"):
376
+ # ① 终端标题契约 "π - <会话名> - <repo basename>"(pi 自设;最快)
377
+ # ② pi-intercom 名册 tmuxPane(注册时读 $TMUX_PANE;与标题改名无关,权威)
378
+ # ③ 本仓「未被认领 pi pane」唯一兜底(启发式:pane_current_command=pi 且
379
+ # pane_current_path=repo;歧义时 warn,不猜)
380
+ # repo(可选):给出时要求标题 basename / 名册 cwd / pane 当前路径一致,避免跨项目同名互串。
381
+ find_pane_by_title() {
356
382
  local name="$1" repo="${2:-}" base=""
357
383
  command -v tmux >/dev/null 2>&1 || return 0
358
384
  if [[ -n "$repo" ]]; then base="$(repo_basename "$repo")"; fi
@@ -361,7 +387,101 @@ find_pane_for() {
361
387
  { t=$2
362
388
  if (b != "") { if (t == p " - " n " - " b) { print $1; exit } }
363
389
  else if (index(t, p " - " n " - ") == 1) { print $1; exit }
364
- }'
390
+ }' || true
391
+ }
392
+
393
+ # tmux pane 是否存活(名册 tmuxPane 可能滞后于 pane 关闭)
394
+ tmux_pane_live() {
395
+ [[ -n "$1" ]] || return 1
396
+ command -v tmux >/dev/null 2>&1 || return 1
397
+ tmux list-panes -a -F '#{pane_id}' 2>/dev/null | grep -qxF "$1"
398
+ }
399
+
400
+ # 名册中该会话的 tmux pane(注册时读 $TMUX_PANE;标题被改写仍有效)
401
+ intercom_pane_for() {
402
+ local name="$1" repo="${2:-}" abs="" json
403
+ json="$(intercom_list_json 2>/dev/null)" || return 0
404
+ [[ -n "$json" ]] || return 0
405
+ if [[ -n "$repo" ]]; then abs="$(repo_abspath "$repo")"; fi
406
+ awk -v n="$name" -v a="$abs" '
407
+ /"name":[[:space:]]*"/ { s=$0; sub(/.*"name":[[:space:]]*"/,"",s); sub(/".*/,"",s); cn=s }
408
+ /"cwd":[[:space:]]*"/ { s=$0; sub(/.*"cwd":[[:space:]]*"/,"",s); sub(/".*/,"",s); cc=s }
409
+ /"tmuxPane":[[:space:]]*"/ {
410
+ s=$0; sub(/.*"tmuxPane":[[:space:]]*"/,"",s); sub(/".*/,"",s); cp=s
411
+ if (cn == n && (a == "" || cc == a) && cp != "") { print cp; exit }
412
+ }
413
+ ' <<< "$json"
414
+ }
415
+
416
+ # 名册中该会话的活动状态(无记录则空);pi-intercom 状态值如 idle / thinking / tool:bash
417
+ # 标题被改写或 pane 不可判时,这是「在跑 turn」的权威信号。
418
+ intercom_status_for() {
419
+ local name="$1" repo="${2:-}" abs="" json
420
+ json="$(intercom_list_json 2>/dev/null)" || return 0
421
+ [[ -n "$json" ]] || return 0
422
+ if [[ -n "$repo" ]]; then abs="$(repo_abspath "$repo")"; fi
423
+ awk -v n="$name" -v a="$abs" '
424
+ /"name":[[:space:]]*"/ { s=$0; sub(/.*"name":[[:space:]]*"/,"",s); sub(/".*/,"",s); cn=s; cs="" }
425
+ /"status":[[:space:]]*"/ { s=$0; sub(/.*"status":[[:space:]]*"/,"",s); sub(/".*/,"",s); cs=s }
426
+ /"cwd":[[:space:]]*"/ {
427
+ s=$0; sub(/.*"cwd":[[:space:]]*"/,"",s); sub(/".*/,"",s); cc=s
428
+ if (cn == n && (a == "" || cc == a)) { print cs; exit }
429
+ }
430
+ ' <<< "$json"
431
+ }
432
+
433
+ # 本仓所有「当前前台命令为 pi」的 pane:pane_id<TAB>pane_current_path
434
+ # pane_current_command 与标题/argv 无关,是标题被改写时的可靠进程特征。
435
+ pi_panes() {
436
+ command -v tmux >/dev/null 2>&1 || return 0
437
+ tmux list-panes -a -F "#{pane_id}"$'\t'"#{pane_current_command}"$'\t'"#{pane_current_path}" 2>/dev/null \
438
+ | awk -F'\t' '{ c=$2; sub(/^.*\//,"",c); if (c=="pi") print $1 "\t" $3 }' || true
439
+ }
440
+
441
+ # 兜底:本仓 pi pane 中「未被其它会话认领」且唯一者
442
+ # 认领来源 = 标题契约命中的 pane + 名册 tmuxPane(二者任一即视为已识别)
443
+ fallback_pane_for() {
444
+ local name="$1" repo="$2" abs="" cp j v
445
+ command -v tmux >/dev/null 2>&1 || return 0
446
+ abs="$(repo_abspath "$repo")"; [[ -n "$abs" ]] || return 0
447
+ local -A claimed=()
448
+ while IFS= read -r cp; do
449
+ [[ -n "$cp" ]] && claimed["$cp"]=1
450
+ done < <(tmux list-panes -a -F "#{pane_id}"$'\t'"#{pane_title}" 2>/dev/null \
451
+ | awk -F'\t' -v t="$PI_TITLE" '$2 ~ "^" t " - " { print $1 }')
452
+ j="$(intercom_list_json 2>/dev/null)" || j=""
453
+ if [[ -n "$j" ]]; then
454
+ cp="$(printf '%s\n' "$j" | sed -n 's/.*"tmuxPane":[[:space:]]*"\([^"]*\)".*/\1/p')"
455
+ while IFS= read -r v; do [[ -n "$v" ]] && claimed["$v"]=1; done <<< "$cp"
456
+ fi
457
+ local -a cand=()
458
+ local pid ppath
459
+ while IFS=$'\t' read -r pid ppath; do
460
+ [[ "$ppath" == "$abs" ]] || continue
461
+ [[ -n "${claimed[$pid]:-}" ]] && continue
462
+ cand+=("$pid")
463
+ done < <(pi_panes)
464
+ if (( ${#cand[@]} == 1 )); then
465
+ printf '%s\n' "${cand[0]}"; return 0
466
+ fi
467
+ if (( ${#cand[@]} > 1 )); then
468
+ warn "标题被改写且名册无 pane 记录:$abs 内有多个未认领 pi pane(${cand[*]})→ 无法唯一确定 $name"
469
+ fi
470
+ return 0
471
+ }
472
+
473
+ find_pane_for() {
474
+ local name="$1" repo="${2:-}" pane=""
475
+ # ① 终端标题契约
476
+ pane="$(find_pane_by_title "$name" "$repo")"
477
+ [[ -n "$pane" ]] && { printf '%s\n' "$pane"; return 0; }
478
+ # ② pi-intercom 名册 tmuxPane(权威;标题被改写仍有效)
479
+ pane="$(intercom_pane_for "$name" "$repo")"
480
+ if [[ -n "$pane" ]] && tmux_pane_live "$pane"; then printf '%s\n' "$pane"; return 0; fi
481
+ # ③ 本仓未认领 pi pane 唯一兜底
482
+ pane="$(fallback_pane_for "$name" "$repo")"
483
+ [[ -n "$pane" ]] && { printf '%s\n' "$pane"; return 0; }
484
+ return 0
365
485
  }
366
486
 
367
487
  # 该会话名对应的 pi 进程 PID(经 pane 句柄取 pane_pid 及其子进程中 comm=pi 者)
@@ -395,25 +515,45 @@ offline_verifiable() {
395
515
  }
396
516
 
397
517
  # 本机可见的会话名(tmux 标题 + tmux/screen 会话 + 名册),供 status 枚举;已去重
518
+ # ⚠️ 每条枚举 `|| true`:set -e + pipefail 下 `screen -ls`(无 socket 时 rc=1)或
519
+ # `tmux`(无 server 时 rc=1)会中断其余枚举,使「名册回退」永远走不到 —— 必疾。
398
520
  visible_session_names() {
399
521
  { if command -v tmux >/dev/null 2>&1; then
400
- tmux list-panes -a -F '#{pane_title}' 2>/dev/null \
522
+ { tmux list-panes -a -F '#{pane_title}' 2>/dev/null || true; } \
401
523
  | sed -n "s/^${PI_TITLE} - \([A-Za-z0-9_-]*\) - .*/\1/p"
402
- tmux list-sessions -F '#{session_name}' 2>/dev/null | sed -n 's/^nao-//p'
524
+ { tmux list-sessions -F '#{session_name}' 2>/dev/null || true; } \
525
+ | sed -n 's/^nao-//p'
403
526
  fi
404
527
  if command -v screen >/dev/null 2>&1; then
405
- screen -ls 2>/dev/null | sed -n 's/.*[0-9]\.nao-\([A-Za-z0-9_-]*\).*/\1/p'
528
+ { screen -ls 2>/dev/null || true; } \
529
+ | sed -n 's/.*[0-9]\.nao-\([A-Za-z0-9_-]*\).*/\1/p'
406
530
  fi
407
- intercom_roster 2>/dev/null | awk -F'\t' 'NF {print $1}'
408
- } | awk '!seen[$0]++'
531
+ intercom_roster 2>/dev/null || true
532
+ } | awk -F'\t' 'NF {print $1}' | awk '!seen[$0]++'
533
+ }
534
+
535
+ # 未能归属到任何会话名的 pi pane(标题不符契约且名册无 tmuxPane 记录):
536
+ # 输出 pane_id<TAB>pane_title;供 status 做「非静默」提示(不假装看不到)。
537
+ unattributed_pi_panes() {
538
+ command -v tmux >/dev/null 2>&1 || return 0
539
+ local claimed p tt
540
+ claimed="$(intercom_list_json 2>/dev/null | sed -n 's/.*"tmuxPane":[[:space:]]*"\([^"]*\)".*/\1/p')" || claimed=""
541
+ while IFS=$'\t' read -r p tt; do
542
+ [[ -n "$p" ]] || continue
543
+ grep -qxF "$p" <<< "$claimed" && continue
544
+ printf '%s\t%s\n' "$p" "$tt"
545
+ done < <(tmux list-panes -a -F "#{pane_id}"$'\t'"#{pane_current_command}"$'\t'"#{pane_title}" 2>/dev/null \
546
+ | awk -F'\t' -v t="$PI_TITLE" '{ c=$2; sub(/^.*\//,"",c); if (c!="pi") next; if ($3 ~ "^" t " - ") next; print $1 "\t" $3 }' || true)
409
547
  }
410
548
 
411
- # 该 pane 末 3 行是否显示在跑 turn(启发式:状态行标记;回执/产物以 PM 核对清单为准)
549
+ # 该 pane 是否显示在跑 turn(启发式:扫末 12 行找状态行标记;回执/产物以 PM 核对清单为准)
550
+ # ⚠️ 窗口不能只用末 3 行:pi 状态行(如 "⠦ Working")上方还有分隔线,实测会落到第 4 行以外。
551
+ # 真伪由 roster 状态(intercom_status_for)交叉;此处仅作 pane 级补充。
412
552
  pane_busy() {
413
- local pane="$1" tail3
414
- tail3="$(tmux capture-pane -p -t "$pane" 2>/dev/null | grep -v '^[[:space:]]*$' | tail -3)"
415
- [[ -n "$tail3" ]] || return 1
416
- grep -qE "$CLOSE_BUSY_PATTERN" <<< "$tail3"
553
+ local pane="$1" window
554
+ window="$(tmux capture-pane -p -t "$pane" 2>/dev/null | grep -v '^[[:space:]]*$' | tail -12)"
555
+ [[ -n "$window" ]] || return 1
556
+ grep -qE "$CLOSE_BUSY_PATTERN" <<< "$window"
417
557
  }
418
558
 
419
559
  # 任务在 tasks-state 的归处:
@@ -951,6 +1091,16 @@ cmd_status() {
951
1091
  done < <(visible_session_names)
952
1092
  (( found )) || echo ' (无)'
953
1093
  [[ -f "$TASKS_STATE" ]] || echo " ($TASKS_STATE 不存在,残留判定已跳过)"
1094
+ # 标题被改写/无名的 pi pane:非静默——显式列出,供人工核对
1095
+ local -a orph=()
1096
+ local pn pt
1097
+ while IFS=$'\t' read -r pn pt; do
1098
+ [[ -n "$pn" ]] && orph+=("$pn($pt)")
1099
+ done < <(unattributed_pi_panes)
1100
+ if (( ${#orph[@]} > 0 )); then
1101
+ warn "以下 pi pane 标题不符契约且名册无 pane 记录,无法归属会话名:${orph[*]}"
1102
+ warn " (标题可能被外部改写;用 intercom({action:'list'}) 人工核对,或按 pane_id 处理)"
1103
+ fi
954
1104
  }
955
1105
 
956
1106
  # ---------------------------------------------------------------------------
@@ -958,7 +1108,7 @@ cmd_status() {
958
1108
  cmd_close() {
959
1109
  local force="$1" task="$2" target="$3"
960
1110
  [[ -n "$target" ]] || die "close 需要目标:角色别名或派生会话名(如 rd-be / rd-be-T1 或 rd-infra / rd-infra-T1)"
961
- local name repo cls bus pane pids r ok
1111
+ local name repo cls bus pane pids r ok st
962
1112
  intercom_list_json >/dev/null 2>&1 || true # 预热名册缓存
963
1113
  if [[ -n "$task" ]]; then
964
1114
  resolve_role "$target"
@@ -997,18 +1147,28 @@ cmd_close() {
997
1147
  return 1
998
1148
  fi
999
1149
 
1000
- # 闸门②:在跑 turn(tmux 可判;非 tmux 宿主无法判 → 需 --force)
1150
+ # 闸门②:在跑 turn(pane 标记 + roster 状态双重信号)
1001
1151
  pane="$(find_pane_for "$name" "$repo")"
1002
- if [[ -n "$pane" ]]; then
1003
- # 显式双分支赋值:pane 空闲时 bus 必须落为 ""。
1004
- # 曾用 `pane_busy "$pane" && bus=…`:pane_busy 为假时 && 短路,bus 在 set -u 下未绑定 → 行 1007 崩溃。
1005
- if pane_busy "$pane"; then
1006
- bus="tmux pane $pane 末行显示在跑 turn"
1152
+ bus=""
1153
+ # 显式双分支赋值:pane 空闲时 bus 必须落为 ""。
1154
+ # 曾用 `pane_busy "$pane" && bus=…`:pane_busy 为假时 && 短路,bus 在 set -u 下未绑定 → 行 1007 崩溃。
1155
+ if [[ -n "$pane" ]] && pane_busy "$pane"; then
1156
+ bus="tmux pane $pane 显示在跑 turn"
1157
+ fi
1158
+ st="$(intercom_status_for "$name" "$repo")"
1159
+ if [[ -z "$bus" ]]; then
1160
+ case "$st" in
1161
+ ""|idle|\?) ;; # 无状态或空闲 → 不阻塞
1162
+ *) bus="pi-intercom 名册状态=$st(在跑 turn)" ;;
1163
+ esac
1164
+ fi
1165
+ # 既无 pane 也无名册状态 ⇒ 确实无法判定 → 拒绝(不静默 no-op)
1166
+ if [[ -z "$bus" && -z "$pane" && -z "$st" ]]; then
1167
+ if command -v tmux >/dev/null 2>&1; then
1168
+ bus="tmux 宿主但未定位到该会话的 pane(标题契约 / 名册 tmuxPane / 仓库兜底均未命中)"
1007
1169
  else
1008
- bus=""
1170
+ bus="非 tmux 宿主,无法确认是否在跑 turn"
1009
1171
  fi
1010
- else
1011
- bus="非 tmux 宿主,无法确认是否在跑 turn"
1012
1172
  fi
1013
1173
  if [[ -n "$bus" && "$force" != true ]]; then
1014
1174
  warn "拒绝回收 $name:$bus"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathan33/nao-skill",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "nao 多角色 AI 开发舰队:角色卡 / 技能 / 协作协议 / 工具链,一键接入项目(pi / Claude / 任意 Agent)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,10 @@
24
24
  ],
25
25
  "author": "Nathan Lee",
26
26
  "license": "MIT",
27
+ "publishConfig": {
28
+ "access": "public",
29
+ "registry": "https://registry.npmjs.org/"
30
+ },
27
31
  "engines": {
28
32
  "node": ">=18"
29
33
  },