@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
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.3.7"
569
+ version = "0.3.9"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.3.7"
12
+ version = "0.3.9"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -133,13 +133,33 @@ pub fn cmd_quick_start(args: &QuickStartArgs) -> Result<CmdResult, CliError> {
133
133
  }
134
134
  Ok(result)
135
135
  } else {
136
- Ok(CmdResult::human(
137
- value
138
- .get("summary")
139
- .and_then(Value::as_str)
140
- .unwrap_or("quick-start complete"),
141
- ))
136
+ // E13:happy 人类路径必须带 attach_commands(json 路径 cli/mod.rs:1775 已有)。
137
+ Ok(CmdResult::human(&quickstart_human(&value)))
138
+ }
139
+ }
140
+
141
+ /// E13:quick-start "team 起了" 人类输出 = summary + attach 块。所有成功出口共用(别每分支手拷)
142
+ /// attach_commands 缺/空 → 只 summary(向后兼容)。
143
+ fn quickstart_human(value: &Value) -> String {
144
+ let summary = value
145
+ .get("summary")
146
+ .and_then(Value::as_str)
147
+ .unwrap_or("quick-start complete");
148
+ let attach: Vec<&str> = value
149
+ .get("attach_commands")
150
+ .and_then(Value::as_array)
151
+ .map(|items| items.iter().filter_map(Value::as_str).collect())
152
+ .unwrap_or_default();
153
+ if attach.is_empty() {
154
+ return summary.to_string();
155
+ }
156
+ let mut out = String::from(summary);
157
+ out.push_str("\n\nattach:");
158
+ for cmd in attach {
159
+ out.push_str("\n ");
160
+ out.push_str(cmd);
142
161
  }
162
+ out
143
163
  }
144
164
 
145
165
  /// `cmd_compile`(`commands.py:42`)。
@@ -1522,9 +1542,34 @@ pub fn cmd_doctor(args: &DoctorArgs) -> Result<CmdResult, CliError> {
1522
1542
  mod tests {
1523
1543
  #![allow(clippy::unwrap_used)]
1524
1544
 
1525
- use super::agent_pane_id;
1545
+ use super::{agent_pane_id, quickstart_human};
1526
1546
  use serde_json::json;
1527
1547
 
1548
+ // E13:happy 人类输出必须带 attach 块(此前 else 分支只打 summary 丢 attach_commands)。
1549
+ #[test]
1550
+ fn e13_quickstart_human_includes_attach_commands() {
1551
+ let value = json!({
1552
+ "summary": "team started",
1553
+ "attach_commands": [
1554
+ "tmux -S /tmp/ta-x attach -t team-y:w1",
1555
+ "tmux -S /tmp/ta-x attach -t team-y:w2",
1556
+ ],
1557
+ });
1558
+ let out = quickstart_human(&value);
1559
+ assert!(out.contains("team started"), "must keep summary; got {out}");
1560
+ assert!(out.contains("attach:"), "must render attach block; got {out}");
1561
+ assert!(out.contains("team-y:w1") && out.contains("team-y:w2"), "must list each attach cmd; got {out}");
1562
+ }
1563
+
1564
+ #[test]
1565
+ fn e13_quickstart_human_summary_only_when_no_attach() {
1566
+ let value = json!({"summary": "quick-start complete"});
1567
+ assert_eq!(quickstart_human(&value), "quick-start complete");
1568
+ // 空数组也只 summary。
1569
+ let value2 = json!({"summary": "s", "attach_commands": []});
1570
+ assert_eq!(quickstart_human(&value2), "s");
1571
+ }
1572
+
1528
1573
  #[test]
1529
1574
  fn agent_pane_id_resolves_session_window_even_with_recorded_pane_id() {
1530
1575
  let state = json!({
@@ -23,7 +23,7 @@ pub fn emit(output: &CmdOutput, as_json: bool) -> Option<String> {
23
23
  }
24
24
  }
25
25
 
26
- /// `main(argv)`(`parser.py:84`):**CLI 唯一进程入口**。codex/claude passthrough 早返回 →
26
+ /// `main(argv)`(`parser.py:84`):**CLI 唯一进程入口**。codex/claude/copilot passthrough 早返回 →
27
27
  /// 解析 argv 到 subcommand → 调对应 handler → 异常落盘 + 信封 + `ExitCode::Error` →
28
28
  /// `consume_leader_inbox_summary` → `emit` → `result.ok is False ? Error : Ok`。
29
29
  /// **行为入口**:契约可端到端跑 argv→(stdout, exit code)。
@@ -31,7 +31,7 @@ pub fn run(argv: &[String], cwd: &Path) -> ExitCode {
31
31
  let Some(command) = argv.first().map(String::as_str) else {
32
32
  return emit_missing_subcommand_usage();
33
33
  };
34
- if command == "codex" || command == "claude" {
34
+ if command == "codex" || command == "claude" || command == "copilot" {
35
35
  return cmd_leader_passthrough(command, &argv[1..], cwd)
36
36
  .map(emit_result)
37
37
  .unwrap_or(ExitCode::Error);
@@ -108,6 +108,7 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
108
108
  "watch" => cmd_watch(&watch_args(args, cwd)).map(emit_result),
109
109
  "sessions" => cmd_sessions(&sessions_args(args, cwd)).map(emit_result),
110
110
  "validate" => cmd_validate(&validate_args(args, cwd)).map(emit_result),
111
+ "install-skill" => cmd_install_skill(&install_skill_args(args)?).map(emit_result),
111
112
  "profile" => cmd_profile(&profile_args(args, cwd)?).map(emit_result),
112
113
  "validate-result" if has_arg(args, "--result") => {
113
114
  eprintln!("team-agent: error: unrecognized arguments: --result");
@@ -160,6 +161,7 @@ const DISPATCH_COMMANDS: &[&str] = &[
160
161
  "watch",
161
162
  "sessions",
162
163
  "validate",
164
+ "install-skill",
163
165
  "profile",
164
166
  "validate-result",
165
167
  "collect",
@@ -191,7 +193,7 @@ fn is_known_subcommand(command: &str) -> bool {
191
193
  fn command_help(command: Option<&str>) -> String {
192
194
  match command {
193
195
  None => {
194
- let mut commands = vec!["codex", "claude"];
196
+ let mut commands = vec!["codex", "claude", "copilot"];
195
197
  commands.extend_from_slice(DISPATCH_COMMANDS);
196
198
  commands.extend_from_slice(SPEC_ONLY_HELP_COMMANDS);
197
199
  format!(
@@ -230,6 +232,7 @@ fn command_help(command: Option<&str>) -> String {
230
232
  Some("watch") => "usage: team-agent watch [--workspace WORKSPACE] [--team TEAM]".to_string(),
231
233
  Some("sessions") => "usage: team-agent sessions [--workspace WORKSPACE] [--json]".to_string(),
232
234
  Some("validate") => "usage: team-agent validate [SPEC] [--json]".to_string(),
235
+ Some("install-skill") => "usage: team-agent install-skill (--source DIR | --uninstall) [--target codex|claude|copilot|all] [--dest DIR] [--dry-run] [--json]".to_string(),
233
236
  Some("profile") => "usage: team-agent profile COMMAND NAME [--workspace WORKSPACE] [--team TEAM] [--auth-mode MODE] [--json]".to_string(),
234
237
  Some("validate-result") => "usage: team-agent validate-result [ENVELOPE] [--file FILE|--result JSON] [--json]".to_string(),
235
238
  Some("collect") => "usage: team-agent collect [--workspace WORKSPACE] [--team TEAM] [--result-file FILE] [--json]".to_string(),
@@ -258,7 +261,7 @@ fn emit_unknown_subcommand_usage(command: &str) -> ExitCode {
258
261
 
259
262
  /// 在已知子命令里找与 `input` 最接近的一个(Levenshtein ≤ 阈值)。无足够接近者 → None。
260
263
  fn nearest_subcommand(input: &str) -> Option<&'static str> {
261
- let mut candidates: Vec<&'static str> = vec!["codex", "claude"];
264
+ let mut candidates: Vec<&'static str> = vec!["codex", "claude", "copilot"];
262
265
  candidates.extend_from_slice(DISPATCH_COMMANDS);
263
266
  candidates.extend_from_slice(SPEC_ONLY_HELP_COMMANDS);
264
267
  // 阈值随长度放宽,但短词收紧,避免 'x' 误配任何东西。
@@ -298,6 +301,115 @@ fn emit_usage_error(message: &str) {
298
301
  }
299
302
 
300
303
  /// `cmd_validate` delegates to runtime validate_file.
304
+ /// `install-skill` 参数(RED-1 根治:把 skill 安装单源收敛到二进制,install.mjs 调它)。
305
+ struct InstallSkillArgs {
306
+ target: crate::packaging::SkillTarget,
307
+ dest: Option<PathBuf>,
308
+ dry_run: bool,
309
+ /// `--uninstall`:删 target 的 skill 目标目录(单源,走同一 SkillTarget 表),不需 --source。
310
+ uninstall: bool,
311
+ source: Option<PathBuf>,
312
+ json: bool,
313
+ }
314
+
315
+ fn install_skill_args(args: &[String]) -> Result<InstallSkillArgs, CliError> {
316
+ let parsed = parse_args(args);
317
+ // `--target` 复用 parse_args.targets(codex|claude|copilot|all,默认 all)。
318
+ let target = match parsed.targets.as_deref() {
319
+ None | Some("all") => crate::packaging::SkillTarget::All,
320
+ Some("codex") => crate::packaging::SkillTarget::Codex,
321
+ Some("claude") => crate::packaging::SkillTarget::Claude,
322
+ Some("copilot") => crate::packaging::SkillTarget::Copilot,
323
+ Some(other) => {
324
+ return Err(CliError::Usage(format!(
325
+ "invalid --target: {other} (choose from codex, claude, copilot, all)"
326
+ )))
327
+ }
328
+ };
329
+ let uninstall = args.iter().any(|a| a == "--uninstall");
330
+ // `--source <dir>` 安装时必需(npm 包的 skills/team-agent;运行期无 CARGO_MANIFEST_DIR);
331
+ // 卸载不需要。
332
+ let source = flag_value(args, "--source").map(PathBuf::from);
333
+ if !uninstall && source.is_none() {
334
+ return Err(CliError::Usage("missing --source <skill dir>".to_string()));
335
+ }
336
+ let dest = flag_value(args, "--dest").map(PathBuf::from);
337
+ let dry_run = args.iter().any(|a| a == "--dry-run");
338
+ Ok(InstallSkillArgs {
339
+ target,
340
+ dest,
341
+ dry_run,
342
+ uninstall,
343
+ source,
344
+ json: parsed.json,
345
+ })
346
+ }
347
+
348
+ /// 取 `--flag <value>` 的值(用于 install-skill 的 --source/--dest,parse_args 不覆盖的旗标)。
349
+ fn flag_value(args: &[String], flag: &str) -> Option<String> {
350
+ args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
351
+ }
352
+
353
+ /// `team-agent install-skill`(RED-1 单源):repo `skills/team-agent` → `~/.codex|.claude|.copilot`。
354
+ /// install.mjs 删 JS 拷贝逻辑、改调本命令(`--target all --source <pkg>/skills/team-agent`)。
355
+ fn cmd_install_skill(args: &InstallSkillArgs) -> Result<CmdResult, CliError> {
356
+ // 卸载分支(单源:走同一 SkillTarget 表的 dest_dir;all → SINGLE_TARGETS 全集)。
357
+ if args.uninstall {
358
+ let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
359
+ let targets: Vec<crate::packaging::SkillTarget> = match args.target {
360
+ crate::packaging::SkillTarget::All => {
361
+ crate::packaging::SkillTarget::SINGLE_TARGETS.to_vec()
362
+ }
363
+ t => vec![t],
364
+ };
365
+ let mut removed: Vec<serde_json::Value> = Vec::new();
366
+ for t in targets {
367
+ if let Some(dest) = t.dest_dir(&home) {
368
+ let existed = dest.0.exists();
369
+ if existed && !args.dry_run {
370
+ std::fs::remove_dir_all(&dest.0).map_err(|e| CliError::Runtime(e.to_string()))?;
371
+ }
372
+ removed.push(serde_json::json!({
373
+ "target": t,
374
+ "dest": dest.0.to_string_lossy(),
375
+ "removed": existed,
376
+ "dry_run": args.dry_run,
377
+ }));
378
+ }
379
+ }
380
+ return Ok(CmdResult::from_json(
381
+ serde_json::json!({"ok": true, "uninstalled": removed}),
382
+ args.json,
383
+ ));
384
+ }
385
+ let source = args
386
+ .source
387
+ .clone()
388
+ .ok_or_else(|| CliError::Usage("missing --source <skill dir>".to_string()))?;
389
+ let outcomes = crate::packaging::install::install_skill(&crate::packaging::SkillInstallOptions {
390
+ target: args.target,
391
+ dest: args.dest.clone(),
392
+ dry_run: args.dry_run,
393
+ source,
394
+ })
395
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
396
+ let installed: Vec<serde_json::Value> = outcomes
397
+ .iter()
398
+ .map(|o| {
399
+ serde_json::json!({
400
+ "target": o.target,
401
+ "dest": o.dest.0.to_string_lossy(),
402
+ "dry_run": o.dry_run,
403
+ "removed_stale": o.removed_stale.len(),
404
+ })
405
+ })
406
+ .collect();
407
+ Ok(CmdResult::from_json(
408
+ serde_json::json!({"ok": true, "installed": installed}),
409
+ args.json,
410
+ ))
411
+ }
412
+
301
413
  pub fn cmd_validate(args: &ValidateArgs) -> Result<CmdResult, CliError> {
302
414
  let spec = resolve_path(&args.spec);
303
415
  let value = if spec.is_dir() {
@@ -1306,6 +1418,22 @@ mod tests {
1306
1418
  "top-level --help is missing spec-only help command `{command}`"
1307
1419
  );
1308
1420
  }
1421
+ assert!(
1422
+ top_help.contains("copilot"),
1423
+ "top-level leader passthrough help must list copilot"
1424
+ );
1425
+ }
1426
+
1427
+ #[test]
1428
+ fn copilot_is_listed_as_leader_passthrough_candidate() {
1429
+ assert!(command_help(None).contains("copilot"));
1430
+ assert_eq!(nearest_subcommand("copliot"), Some("copilot"));
1431
+ }
1432
+
1433
+ #[test]
1434
+ fn copilot_help_dispatches_as_leader_passthrough() {
1435
+ let cwd = tmp_workspace();
1436
+ assert_eq!(run(&cli_argv(&["copilot", "--help"]), &cwd), ExitCode::Ok);
1309
1437
  }
1310
1438
 
1311
1439
  #[test]
@@ -1,4 +1,4 @@
1
- //! cli · leader — `codex`/`claude` passthrough(`cmd_leader_passthrough` + `_provider_args` /
1
+ //! cli · leader — `codex`/`claude`/`copilot` passthrough(`cmd_leader_passthrough` + `_provider_args` /
2
2
  //! `_leader_launcher_args`)+ leader fallback inbox 摘要(`consume_leader_inbox_summary` 及
3
3
  //! `_leader_inbox_entries` / `_leader_inbox_summary` / `_leader_inbox_entry_title`)。
4
4
 
@@ -68,9 +68,9 @@ fn without_leader_json(values: &[String]) -> Vec<String> {
68
68
  out
69
69
  }
70
70
 
71
- /// `codex`/`claude` passthrough(`parser.py:86`/`_run_leader_passthrough`):leader 早返回,
71
+ /// `codex`/`claude`/`copilot` passthrough(`parser.py:86`/`_run_leader_passthrough`):leader 早返回,
72
72
  /// **不**进 subparser。`-h`/`--help` 打 usage 直接返回 [`CmdResult::none`]。否则解析 attach
73
- /// 旗标 + `lifecycle_port::start_leader`。`command` ∈ {codex, claude}。
73
+ /// 旗标 + `lifecycle_port::start_leader`。`command` ∈ {codex, claude, copilot}。
74
74
  pub fn cmd_leader_passthrough(
75
75
  command: &str,
76
76
  provider_args: &[String],
@@ -82,15 +82,19 @@ pub fn cmd_leader_passthrough(
82
82
  let as_json = leader_launcher_json(provider_args);
83
83
  let launcher_args = without_leader_json(provider_args);
84
84
  let attach = leader_launcher_args(&launcher_args)?;
85
- let provider = if command == "codex" {
86
- crate::model::enums::Provider::Codex
87
- } else {
88
- crate::model::enums::Provider::ClaudeCode
89
- };
85
+ let provider = leader_passthrough_provider(command);
90
86
  let value = lifecycle_port::start_leader(provider, &attach.provider_args, cwd, &attach)?;
91
87
  Ok(CmdResult::from_json(value, as_json))
92
88
  }
93
89
 
90
+ pub(crate) fn leader_passthrough_provider(command: &str) -> crate::model::enums::Provider {
91
+ match command {
92
+ "codex" => crate::model::enums::Provider::Codex,
93
+ "copilot" => crate::model::enums::Provider::Copilot,
94
+ _ => crate::model::enums::Provider::ClaudeCode,
95
+ }
96
+ }
97
+
94
98
  // =============================================================================
95
99
  // leader fallback inbox 摘要(helpers.py `consume_leader_inbox_summary`)
96
100
  // =============================================================================
@@ -168,6 +168,7 @@ pub mod lifecycle_port {
168
168
  let run_ws = crate::model::paths::canonical_run_workspace(workspace)
169
169
  .map_err(|e| CliError::Runtime(e.to_string()))?;
170
170
  let state = shutdown_state_for_team(&run_ws, team)?;
171
+ let state_for_kill = state.clone();
171
172
  let transport = if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
172
173
  crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint)
173
174
  } else {
@@ -176,17 +177,33 @@ pub mod lifecycle_port {
176
177
  let result =
177
178
  shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state));
178
179
  if team.is_none() {
179
- // B5/F1: the leader terminal (`team-agent claude`) lives on this same
180
- // workspace socket by design (leader/start.rs); a bare shutdown must not
181
- // `kill-server` it away. Spare `team-agent-leader-*` sessions and clear the
182
- // remaining non-leader sessions individually; only an empty-of-leader socket
183
- // gets the whole-server teardown (the original leak-cleanup intent).
180
+ // E12 (P0): the leader terminal lives on this socket by design. A bare shutdown must
181
+ // NOT `kill-server` it away. spare = state-anchor sessions ∪ `team-agent-leader-*`
182
+ // prefix sessions (union; cr E12 ①). kill_server only when the socket is exclusively
183
+ // ours (no spare + no foreign session); shared socket kill our sessions individually
184
+ // (cr E12 ②). All spare derivation comes from ONE snapshot (list_targets + the state
185
+ // already loaded) — no independent ps/tmux re-derivation (N39).
184
186
  let transport_dyn: &dyn crate::transport::Transport = &transport;
187
+ let pane_targets = transport_dyn.list_targets().unwrap_or_default();
185
188
  let sessions = socket_session_names(transport_dyn);
186
- match sessions_to_kill_sparing_leader(&sessions) {
187
- None => transport.kill_server(),
188
- Some(non_leader_sessions) => {
189
- for session in &non_leader_sessions {
189
+ let event_log = crate::event_log::EventLog::new(&run_ws);
190
+ let anchor_sessions =
191
+ anchor_sessions_from_state(&state_for_kill, &pane_targets, &event_log);
192
+ let decision = sessions_to_kill(&sessions, &anchor_sessions);
193
+ match decision {
194
+ KillDecision::KillServerExclusive => transport.kill_server(),
195
+ KillDecision::KillIndividually { to_kill, spared } => {
196
+ if !spared.is_empty() || to_kill.len() != sessions.len() {
197
+ // shared socket / leader spared → never whole-server teardown.
198
+ let _ = event_log.write(
199
+ "shutdown.kill_server_skipped_shared_socket",
200
+ json!({
201
+ "spared_sessions": spared.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
202
+ "killed_sessions": to_kill.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
203
+ }),
204
+ );
205
+ }
206
+ for session in &to_kill {
190
207
  let _ = transport_dyn.kill_session(session);
191
208
  }
192
209
  }
@@ -195,6 +212,29 @@ pub mod lifecycle_port {
195
212
  result
196
213
  }
197
214
 
215
+ /// E12 ①:从 state 锚 pane_id(leader_receiver/team_owner,top+teams)映射到其所在 session
216
+ /// (经同一帧 list_targets pane→session)。state 无任何锚 → 退命名判据 + spare_fallback event。
217
+ fn anchor_sessions_from_state(
218
+ state: &Value,
219
+ pane_targets: &[crate::transport::PaneInfo],
220
+ event_log: &crate::event_log::EventLog,
221
+ ) -> std::collections::BTreeSet<String> {
222
+ let anchor_pane_ids = collect_state_leader_anchor_pane_ids(state);
223
+ if anchor_pane_ids.is_empty() {
224
+ // 无锚(state 损坏/未记)→ 退纯命名前缀判据(下游 sessions_to_kill 仍 spare 前缀)。
225
+ let _ = event_log.write(
226
+ "shutdown.spare_fallback_to_naming",
227
+ json!({"reason": "no leader_receiver/team_owner pane anchor in state"}),
228
+ );
229
+ return std::collections::BTreeSet::new();
230
+ }
231
+ pane_targets
232
+ .iter()
233
+ .filter(|pane| anchor_pane_ids.contains(pane.pane_id.as_str()))
234
+ .map(|pane| pane.session.as_str().to_string())
235
+ .collect()
236
+ }
237
+
198
238
  fn socket_session_names(
199
239
  transport: &dyn crate::transport::Transport,
200
240
  ) -> Vec<crate::transport::SessionName> {
@@ -208,26 +248,37 @@ pub mod lifecycle_port {
208
248
  .collect()
209
249
  }
210
250
 
211
- /// B5/F1 pure kill decision for the bare-shutdown socket teardown.
212
- /// `None` => no `team-agent-leader-*` session on the socket → safe to kill the whole
213
- /// server. `Some(rest)` => leader present → kill only the non-leader sessions.
214
- pub(crate) fn sessions_to_kill_sparing_leader(
251
+ /// E12 下沉纯函数:bare-shutdown socket 拆除决策。
252
+ #[derive(Debug, Clone, PartialEq, Eq)]
253
+ pub(crate) enum KillDecision {
254
+ /// socket 独享(无 spare、无外来 session) 可整 server 拆除。
255
+ KillServerExclusive,
256
+ /// 有 spare(leader 锚/前缀)或非独享 → 逐 session kill,绝不 kill-server。
257
+ KillIndividually {
258
+ to_kill: Vec<crate::transport::SessionName>,
259
+ spared: Vec<crate::transport::SessionName>,
260
+ },
261
+ }
262
+
263
+ /// E12 纯决策(单测下沉):spare = `anchor_sessions` ∪ `team-agent-leader-*` 前缀(并集,锚优先)。
264
+ /// 全部 session 都不 spare 且非空 → `KillServerExclusive`(独享 socket 兜底);否则逐 session
265
+ /// kill 非 spare 的(共享 socket / leader 在 → 绝不整 server 拆)。空 session 集 → 逐 kill(no-op)。
266
+ pub(crate) fn sessions_to_kill(
215
267
  sessions: &[crate::transport::SessionName],
216
- ) -> Option<Vec<crate::transport::SessionName>> {
217
- let leader_present = sessions
218
- .iter()
219
- .any(|session| session.as_str().starts_with(crate::leader::LEADER_SESSION_PREFIX));
220
- leader_present.then(|| {
221
- sessions
222
- .iter()
223
- .filter(|session| {
224
- !session
225
- .as_str()
226
- .starts_with(crate::leader::LEADER_SESSION_PREFIX)
227
- })
228
- .cloned()
229
- .collect()
230
- })
268
+ anchor_sessions: &std::collections::BTreeSet<String>,
269
+ ) -> KillDecision {
270
+ let is_spared = |s: &crate::transport::SessionName| {
271
+ s.as_str().starts_with(crate::leader::LEADER_SESSION_PREFIX)
272
+ || anchor_sessions.contains(s.as_str())
273
+ };
274
+ let spared: Vec<_> = sessions.iter().filter(|s| is_spared(s)).cloned().collect();
275
+ let to_kill: Vec<_> = sessions.iter().filter(|s| !is_spared(s)).cloned().collect();
276
+ // 独享 = 非空 + 无 spare(socket 上每个 session 都是要 kill 的我方 session)。
277
+ if spared.is_empty() && !sessions.is_empty() {
278
+ KillDecision::KillServerExclusive
279
+ } else {
280
+ KillDecision::KillIndividually { to_kill, spared }
281
+ }
231
282
  }
232
283
 
233
284
  pub fn shutdown_with_transport(
@@ -1782,6 +1833,7 @@ pub mod lifecycle_port {
1782
1833
  session_name,
1783
1834
  state_path,
1784
1835
  next_actions,
1836
+ attach_commands,
1785
1837
  } => json!({
1786
1838
  "ok": false,
1787
1839
  "summary": "existing runtime",
@@ -1789,20 +1841,61 @@ pub mod lifecycle_port {
1789
1841
  "session_name": session_name.map(|s| s.as_str().to_string()),
1790
1842
  "state_path": state_path.map(|p| p.to_string_lossy().to_string()),
1791
1843
  "next_actions": next_actions,
1844
+ "attach_commands": attach_commands,
1792
1845
  }),
1793
1846
  crate::lifecycle::QuickStartReport::PreflightBlocked {
1794
1847
  summary,
1795
1848
  blockers,
1796
1849
  next_actions,
1850
+ attach_commands,
1797
1851
  } => json!({
1798
1852
  "ok": false,
1799
1853
  "summary": summary,
1800
1854
  "blockers": blockers,
1801
1855
  "next_actions": next_actions,
1856
+ "attach_commands": attach_commands,
1802
1857
  }),
1803
1858
  }
1804
1859
  }
1805
1860
 
1861
+ #[cfg(test)]
1862
+ mod quick_start_value_tests {
1863
+ use super::*;
1864
+
1865
+ #[test]
1866
+ fn existing_runtime_json_includes_attach_commands() {
1867
+ let value = quick_start_value(crate::lifecycle::QuickStartReport::ExistingRuntime {
1868
+ team: Some("teamA".to_string()),
1869
+ session_name: Some(crate::transport::SessionName::new("team-teamA")),
1870
+ state_path: Some(PathBuf::from("/tmp/state.json")),
1871
+ next_actions: vec!["restart".to_string()],
1872
+ attach_commands: vec![
1873
+ "tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker".to_string(),
1874
+ ],
1875
+ });
1876
+ assert_eq!(
1877
+ value.pointer("/attach_commands/0").and_then(Value::as_str),
1878
+ Some("tmux -S /tmp/tmux-501/ta-test attach -t team-teamA:worker"),
1879
+ "B-2: ExistingRuntime JSON must preserve attach_commands instead of only next_actions; value={value}"
1880
+ );
1881
+ }
1882
+
1883
+ #[test]
1884
+ fn preflight_blocked_json_includes_empty_attach_commands() {
1885
+ let value = quick_start_value(crate::lifecycle::QuickStartReport::PreflightBlocked {
1886
+ summary: "blocked".to_string(),
1887
+ blockers: vec!["missing TEAM.md".to_string()],
1888
+ next_actions: vec!["fix preflight blockers".to_string()],
1889
+ attach_commands: Vec::new(),
1890
+ });
1891
+ assert_eq!(
1892
+ value.get("attach_commands").and_then(Value::as_array).map(Vec::len),
1893
+ Some(0),
1894
+ "B-2: PreflightBlocked JSON must include attach_commands: [] for schema parity with Ready/Restart; value={value}"
1895
+ );
1896
+ }
1897
+ }
1898
+
1806
1899
  fn restart_value(report: crate::lifecycle::RestartReport) -> Value {
1807
1900
  match report {
1808
1901
  crate::lifecycle::RestartReport::Restarted {
@@ -617,5 +617,19 @@ Truncated: more fallback entries available; run team-agent inbox leader";
617
617
  assert_eq!(r.exit, ExitCode::Ok);
618
618
  let r2 = cmd_leader_passthrough("claude", &["--help".into()], Path::new(".")).unwrap();
619
619
  assert_eq!(r2.output, CmdOutput::None);
620
+ let r3 = cmd_leader_passthrough("copilot", &["--help".into()], Path::new(".")).unwrap();
621
+ assert_eq!(r3.output, CmdOutput::None);
620
622
  }
621
623
 
624
+ #[test]
625
+ fn cmd_leader_passthrough_maps_copilot_provider() {
626
+ assert_eq!(leader_passthrough_provider("codex"), crate::model::enums::Provider::Codex);
627
+ assert_eq!(
628
+ leader_passthrough_provider("claude"),
629
+ crate::model::enums::Provider::ClaudeCode
630
+ );
631
+ assert_eq!(
632
+ leader_passthrough_provider("copilot"),
633
+ crate::model::enums::Provider::Copilot
634
+ );
635
+ }