@team-agent/installer 0.3.7 → 0.3.9

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.
Files changed (32) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +52 -7
  4. package/crates/team-agent/src/cli/emit.rs +132 -4
  5. package/crates/team-agent/src/cli/leader.rs +12 -8
  6. package/crates/team-agent/src/cli/mod.rs +121 -28
  7. package/crates/team-agent/src/cli/tests/base.rs +14 -0
  8. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +83 -1
  9. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/run_delegation.rs +6 -2
  11. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +86 -21
  12. package/crates/team-agent/src/cli/tests/verb_install_skill.rs +76 -0
  13. package/crates/team-agent/src/leader/owner_bind.rs +59 -20
  14. package/crates/team-agent/src/leader/start.rs +34 -23
  15. package/crates/team-agent/src/leader/tests/identity.rs +22 -0
  16. package/crates/team-agent/src/leader/tests/wake_start_owner.rs +13 -0
  17. package/crates/team-agent/src/lifecycle/launch.rs +203 -16
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +25 -12
  19. package/crates/team-agent/src/lifecycle/restart.rs +7 -3
  20. package/crates/team-agent/src/lifecycle/tests/core.rs +192 -0
  21. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +112 -0
  22. package/crates/team-agent/src/lifecycle/types.rs +2 -0
  23. package/crates/team-agent/src/messaging/results.rs +27 -22
  24. package/crates/team-agent/src/messaging/tests/runtime.rs +18 -0
  25. package/crates/team-agent/src/provider/adapter.rs +177 -15
  26. package/crates/team-agent/src/state/identity.rs +29 -0
  27. package/crates/team-agent/src/tmux_backend/tests.rs +44 -0
  28. package/crates/team-agent/src/tmux_backend.rs +90 -7
  29. package/crates/team-agent/src/transport/test_support.rs +57 -4
  30. package/crates/team-agent/src/transport.rs +13 -0
  31. package/npm/install.mjs +31 -35
  32. package/package.json +4 -4
@@ -499,12 +499,14 @@ pub enum QuickStartReport {
499
499
  session_name: Option<SessionName>,
500
500
  state_path: Option<PathBuf>,
501
501
  next_actions: Vec<String>,
502
+ attach_commands: Vec<String>,
502
503
  },
503
504
  /// preflight 阻塞(`quick_start.py:59`)。
504
505
  PreflightBlocked {
505
506
  summary: String,
506
507
  blockers: Vec<String>,
507
508
  next_actions: Vec<String>,
509
+ attach_commands: Vec<String>,
508
510
  },
509
511
  }
510
512
 
@@ -689,28 +689,33 @@ pub fn report_result_for_owner_team(
689
689
  std::thread::sleep(std::time::Duration::from_millis(50));
690
690
  }
691
691
  }
692
- match inject_leader_notification_direct(workspace, &delivery_state, &content, &message_id) {
693
- Ok(()) => {
694
- store.mark(&message_id, "delivered", None)?;
695
- outcome = crate::messaging::DeliveryOutcome {
696
- ok: true,
697
- status: crate::messaging::DeliveryStatus::Delivered,
698
- message_status: super::helpers::MessageStatusShadow("delivered".to_string()),
699
- message_id: Some(message_id),
700
- verification: None,
701
- stage: None,
702
- reason: None,
703
- channel: Some("leader_receiver".to_string()),
704
- };
705
- }
706
- Err(reason) => {
707
- event_log.write(
708
- "leader_receiver.direct_inject_skipped",
709
- serde_json::json!({
710
- "message_id": message_id,
711
- "reason": reason,
712
- }),
713
- )?;
692
+ // E15(F4.4 双投修):direct inject deliver **失败时的兜底**,不是无条件第二投。
693
+ // deliver loop 成功(outcome.ok) 跳过 direct inject,leader 仅收 deliver 那一条;
694
+ // 全失败 → direct inject 兜底投一条(不丢 result,守 #230/MUST-8)。两全:恰一条。
695
+ if !outcome.ok {
696
+ match inject_leader_notification_direct(workspace, &delivery_state, &content, &message_id) {
697
+ Ok(()) => {
698
+ store.mark(&message_id, "delivered", None)?;
699
+ outcome = crate::messaging::DeliveryOutcome {
700
+ ok: true,
701
+ status: crate::messaging::DeliveryStatus::Delivered,
702
+ message_status: super::helpers::MessageStatusShadow("delivered".to_string()),
703
+ message_id: Some(message_id),
704
+ verification: None,
705
+ stage: None,
706
+ reason: None,
707
+ channel: Some("leader_receiver".to_string()),
708
+ };
709
+ }
710
+ Err(reason) => {
711
+ event_log.write(
712
+ "leader_receiver.direct_inject_skipped",
713
+ serde_json::json!({
714
+ "message_id": message_id,
715
+ "reason": reason,
716
+ }),
717
+ )?;
718
+ }
714
719
  }
715
720
  }
716
721
  }
@@ -1435,3 +1435,21 @@ fn r8_attach_requeue_exhausted_to_notify_failed_golden_attach_event() {
1435
1435
  assert_eq!(ev.get("trigger").and_then(|v| v.as_str()), Some("attach_leader"));
1436
1436
  assert_eq!(ev.get("new_pane_id").and_then(|v| v.as_str()), Some("%leader-new"));
1437
1437
  }
1438
+
1439
+ // E15 (F4.4 双投修)·源码守卫:report_result 的 direct inject 必须被 `if !outcome.ok` 守为
1440
+ // deliver 失败时的真兜底,绝不在 deliver 成功后无条件再投一次(=用户看到两条同内容回复)。
1441
+ #[test]
1442
+ fn e15_direct_inject_is_gated_by_deliver_failure_not_unconditional() {
1443
+ let src = include_str!("../results.rs");
1444
+ // 锁:inject_leader_notification_direct 调用点之前必须有 `if !outcome.ok` 门。
1445
+ let gate = "if !outcome.ok {";
1446
+ let inject_call = "match inject_leader_notification_direct(";
1447
+ let gate_pos = src.find(gate);
1448
+ let inject_pos = src.find(inject_call);
1449
+ assert!(gate_pos.is_some(), "E15: direct inject must be gated by `if !outcome.ok` (deliver-fail fallback)");
1450
+ assert!(inject_pos.is_some(), "inject_leader_notification_direct call site must exist (do NOT delete it; #230 fallback)");
1451
+ assert!(
1452
+ gate_pos.unwrap() < inject_pos.unwrap(),
1453
+ "E15: the `if !outcome.ok` gate must precede the direct-inject call (deliver-success must skip inject → leader gets exactly one copy)"
1454
+ );
1455
+ }
@@ -1118,30 +1118,51 @@ fn scan_copilot_session_store(context: &CaptureSessionContext) -> Vec<CapturedSe
1118
1118
  ) else {
1119
1119
  return Vec::new();
1120
1120
  };
1121
- let cwd = context.spawn_cwd.to_string_lossy().to_string();
1122
- let mut stmt = match conn.prepare(
1123
- "select id from sessions where cwd = ?1 order by updated_at desc, id desc limit 1",
1124
- ) {
1125
- Ok(stmt) => stmt,
1126
- Err(_) => return Vec::new(),
1127
- };
1128
- let row: Option<String> = stmt
1129
- .query_row([cwd.as_str()], |row| row.get::<_, String>(0))
1130
- .ok();
1131
- let Some(session_id) = row else {
1121
+ // E11 层1(本机实锤):copilot honor `--session-id`(sessions.id == 注入的 expected_session_id),
1122
+ // worker 权威 id 可靠在 db。**expected-id 优先点查**:命中即返(High,直接根治 leader/worker
1123
+ // cwd 共享 db latest-wins 误抓 leader bug)。expected 查无 **不 promote**(E6 铁律:
1124
+ // 不硬写不在盘的假 session),回落 cwd-latest 让收敛重试。
1125
+ if let Some(expected) = context.expected_session_id.as_ref() {
1126
+ let hit: Option<String> = conn
1127
+ .prepare("select id from sessions where id = ?1 limit 1")
1128
+ .ok()
1129
+ .and_then(|mut stmt| {
1130
+ stmt.query_row([expected.as_str()], |row| row.get::<_, String>(0)).ok()
1131
+ });
1132
+ if let Some(session_id) = hit {
1133
+ return vec![copilot_candidate(session_id, &db_path, context)];
1134
+ }
1135
+ // expected 设了但 db 无该 id → 返空(收敛重试),绝不回落抓别人(尤其 leader)的 latest。
1132
1136
  return Vec::new();
1133
- };
1134
- vec![CapturedSessionCandidate {
1137
+ }
1138
+ // 无 expected → **返空**(保守,不 cwd-latest 猜)。
1139
+ // E11 层1 兜底洞(architect 核实):leader+worker 同 cwd 共享 db 时,cwd-latest 可能抓 leader
1140
+ // 的 session;而 allocator 的 claimed 去重只扫 state.agents,**leader 在 state.leader/team_owner
1141
+ // 不在 agents → 兜不住**;且 leader 的 copilot session_id 运行期不入 state(team_owner 只存
1142
+ // leader_session_uuid,非 copilot db id),故无从显式排除。所幸 copilot build_command_plan **总**
1143
+ // 注入 --session-id(expected_session_id 恒 Some)→ 真实 copilot worker 永走上面点查路径,
1144
+ // 此 expected=None 分支对真实 worker 不可达。故直接返空最干净:不猜、绝不把 leader session 分给
1145
+ // worker。db 留 _。
1146
+ let _ = (&db_path, &conn);
1147
+ Vec::new()
1148
+ }
1149
+
1150
+ fn copilot_candidate(
1151
+ session_id: String,
1152
+ db_path: &Path,
1153
+ context: &CaptureSessionContext,
1154
+ ) -> CapturedSessionCandidate {
1155
+ CapturedSessionCandidate {
1135
1156
  captured: CapturedSession {
1136
1157
  session_id: Some(SessionId::new(session_id)),
1137
- rollout_path: Some(RolloutPath::new(db_path.clone())),
1158
+ rollout_path: Some(RolloutPath::new(db_path.to_path_buf())),
1138
1159
  captured_via: CaptureVia::FsWatch,
1139
1160
  attribution_confidence: Confidence::High,
1140
1161
  spawn_cwd: context.spawn_cwd.clone(),
1141
1162
  },
1142
1163
  positive_agent_id_match: false,
1143
1164
  agent_path_match: false,
1144
- }]
1165
+ }
1145
1166
  }
1146
1167
 
1147
1168
  fn command_on_path(name: &str) -> bool {
@@ -1968,4 +1989,145 @@ mod e6_session_attribution_tests {
1968
1989
  let f = std::fs::OpenOptions::new().write(true).open(path).unwrap();
1969
1990
  f.set_modified(when).unwrap();
1970
1991
  }
1992
+
1993
+ // ── E11 层1:copilot session 归因(expected-id 优先,不抓 leader latest)──
1994
+ struct HomeGuard {
1995
+ prev: Option<std::ffi::OsString>,
1996
+ }
1997
+ impl HomeGuard {
1998
+ fn set(home: &Path) -> Self {
1999
+ let prev = std::env::var_os("HOME");
2000
+ std::env::set_var("HOME", home);
2001
+ Self { prev }
2002
+ }
2003
+ }
2004
+ impl Drop for HomeGuard {
2005
+ fn drop(&mut self) {
2006
+ match &self.prev {
2007
+ Some(v) => std::env::set_var("HOME", v),
2008
+ None => std::env::remove_var("HOME"),
2009
+ }
2010
+ }
2011
+ }
2012
+
2013
+ /// 造一个 copilot session-store.db,写 (id, cwd, updated_at) 行。
2014
+ fn seed_copilot_db(home: &Path, rows: &[(&str, &str, i64)]) {
2015
+ let dir = home.join(".copilot");
2016
+ std::fs::create_dir_all(&dir).unwrap();
2017
+ let conn = rusqlite::Connection::open(dir.join("session-store.db")).unwrap();
2018
+ conn.execute(
2019
+ "create table sessions (id text primary key, cwd text, updated_at integer)",
2020
+ [],
2021
+ )
2022
+ .unwrap();
2023
+ for (id, cwd, updated) in rows {
2024
+ conn.execute(
2025
+ "insert into sessions (id, cwd, updated_at) values (?1, ?2, ?3)",
2026
+ rusqlite::params![id, cwd, updated],
2027
+ )
2028
+ .unwrap();
2029
+ }
2030
+ }
2031
+
2032
+ #[test]
2033
+ #[serial_test::serial(env)]
2034
+ fn copilot_expected_id_wins_over_leader_latest_same_cwd() {
2035
+ // 真机复现的确定性 fixture:leader row updated 晚(latest),worker row id == expected。
2036
+ // capture 必返 worker 自己的 id,不返 leader 的 latest。
2037
+ let base = tmp_root("e11-copilot");
2038
+ let home = base.join("home");
2039
+ std::fs::create_dir_all(&home).unwrap();
2040
+ let cwd = base.join("ws");
2041
+ std::fs::create_dir_all(&cwd).unwrap();
2042
+ let worker_id = "1142c4c2-0000-4000-8000-000000000001";
2043
+ let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
2044
+ // leader updated_at 更大(latest-wins 会抓它);worker 更早。
2045
+ seed_copilot_db(
2046
+ &home,
2047
+ &[
2048
+ (worker_id, &cwd.to_string_lossy(), 100),
2049
+ (leader_id, &cwd.to_string_lossy(), 999),
2050
+ ],
2051
+ );
2052
+ let _h = HomeGuard::set(&home);
2053
+ let ctx = CaptureSessionContext {
2054
+ agent_id: "worker".to_string(),
2055
+ spawn_cwd: cwd.clone(),
2056
+ pane_id: None,
2057
+ pane_pid: None,
2058
+ spawned_at: None,
2059
+ expected_session_id: Some(SessionId::new(worker_id)),
2060
+ provider_projects_root: None,
2061
+ };
2062
+ let out = scan_copilot_session_store(&ctx);
2063
+ assert_eq!(out.len(), 1, "expected-id point query → single authoritative candidate");
2064
+ assert_eq!(
2065
+ out[0].captured.session_id.as_ref().unwrap().as_str(),
2066
+ worker_id,
2067
+ "must return worker's own (expected) session, NOT leader's latest"
2068
+ );
2069
+ drop(_h);
2070
+ let _ = std::fs::remove_dir_all(&base);
2071
+ }
2072
+
2073
+ #[test]
2074
+ #[serial_test::serial(env)]
2075
+ fn copilot_expected_id_absent_in_db_returns_empty_not_leader() {
2076
+ // expected 设了但 db 无该 id(会话还没落)→ 返空(收敛重试),绝不回落抓 leader latest。
2077
+ let base = tmp_root("e11-copilot-absent");
2078
+ let home = base.join("home");
2079
+ std::fs::create_dir_all(&home).unwrap();
2080
+ let cwd = base.join("ws");
2081
+ std::fs::create_dir_all(&cwd).unwrap();
2082
+ let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
2083
+ seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
2084
+ let _h = HomeGuard::set(&home);
2085
+ let ctx = CaptureSessionContext {
2086
+ agent_id: "worker".to_string(),
2087
+ spawn_cwd: cwd.clone(),
2088
+ pane_id: None,
2089
+ pane_pid: None,
2090
+ spawned_at: None,
2091
+ expected_session_id: Some(SessionId::new("1142c4c2-0000-4000-8000-000000000001")),
2092
+ provider_projects_root: None,
2093
+ };
2094
+ let out = scan_copilot_session_store(&ctx);
2095
+ assert!(
2096
+ out.is_empty(),
2097
+ "expected id absent in db → empty (no promote, no leader latest); got {out:?}"
2098
+ );
2099
+ drop(_h);
2100
+ let _ = std::fs::remove_dir_all(&base);
2101
+ }
2102
+
2103
+ #[test]
2104
+ #[serial_test::serial(env)]
2105
+ fn copilot_no_expected_same_cwd_only_leader_row_returns_empty_not_leader() {
2106
+ // E11 层1 兜底洞(architect):无 expected + 同 cwd 仅 leader row → 必返空,绝不返 leader。
2107
+ // (真实 copilot worker 恒有 expected,此分支不可达;保守返空堵住 allocator 不排除 leader 的洞。)
2108
+ let base = tmp_root("e11-copilot-noexp");
2109
+ let home = base.join("home");
2110
+ std::fs::create_dir_all(&home).unwrap();
2111
+ let cwd = base.join("ws");
2112
+ std::fs::create_dir_all(&cwd).unwrap();
2113
+ let leader_id = "f9c5485d-0000-4000-8000-00000000beef";
2114
+ seed_copilot_db(&home, &[(leader_id, &cwd.to_string_lossy(), 999)]);
2115
+ let _h = HomeGuard::set(&home);
2116
+ let ctx = CaptureSessionContext {
2117
+ agent_id: "worker".to_string(),
2118
+ spawn_cwd: cwd.clone(),
2119
+ pane_id: None,
2120
+ pane_pid: None,
2121
+ spawned_at: None,
2122
+ expected_session_id: None, // 无 expected → 兜底路径
2123
+ provider_projects_root: None,
2124
+ };
2125
+ let out = scan_copilot_session_store(&ctx);
2126
+ assert!(
2127
+ out.is_empty(),
2128
+ "no expected + only leader row in same cwd → must return empty, NOT leader; got {out:?}"
2129
+ );
2130
+ drop(_h);
2131
+ let _ = std::fs::remove_dir_all(&base);
2132
+ }
1971
2133
  }
@@ -422,6 +422,7 @@ fn py_repr_str(s: &str) -> String {
422
422
  mod tests {
423
423
  #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
424
424
  use super::*;
425
+ use serial_test::serial;
425
426
  use std::collections::HashMap;
426
427
  use std::sync::atomic::{AtomicU32, Ordering};
427
428
 
@@ -446,6 +447,32 @@ mod tests {
446
447
  ws
447
448
  }
448
449
 
450
+ struct EnvUnsetGuard {
451
+ previous: Vec<(&'static str, Option<String>)>,
452
+ }
453
+ impl EnvUnsetGuard {
454
+ fn unset(keys: &[&'static str]) -> Self {
455
+ let previous = keys
456
+ .iter()
457
+ .map(|key| (*key, std::env::var(key).ok()))
458
+ .collect::<Vec<_>>();
459
+ for key in keys {
460
+ std::env::remove_var(key);
461
+ }
462
+ Self { previous }
463
+ }
464
+ }
465
+ impl Drop for EnvUnsetGuard {
466
+ fn drop(&mut self) {
467
+ for (key, value) in self.previous.drain(..).rev() {
468
+ match value {
469
+ Some(value) => std::env::set_var(key, value),
470
+ None => std::env::remove_var(key),
471
+ }
472
+ }
473
+ }
474
+ }
475
+
449
476
  #[test]
450
477
  fn os_user_and_fingerprint() {
451
478
  assert_eq!(identity_os_user(&env(&[("USER", "alice")])), "alice");
@@ -560,7 +587,9 @@ mod tests {
560
587
  }
561
588
 
562
589
  #[test]
590
+ #[serial(env)]
563
591
  fn apply_first_time_binding_success_writes_state() {
592
+ let _env = EnvUnsetGuard::unset(&["TMUX", "TMUX_PANE"]);
564
593
  let ws = temp_ws();
565
594
  let now = "2026-06-02T09:17:59.994383+00:00";
566
595
  let mut state = json!({});
@@ -503,6 +503,50 @@
503
503
  );
504
504
  }
505
505
 
506
+ #[test]
507
+ fn has_pane_is_direct_existence_probe_not_liveness_guess() {
508
+ let (be, rec) = backend_with(MockResp::Out(ok("%7")), vec![]);
509
+ assert_eq!(be.has_pane(&PaneId::new("%7")).expect("has_pane"), Some(true));
510
+ let argv0 = rec.lock().unwrap()[0].clone();
511
+ assert!(
512
+ argv0.contains(&"display-message".to_string())
513
+ && argv0.iter().any(|x| x.contains("#{pane_id}"))
514
+ && argv0.contains(&"%7".to_string()),
515
+ "has_pane must use the cheap display-message #{{pane_id}} probe; got {argv0:?}"
516
+ );
517
+
518
+ let (be, _r) = backend_with(MockResp::Out(ok("")), vec![]);
519
+ assert_eq!(
520
+ be.has_pane(&PaneId::new("%9999")).expect("has_pane"),
521
+ Some(false),
522
+ "real tmux can report a missing pane as exit 0 with empty stdout"
523
+ );
524
+
525
+ let (be, _r) = backend_with(MockResp::Out(fail(1, "can't find pane: %9999")), vec![]);
526
+ assert_eq!(be.has_pane(&PaneId::new("%9999")).expect("has_pane"), Some(false));
527
+
528
+ let (be, _r) = backend_with(MockResp::Out(ok("%8")), vec![]);
529
+ assert_eq!(
530
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
531
+ None,
532
+ "a successful but mismatched pane id is not proof that the requested pane exists"
533
+ );
534
+
535
+ let (be, _r) = backend_with(MockResp::Out(ok("not-a-pane")), vec![]);
536
+ assert_eq!(
537
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
538
+ None,
539
+ "a successful but invalid pane id stays Unknown"
540
+ );
541
+
542
+ let (be, _r) = backend_with(MockResp::Out(fail(1, "error connecting to server: No such file or directory")), vec![]);
543
+ assert_eq!(
544
+ be.has_pane(&PaneId::new("%7")).expect("has_pane"),
545
+ None,
546
+ "server/probe errors remain Unknown, not absent"
547
+ );
548
+ }
549
+
506
550
  // ── CP-1: per-team socket — for_workspace injects `-L ta-<hash>` at the run chokepoint; new() does NOT ─
507
551
  #[test]
508
552
  fn for_workspace_backend_injects_per_team_socket_but_default_backend_does_not() {
@@ -18,6 +18,7 @@
18
18
  use std::collections::BTreeMap;
19
19
  use std::hash::{Hash, Hasher};
20
20
  use std::io::{Read, Write};
21
+ use std::os::unix::fs::FileTypeExt;
21
22
  use std::path::{Path, PathBuf};
22
23
  use std::process::Stdio;
23
24
  use std::time::{Duration, Instant};
@@ -331,6 +332,20 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
331
332
  }
332
333
 
333
334
  pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
335
+ if let Some(existing) = existing_socket_path_for_workspace(workspace) {
336
+ return Some(existing);
337
+ }
338
+ let uid = unsafe { libc::geteuid() };
339
+ let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
340
+ let default_root = default_root.canonicalize().unwrap_or(default_root);
341
+ Some(default_root.join(socket_name_for_workspace(workspace)))
342
+ }
343
+
344
+ pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
345
+ existing_socket_path_for_workspace(workspace).is_none()
346
+ }
347
+
348
+ fn existing_socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
334
349
  let socket_name = socket_name_for_workspace(workspace);
335
350
  let roots = tmux_socket_roots();
336
351
  for root in &roots {
@@ -340,12 +355,19 @@ pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
340
355
  return Some(candidate.canonicalize().unwrap_or(candidate));
341
356
  }
342
357
  }
343
- let uid = unsafe { libc::geteuid() };
344
- let default_root = PathBuf::from(format!("/tmp/tmux-{uid}"));
345
- let default_root = default_root
346
- .canonicalize()
347
- .unwrap_or(default_root);
348
- Some(default_root.join(socket_name))
358
+ None
359
+ }
360
+
361
+ pub(crate) fn socket_missing_hint_for_workspace(workspace: &Path) -> String {
362
+ let socket_name = socket_name_for_workspace(workspace);
363
+ let roots = tmux_socket_roots()
364
+ .into_iter()
365
+ .map(|root| root.display().to_string())
366
+ .collect::<Vec<_>>()
367
+ .join(", ");
368
+ format!(
369
+ "tmux socket {socket_name} not found under [{roots}]; run `team-agent attach-leader` or restart the team before attaching"
370
+ )
349
371
  }
350
372
 
351
373
  pub(crate) fn attach_command_for_workspace(
@@ -373,7 +395,7 @@ pub(crate) fn attach_commands_for_windows<'a>(
373
395
  .collect()
374
396
  }
375
397
 
376
- fn tmux_socket_roots() -> Vec<PathBuf> {
398
+ pub(crate) fn tmux_socket_roots() -> Vec<PathBuf> {
377
399
  let uid = unsafe { libc::geteuid() };
378
400
  let mut roots = vec![PathBuf::from(format!("/tmp/tmux-{uid}"))];
379
401
  if let Some(tmpdir) = std::env::var_os("TMPDIR") {
@@ -384,6 +406,29 @@ fn tmux_socket_roots() -> Vec<PathBuf> {
384
406
  roots
385
407
  }
386
408
 
409
+ pub(crate) fn tmux_socket_endpoints() -> Vec<String> {
410
+ let mut endpoints = Vec::new();
411
+ for root in tmux_socket_roots() {
412
+ let Ok(entries) = std::fs::read_dir(root) else {
413
+ continue;
414
+ };
415
+ for entry in entries.flatten() {
416
+ let Ok(file_type) = entry.file_type() else {
417
+ continue;
418
+ };
419
+ if !file_type.is_socket() {
420
+ continue;
421
+ }
422
+ let path = entry.path();
423
+ let path = path.canonicalize().unwrap_or(path);
424
+ endpoints.push(path.to_string_lossy().to_string());
425
+ }
426
+ }
427
+ endpoints.sort();
428
+ endpoints.dedup();
429
+ endpoints
430
+ }
431
+
387
432
  pub(crate) fn socket_name_from_tmux_env() -> Option<String> {
388
433
  let tmux = std::env::var("TMUX")
389
434
  .ok()
@@ -673,6 +718,10 @@ impl Transport for TmuxBackend {
673
718
  BackendKind::Tmux
674
719
  }
675
720
 
721
+ fn probes_real_tmux_socket_roots(&self) -> bool {
722
+ true
723
+ }
724
+
676
725
  fn spawn_first(
677
726
  &self,
678
727
  session: &SessionName,
@@ -859,6 +908,40 @@ impl Transport for TmuxBackend {
859
908
  }
860
909
  }
861
910
 
911
+ fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
912
+ let argv = self.tmux_argv(&[
913
+ "tmux".to_string(),
914
+ "display-message".to_string(),
915
+ "-p".to_string(),
916
+ "-t".to_string(),
917
+ pane.as_str().to_string(),
918
+ "#{pane_id}".to_string(),
919
+ ]);
920
+ let output = self.runner.run(&argv)?;
921
+ if output.success {
922
+ let pane_id = output.stdout.trim();
923
+ if pane_id.is_empty() {
924
+ return Ok(Some(false));
925
+ }
926
+ if pane_id == pane.as_str()
927
+ && pane_id.starts_with('%')
928
+ && pane_id[1..].chars().all(|ch| ch.is_ascii_digit())
929
+ {
930
+ return Ok(Some(true));
931
+ }
932
+ return Ok(None);
933
+ }
934
+ let stderr = output.stderr.to_ascii_lowercase();
935
+ if stderr.contains("can't find pane")
936
+ || stderr.contains("no such pane")
937
+ || (stderr.contains("can't find") && stderr.contains("pane"))
938
+ {
939
+ Ok(Some(false))
940
+ } else {
941
+ Ok(None)
942
+ }
943
+ }
944
+
862
945
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
863
946
  // P5 (C-P5-3): `#{pane_pid}` rides the single list-panes call (field index 11),
864
947
  // killing the per-pane display-message N+1 fallback.
@@ -18,18 +18,39 @@ pub struct SpawnRecord {
18
18
  pub argv: Vec<String>,
19
19
  }
20
20
 
21
- #[derive(Debug, Clone, Default)]
21
+ #[derive(Debug, Clone)]
22
22
  struct OfflineState {
23
23
  session_present: bool,
24
24
  session_absent_after_spawn_first: bool,
25
25
  targets: Vec<PaneInfo>,
26
26
  windows: Vec<WindowName>,
27
+ pane_presence: BTreeMap<String, bool>,
28
+ liveness: BTreeMap<String, PaneLiveness>,
29
+ default_liveness: PaneLiveness,
27
30
  calls: Vec<&'static str>,
28
31
  spawns: Vec<SpawnRecord>,
29
32
  inject_targets: Vec<Target>,
30
33
  inject_payloads: Vec<String>,
31
34
  }
32
35
 
36
+ impl Default for OfflineState {
37
+ fn default() -> Self {
38
+ Self {
39
+ session_present: false,
40
+ session_absent_after_spawn_first: false,
41
+ targets: Vec::new(),
42
+ windows: Vec::new(),
43
+ pane_presence: BTreeMap::new(),
44
+ liveness: BTreeMap::new(),
45
+ default_liveness: PaneLiveness::Unknown,
46
+ calls: Vec::new(),
47
+ spawns: Vec::new(),
48
+ inject_targets: Vec::new(),
49
+ inject_payloads: Vec::new(),
50
+ }
51
+ }
52
+ }
53
+
33
54
  #[derive(Debug, Clone, Default)]
34
55
  pub struct OfflineTransport {
35
56
  inner: Arc<Mutex<OfflineState>>,
@@ -60,6 +81,25 @@ impl OfflineTransport {
60
81
  self
61
82
  }
62
83
 
84
+ pub fn with_default_liveness(self, liveness: PaneLiveness) -> Self {
85
+ self.with_state(|state| state.default_liveness = liveness);
86
+ self
87
+ }
88
+
89
+ pub fn with_liveness(self, pane: impl Into<String>, liveness: PaneLiveness) -> Self {
90
+ self.with_state(|state| {
91
+ state.liveness.insert(pane.into(), liveness);
92
+ });
93
+ self
94
+ }
95
+
96
+ pub fn with_pane_presence(self, pane: impl Into<String>, present: bool) -> Self {
97
+ self.with_state(|state| {
98
+ state.pane_presence.insert(pane.into(), present);
99
+ });
100
+ self
101
+ }
102
+
63
103
  pub fn calls(&self) -> Vec<&'static str> {
64
104
  self.with_state(|state| state.calls.clone())
65
105
  }
@@ -198,9 +238,22 @@ impl Transport for OfflineTransport {
198
238
  Ok(None)
199
239
  }
200
240
 
201
- fn liveness(&self, _pane: &PaneId) -> Result<PaneLiveness, TransportError> {
202
- self.record("liveness");
203
- Ok(PaneLiveness::Unknown)
241
+ fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
242
+ Ok(self.with_state(|state| {
243
+ state.calls.push("liveness");
244
+ state
245
+ .liveness
246
+ .get(pane.as_str())
247
+ .copied()
248
+ .unwrap_or(state.default_liveness)
249
+ }))
250
+ }
251
+
252
+ fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
253
+ Ok(self.with_state(|state| {
254
+ state.calls.push("has_pane");
255
+ state.pane_presence.get(pane.as_str()).copied()
256
+ }))
204
257
  }
205
258
 
206
259
  fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
@@ -399,6 +399,12 @@ pub trait Transport: Send + Sync {
399
399
  /// 后端种类(诊断/事件用)。
400
400
  fn kind(&self) -> BackendKind;
401
401
 
402
+ /// Only the concrete tmux backend should scan real tmux socket roots.
403
+ /// Test doubles stay hermetic and use their injected probe results.
404
+ fn probes_real_tmux_socket_roots(&self) -> bool {
405
+ false
406
+ }
407
+
402
408
  // —— SPAWN(ST):所有后端天然满足;cwd/env 是 spawn 参数,无独立动词(§gap-setenv)——
403
409
 
404
410
  /// tmux=`new-session -d` / wezterm=`spawn --new-window` / conpty=`openpty`+spawn。
@@ -481,6 +487,13 @@ pub trait Transport: Send + Sync {
481
487
  /// pane 存活三态(`PaneLiveness`,bug-085 穷尽 match;unknown ≠ dead ≠ live)。
482
488
  fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError>;
483
489
 
490
+ /// Cheap direct pane existence check when a backend can prove it. `Ok(None)`
491
+ /// preserves the existing Unknown boundary.
492
+ fn has_pane(&self, pane: &PaneId) -> Result<Option<bool>, TransportError> {
493
+ let _ = pane;
494
+ Ok(None)
495
+ }
496
+
484
497
  // —— ENUMERATE / IDENTITY(SL + 进程探测):身份/rebind 地基 ——
485
498
 
486
499
  /// 全局枚举所有 pane + 每 pane 的 leader_env。tmux=`list-panes -a` + 读进程 env;