@team-agent/installer 0.5.104 → 0.5.106

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 (38) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +5 -3
  4. package/README.zh.md +4 -3
  5. package/crates/team-agent/src/cli/adapters.rs +17 -1
  6. package/crates/team-agent/src/cli/emit.rs +135 -18
  7. package/crates/team-agent/src/cli/mod.rs +156 -35
  8. package/crates/team-agent/src/cli/spec.rs +2 -2
  9. package/crates/team-agent/src/cli/status_port/brief.rs +62 -3
  10. package/crates/team-agent/src/cli/tests/run_delegation.rs +5 -2
  11. package/crates/team-agent/src/cli/tests/status_send.rs +2 -2
  12. package/crates/team-agent/src/cli/types.rs +3 -1
  13. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +6 -2
  14. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +6 -4
  15. package/crates/team-agent/src/lifecycle/launch/role_source.rs +1 -1
  16. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +7 -2
  17. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +8 -3
  18. package/crates/team-agent/src/lifecycle/launch.rs +4 -1
  19. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  20. package/crates/team-agent/src/lifecycle/restart/agent.rs +75 -0
  21. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +24 -17
  22. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  23. package/crates/team-agent/src/lifecycle/role_config.rs +1123 -0
  24. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +22 -0
  25. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +43 -0
  26. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +71 -0
  27. package/crates/team-agent/src/lifecycle/tests/restart_build_before_destroy_0540_contract.rs +6 -2
  28. package/crates/team-agent/src/state/projection.rs +15 -0
  29. package/crates/team-agent/src/state/repository.rs +2 -1
  30. package/crates/team-agent/src/tmux_backend/tests.rs +137 -0
  31. package/crates/team-agent/src/tmux_backend.rs +110 -0
  32. package/crates/team-agent/src/transport/test_support.rs +35 -0
  33. package/crates/team-agent/src/transport/tests/wire.rs +32 -3
  34. package/crates/team-agent/src/transport.rs +26 -12
  35. package/package.json +4 -4
  36. package/skills/team-agent/SKILL.md +55 -20
  37. package/skills/team-agent/references/recovery-runbook.md +2 -2
  38. package/skills/team-agent/references/team-agent-operator.md +14 -6
package/Cargo.lock CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.104"
584
+ version = "0.5.106"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.104"
12
+ version = "0.5.106"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
package/README.md CHANGED
@@ -82,9 +82,11 @@ Subagents are one-shot, fire-and-forget, and can't talk to each other. Team Agen
82
82
 
83
83
  ## Status
84
84
 
85
- `team-agent status` prints one concise row per registered node with seven fields:
86
- `name`, `provider`, `runtime_status`, `activity`, `health`, `session_name`, and `tmux_command`.
87
- `--json` exposes the same projection as structured JSON. Missing or conflicting evidence is
85
+ `team-agent status` prints one concise row per registered node with nine fields:
86
+ `name`, `provider`, `model`, `effort`, `runtime_status`, `activity`, `health`, `session_name`, and `tmux_command`.
87
+ `model` and `effort` are the last accepted launch settings, or `null` when unspecified;
88
+ pending role-file edits and provider defaults are not inferred.
89
+ `--json` exposes the same projection as structured JSON. Missing or conflicting runtime evidence is
88
90
  reported as `unknown`; a non-null `tmux_command` is safe to copy for the observed socket,
89
91
  session, window, and pane. `--summary` and `--detail` remain accepted for compatibility and
90
92
  do not add diagnostic or history fields. The accepted nodeprobe pairing and
package/README.zh.md CHANGED
@@ -82,9 +82,10 @@ subagent 是一次性的、干完即焚、彼此不通信。Team Agent 的队友
82
82
 
83
83
  ## 状态
84
84
 
85
- `team-agent status` 为每个已登记节点输出七个简要字段:
86
- `name`、`provider`、`runtime_status`、`activity`、`health`、`session_name`、`tmux_command`。
87
- `--json` 与人读输出使用同一信息投影。证据缺失或冲突时显示 `unknown`;非空
85
+ `team-agent status` 为每个已登记节点输出九个简要字段:
86
+ `name`、`provider`、`model`、`effort`、`runtime_status`、`activity`、`health`、`session_name`、`tmux_command`。
87
+ `model`、`effort` 来自最近已接受的启动配置,未设置时为 `null`;不会把待启动的角色编辑或猜测的 provider 默认值当作已生效。
88
+ `--json` 与人读输出使用同一信息投影。运行态证据缺失或冲突时显示 `unknown`;非空
88
89
  `tmux_command` 可复制到已观测的 socket、session、window 和 pane。`--summary` 与
89
90
  `--detail` 保留兼容性,但不会增加诊断或历史字段。已验收 nodeprobe 的配对方式、
90
91
  operator-trusted 能力 receipt 与 fail-closed 边界见
@@ -174,7 +174,7 @@ fn append_reminder(text: String, reminder: &str) -> String {
174
174
  }
175
175
  }
176
176
 
177
- /// `cmd_status`(`commands.py:90`)。CLI status 统一为只读七字段 brief;
177
+ /// `cmd_status`(`commands.py:90`)。CLI status 统一为只读九字段 brief;
178
178
  /// `--json` 与人读路径共享 native tmux/process projection;`--summary`/`--detail` 保留参数兼容性但不暴露诊断。
179
179
  #[cfg(test)]
180
180
  pub(crate) fn cmd_status(args: &StatusArgs) -> Result<CmdResult, CliError> {
@@ -1100,6 +1100,7 @@ pub fn cmd_start_agent(args: &StartAgentArgs) -> Result<CmdResult, CliError> {
1100
1100
  args.force,
1101
1101
  args.allow_fresh,
1102
1102
  args.team.as_deref(),
1103
+ &args.role_config,
1103
1104
  )?;
1104
1105
  if value.get("agent_ids").is_none() {
1105
1106
  if let Some(object) = value.as_object_mut() {
@@ -1142,6 +1143,7 @@ pub fn cmd_add_agent(args: &AddAgentArgs) -> Result<CmdResult, CliError> {
1142
1143
  &args.role_file,
1143
1144
  args.team.as_deref(),
1144
1145
  args.force,
1146
+ &args.role_config,
1145
1147
  )?;
1146
1148
  if value.get("agent_ids").is_none() {
1147
1149
  if let Some(object) = value.as_object_mut() {
@@ -1638,6 +1640,20 @@ mod tests {
1638
1640
  );
1639
1641
  }
1640
1642
 
1643
+ #[test]
1644
+ fn reminders_only_recommend_supported_result_commands() {
1645
+ let reminder = crate::cli::QUICK_START_REMINDER;
1646
+ assert!(reminder.contains("Use team-agent status / inbox instead."));
1647
+ for text in [
1648
+ reminder,
1649
+ crate::cli::STATUS_REMINDER,
1650
+ crate::cli::SEND_REMINDER,
1651
+ &quickstart_human(&json!({"summary": "team started"})),
1652
+ ] {
1653
+ assert!(!text.contains("collect"), "removed CLI command leaked: {text}");
1654
+ }
1655
+ }
1656
+
1641
1657
  #[test]
1642
1658
  fn e13_quickstart_human_summary_only_when_no_attach() {
1643
1659
  let value = json!({"summary": "quick-start complete"});
@@ -340,15 +340,21 @@ fn command_help(command: Option<&str>) -> String {
340
340
  )
341
341
  .to_string(),
342
342
  Some("allow-peer-talk") => "usage: team-agent allow-peer-talk A B [--workspace WORKSPACE] [--json]".to_string(),
343
- Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出七字段:name/provider/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。缺少可靠定位或原生 tmux/process 采样时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
343
+ Some("status") => "usage: team-agent status [AGENT] [--workspace WORKSPACE] [--team TEAM] [--summary|--json] [--detail]\n\n输出九字段:name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command;人读与 --json 使用同一投影。model/effort 为已接受启动配置,未设置为 null,不猜 provider 默认值。缺少可靠定位或原生 tmux/process 采样时显示 unknown;tmux_command 可复制到对应目标。--summary/--detail 仅保留兼容性,不增加诊断字段。".to_string(),
344
344
  Some("models") => "usage: team-agent models [--provider pi|cursor_agent|codex|claude|claude_code] [QUERY|--search TEXT] [--json]\n\nLists exact provider model ids with case-insensitive multi-word search across provider, vendor, id, display name, and source aliases. Uses each provider's native catalog; Codex uses `codex debug models` and Claude uses SDK stream-json initialize.".to_string(),
345
345
  Some("leaders") => "usage: team-agent leaders [QUERY|--search TEXT] [--all|--stale] [--json] | --prune [--dry-run] [--json]\n\nLists LIVE leaders by default. Use --all to include retained STALE entries, --stale to inspect only STALE entries, QUERY or --search TEXT to match workspace/team/name fields, and --prune to remove only entries proven terminal by canonical state. --dry-run is valid only with --prune.".to_string(),
346
346
  Some("shutdown") => "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]".to_string(),
347
347
  Some("restart") => "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json] [--detail]".to_string(),
348
348
  Some("reset-agent") => "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--json]".to_string(),
349
- Some("start-agent") => "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--json]\n\nAfter a successful start, use the returned `send_commands` with `team-agent send AGENT MESSAGE`.".to_string(),
349
+ Some(name @ ("start-agent" | "add-agent")) => command_spec(name)
350
+ .map(|spec| format!(
351
+ "{}\n\n{}\n\nAfter a successful {}, use the returned `send_commands` with `team-agent send AGENT MESSAGE`.",
352
+ spec.usage,
353
+ spec.summary,
354
+ if name == "start-agent" { "start" } else { "add" },
355
+ ))
356
+ .unwrap_or_else(|| format!("usage: team-agent {name} [options]")),
350
357
  Some("stop-agent") => "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
351
- Some("add-agent") => "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--json]\n\nAfter a successful add, use the returned `send_commands` with `team-agent send AGENT MESSAGE`.".to_string(),
352
358
  Some("clone-agent") => "usage: team-agent clone-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
353
359
  Some("fork-agent") => "usage: team-agent fork-agent SOURCE_AGENT --as AGENT [--label LABEL] [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
354
360
  Some("remove-agent") => "usage: team-agent remove-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--from-spec] [--confirm] [--force] [--json]".to_string(),
@@ -1430,9 +1436,107 @@ fn restart_args(args: &[String], cwd: &Path) -> Result<RestartArgs, CliError> {
1430
1436
  })
1431
1437
  }
1432
1438
 
1439
+ // Keep strict role CLI parsing separate from the legacy shared parser.
1440
+ fn role_agent_args(args: &[String], add: bool) -> Result<(ParsedArgs, crate::lifecycle::role_config::RoleConfigPatch), CliError> {
1441
+ let mut role = crate::lifecycle::role_config::RoleConfigPatch::default();
1442
+ let mut forwarded = Vec::new();
1443
+ let mut seen = std::collections::BTreeSet::new();
1444
+ let mut index = 0;
1445
+ while index < args.len() {
1446
+ let argument = &args[index];
1447
+ if !argument.starts_with('-') {
1448
+ forwarded.push(argument.clone());
1449
+ index += 1;
1450
+ continue;
1451
+ }
1452
+ let (flag, inline) = argument.split_once('=').map_or((argument.as_str(), None), |(key, value)| (key, Some(value)));
1453
+ if !seen.insert(flag.to_string()) { return Err(CliError::Usage(format!("duplicate {flag}"))); }
1454
+ if matches!(flag, "--json" | "--allow-fresh" | "--force") {
1455
+ if inline.is_some() || (add && flag == "--allow-fresh") {
1456
+ return Err(CliError::Usage(format!("unsupported option: {argument}")));
1457
+ }
1458
+ forwarded.push(flag.to_string());
1459
+ index += 1;
1460
+ continue;
1461
+ }
1462
+ if !matches!(flag, "--provider" | "--model" | "--effort" | "--bypass" | "--prompt" | "--profile" | "--workspace" | "--team" | "--role-file")
1463
+ || (!add && flag == "--role-file") {
1464
+ return Err(CliError::Usage(format!("unknown option: {flag}")));
1465
+ }
1466
+ let value = if let Some(value) = inline { value.to_string() } else {
1467
+ index += 1;
1468
+ args.get(index).filter(|value| !value.starts_with("--"))
1469
+ .ok_or_else(|| CliError::Usage(format!("missing value for {flag}")))?.clone()
1470
+ };
1471
+ if value.trim().is_empty() { return Err(CliError::Usage(format!("empty value for {flag}"))); }
1472
+ match flag {
1473
+ "--provider" => {
1474
+ let provider = crate::provider::wire::parse_provider(&value)
1475
+ .ok_or_else(|| CliError::Usage(format!("unknown provider: {value}")))?;
1476
+ role.provider = Some(crate::provider::wire::provider_wire(provider).to_string());
1477
+ }
1478
+ "--bypass" => role.bypass = Some(match value.as_str() {
1479
+ "true" => true, "false" => false,
1480
+ _ => return Err(CliError::Usage("--bypass requires true or false".into())),
1481
+ }),
1482
+ "--model" => role.model = Some(value),
1483
+ "--effort" => {
1484
+ if crate::model::enums::ProviderEffort::parse(&value).is_none() {
1485
+ return Err(CliError::Usage(format!("unknown effort: {value}")));
1486
+ }
1487
+ role.effort = Some(value);
1488
+ }
1489
+ "--prompt" => role.prompt = Some(value),
1490
+ "--profile" => role.profile = Some(value),
1491
+ _ => { forwarded.push(flag.to_string()); forwarded.push(value); }
1492
+ }
1493
+ index += 1;
1494
+ }
1495
+ let parsed = parse_args(&forwarded);
1496
+ if add && parsed.role_file.is_none() {
1497
+ if role.provider.is_none() { return Err(CliError::Usage("add-agent requires --provider <name>".into())); }
1498
+ if role.bypass.is_none() { return Err(CliError::Usage("add-agent requires --bypass <true|false>".into())); }
1499
+ }
1500
+ if parsed.positionals.len() != 1 { return Err(CliError::Usage("expected exactly one agent id".into())); }
1501
+ Ok((parsed, role))
1502
+ }
1503
+
1504
+ #[cfg(test)]
1505
+ mod role_cli_tests {
1506
+ use super::*;
1507
+ fn strings(values: &[&str]) -> Vec<String> { values.iter().map(|value| (*value).to_string()).collect() }
1508
+
1509
+ #[test]
1510
+ fn add_requires_decisions_without_a_template_but_accepts_file_input() {
1511
+ for args in [vec!["w"], vec!["w", "--provider", "pi"], vec!["w", "--bypass", "false"]] {
1512
+ assert!(role_agent_args(&strings(&args), true).is_err());
1513
+ }
1514
+ assert!(role_agent_args(&strings(&["w", "--role-file", "w.md"]), true).is_ok());
1515
+ let (parsed, _) = role_agent_args(&strings(&["w", "--role-file", "w.md", "--force"]), true).unwrap();
1516
+ assert!(parsed.force);
1517
+ assert!(role_agent_args(&strings(&["w", "--role-file", "w.md", "--force=true"]), true).is_err());
1518
+ assert!(role_agent_args(&strings(&["w", "--role-file", "w.md", "--allow-fresh"]), true).is_err());
1519
+ let (_, patch) = role_agent_args(&strings(&["w", "--provider=pi", "--bypass=false"]), true).unwrap();
1520
+ assert_eq!(patch.provider.as_deref(), Some("pi"));
1521
+ assert_eq!(patch.bypass, Some(false));
1522
+ }
1523
+
1524
+ #[test]
1525
+ fn strict_role_options_reject_ambiguous_values() {
1526
+ for args in [
1527
+ vec!["w", "--bypass"], vec!["w", "--bypass", "yes"],
1528
+ vec!["w", "--no-bypass"], vec!["w", "--bypass", "true", "--bypass=false"],
1529
+ vec!["w", "--unknown", "x"], vec!["w", "extra"],
1530
+ ] { assert!(role_agent_args(&strings(&args), false).is_err()); }
1531
+ let (_, patch) = role_agent_args(&strings(&["w"]), false).unwrap();
1532
+ assert_eq!(patch, crate::lifecycle::role_config::RoleConfigPatch::default());
1533
+ }
1534
+ }
1535
+
1433
1536
  fn start_agent_args(args: &[String], cwd: &Path) -> Result<StartAgentArgs, CliError> {
1434
- let parsed = parse_args(args);
1537
+ let (parsed, role_config) = role_agent_args(args, false)?;
1435
1538
  Ok(StartAgentArgs {
1539
+ role_config,
1436
1540
  agent: required_pos(&parsed, 0, "agent")?,
1437
1541
  workspace: workspace(&parsed, cwd),
1438
1542
  team: parsed.team,
@@ -1466,14 +1570,13 @@ fn reset_agent_args(args: &[String], cwd: &Path) -> Result<ResetAgentArgs, CliEr
1466
1570
  }
1467
1571
 
1468
1572
  fn add_agent_args(args: &[String], cwd: &Path) -> Result<AddAgentArgs, CliError> {
1469
- let parsed = parse_args(args);
1573
+ let (parsed, role_config) = role_agent_args(args, true)?;
1470
1574
  Ok(AddAgentArgs {
1575
+ role_config,
1471
1576
  agent: required_pos(&parsed, 0, "agent")?,
1472
1577
  workspace: workspace(&parsed, cwd),
1473
1578
  team: parsed.team,
1474
- role_file: parsed
1475
- .role_file
1476
- .ok_or_else(|| CliError::Usage("missing --role-file".to_string()))?,
1579
+ role_file: parsed.role_file.unwrap_or_default(),
1477
1580
  force: parsed.force,
1478
1581
  json: parsed.json,
1479
1582
  })
@@ -2076,11 +2179,8 @@ mod tests {
2076
2179
  (
2077
2180
  "start-agent",
2078
2181
  &[
2079
- "--workspace",
2080
- "--team",
2081
- "--force",
2082
- "--allow-fresh",
2083
- "--json",
2182
+ "--model", "--effort", "--bypass", "--prompt", "--profile", "--provider",
2183
+ "--workspace", "--team", "--allow-fresh", "--json",
2084
2184
  ][..],
2085
2185
  ),
2086
2186
  (
@@ -2095,10 +2195,8 @@ mod tests {
2095
2195
  (
2096
2196
  "add-agent",
2097
2197
  &[
2098
- "--role-file",
2099
- "--workspace",
2100
- "--team",
2101
- "--json",
2198
+ "--role-file", "--provider", "--bypass", "--model", "--effort", "--prompt", "--profile",
2199
+ "--force", "--workspace", "--team", "--json",
2102
2200
  ][..],
2103
2201
  ),
2104
2202
  (
@@ -2185,11 +2283,30 @@ mod tests {
2185
2283
  );
2186
2284
  }
2187
2285
 
2286
+ #[test]
2287
+ fn worker_lifecycle_help_uses_authoritative_spec_usage_and_summary() {
2288
+ for name in ["start-agent", "add-agent"] {
2289
+ let spec = command_spec(name).expect("registered worker lifecycle command");
2290
+ let help = command_help(Some(name));
2291
+ assert_eq!(help.lines().next(), Some(spec.usage));
2292
+ assert!(help.contains(spec.summary));
2293
+ for flag in ["--model SLUG", "--effort LEVEL", "--bypass true|false", "--prompt TEXT", "--profile NAME", "--provider NAME"] {
2294
+ assert!(help.contains(flag), "{name} help missing {flag}: {help}");
2295
+ }
2296
+ assert!(help.contains("team-agent send AGENT MESSAGE"));
2297
+ }
2298
+ assert!(!command_help(Some("start-agent")).contains("--force"));
2299
+ let add = command_help(Some("add-agent"));
2300
+ assert!(add.contains("[--role-file FILE]"));
2301
+ assert!(add.contains("provider/bypass required from CLI or role file"));
2302
+ assert!(add.contains("conflicting values rejected"));
2303
+ }
2304
+
2188
2305
  #[test]
2189
2306
  fn status_help_describes_brief_projection_and_unknown_boundary() {
2190
2307
  let help = command_help(Some("status"));
2191
2308
  for marker in [
2192
- "name/provider/runtime_status/activity/health/session_name/tmux_command",
2309
+ "name/provider/model/effort/runtime_status/activity/health/session_name/tmux_command",
2193
2310
  "人读与 --json 使用同一投影",
2194
2311
  "显示 unknown",
2195
2312
  "tmux_command",
@@ -49,7 +49,7 @@ use crate::messaging::{self, MessageTarget, SendOptions, TrustedSender};
49
49
  use crate::model::ids::{TaskId, TeamKey};
50
50
 
51
51
  pub(crate) const COMMS_BOUNDARY_TEXT: &str = "validates live pane binding consistency and zero-token comms contracts. Does NOT perform live runtime message round-trip. (zero token, zero pollution)";
52
- pub(crate) const QUICK_START_REMINDER: &str = "Reminder: Do not inspect raw worker pane output during normal operation. Use team-agent status / inbox / collect instead. Wait for report_result.";
52
+ pub(crate) const QUICK_START_REMINDER: &str = "Reminder: Do not inspect raw worker pane output during normal operation. Use team-agent status / inbox instead. Wait for report_result.";
53
53
  pub(crate) const SEND_REMINDER: &str = "Message delivered. Wait for the worker to report_result. Do not poll the worker terminal with capture-pane.";
54
54
  pub(crate) const STATUS_REMINDER: &str = "Results are finalized automatically; use --watch-result or team-agent inbox AGENT -n 3 for transport fallback. Do not capture-pane worker terminals.";
55
55
 
@@ -707,19 +707,7 @@ pub mod lifecycle_port {
707
707
  }
708
708
 
709
709
  fn state_generation(state: &Value) -> Option<&str> {
710
- state
711
- .get("generation")
712
- .and_then(Value::as_str)
713
- .or_else(|| {
714
- state
715
- .get("agents")
716
- .and_then(Value::as_object)
717
- .and_then(|agents| {
718
- agents.values().find_map(|agent| {
719
- agent.get("spawned_at").and_then(Value::as_str)
720
- })
721
- })
722
- })
710
+ crate::state::projection::session_generation(state)
723
711
  }
724
712
 
725
713
  fn state_for_session<'a>(state: &'a Value, session: &str) -> Option<&'a Value> {
@@ -777,12 +765,23 @@ pub mod lifecycle_port {
777
765
  if owner.generation != expected_generation {
778
766
  return SessionOwnership::Foreign;
779
767
  }
780
- if session_targets.iter().any(|target| {
781
- target.current_path.as_ref().map_or(true, |path| {
782
- !path_is_under(path.as_path(), workspace)
783
- })
784
- }) {
785
- return SessionOwnership::Unknown;
768
+ for target in session_targets {
769
+ if target.current_path.as_ref().is_some_and(|path| {
770
+ path_is_under(path.as_path(), workspace)
771
+ }) {
772
+ continue;
773
+ }
774
+ // An exited keepalive has no cwd or live process. Exempt only the
775
+ // internal anchor, with native positive dead-pane proof, AFTER the
776
+ // session workspace/team/generation ownership gates above.
777
+ let dead_anchor = target.current_path.as_ref().is_none_or(|path| {
778
+ path.as_os_str().is_empty()
779
+ }) && target.window_name.as_ref().is_some_and(|window| {
780
+ window.as_str() == crate::tmux_backend::KEEPALIVE_WINDOW_NAME
781
+ }) && matches!(transport.pane_is_dead(&target.pane_id), Ok(Some(true)));
782
+ if !dead_anchor {
783
+ return SessionOwnership::Unknown;
784
+ }
786
785
  }
787
786
  SessionOwnership::Owned
788
787
  }
@@ -793,7 +792,135 @@ pub mod lifecycle_port {
793
792
 
794
793
  use super::*;
795
794
  use crate::transport::test_support::OfflineTransport;
796
- use crate::transport::{PaneId, PaneInfo, SessionName};
795
+ use crate::transport::{PaneId, PaneInfo, SessionName, Transport, WindowName};
796
+
797
+ fn ownership_pane(session: &SessionName, pane: &str, window: &str, cwd: Option<PathBuf>) -> PaneInfo {
798
+ PaneInfo {
799
+ pane_id: PaneId::new(pane),
800
+ session: session.clone(),
801
+ window_index: None,
802
+ window_name: Some(WindowName::new(window)),
803
+ pane_index: None,
804
+ tty: None,
805
+ current_command: None,
806
+ current_path: cwd,
807
+ active: false,
808
+ pane_pid: None,
809
+ leader_env: BTreeMap::new(),
810
+ }
811
+ }
812
+
813
+ #[test]
814
+ fn dead_anchor_ownership_requires_native_dead_proof_and_all_owner_markers() {
815
+ let workspace = std::env::temp_dir().canonicalize().unwrap();
816
+ let session = SessionName::new("owned");
817
+ let state = json!({"team_key": "owned", "generation": "g"});
818
+ for (window, cwd, dead, team, generation, expected) in [
819
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(true), "owned", "g", SessionOwnership::Owned),
820
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(false), "owned", "g", SessionOwnership::Unknown),
821
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, None, "owned", "g", SessionOwnership::Unknown),
822
+ ("foreign", None, Some(true), "owned", "g", SessionOwnership::Unknown),
823
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, Some(PathBuf::from("/foreign-workspace")), Some(true), "owned", "g", SessionOwnership::Unknown),
824
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(true), "other-team", "g", SessionOwnership::Foreign),
825
+ (crate::tmux_backend::KEEPALIVE_WINDOW_NAME, None, Some(true), "owned", "other-generation", SessionOwnership::Foreign),
826
+ ] {
827
+ let mut transport = OfflineTransport::new().with_session_owner(&workspace, team, generation);
828
+ if let Some(dead) = dead { transport = transport.with_pane_dead("%1", dead); }
829
+ let targets = vec![
830
+ ownership_pane(&session, "%2", "assistant", Some(workspace.clone())),
831
+ ownership_pane(&session, "%1", window, cwd),
832
+ ];
833
+ assert_eq!(session_ownership(&workspace, &state, &transport, &session, &targets), expected);
834
+ if expected == SessionOwnership::Foreign {
835
+ assert!(!transport.calls().contains(&"pane_is_dead"), "owner gates must precede exemption");
836
+ }
837
+ assert!(!transport.calls().contains(&"kill_session"));
838
+ }
839
+ }
840
+
841
+ #[test]
842
+ #[cfg(unix)]
843
+ #[ignore = "isolated native tmux: explicit Grok execution, no provider subscription"]
844
+ fn dead_anchor_scoped_shutdown_reaps_worker_wrapper_and_spares_other_team() {
845
+ let root = std::env::temp_dir().join(format!(
846
+ "ta-r2-{}-{}", std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap(),
847
+ ));
848
+ std::fs::create_dir(&root).unwrap();
849
+ let root = root.canonicalize().unwrap();
850
+ let workspace = root.join("owned");
851
+ let foreign_workspace = root.join("foreign");
852
+ std::fs::create_dir(&workspace).unwrap();
853
+ std::fs::create_dir(&foreign_workspace).unwrap();
854
+ let endpoint = root.join("sock");
855
+ let transport = crate::tmux_backend::TmuxBackend::with_runner_for_tmux_endpoint(
856
+ Box::new(crate::tmux_backend::RealCommandRunner), endpoint.to_str().unwrap(),
857
+ );
858
+ let session = SessionName::new("round2-owned");
859
+ let foreign = SessionName::new("round2-foreign");
860
+ struct Cleanup<'a>(&'a crate::tmux_backend::TmuxBackend, [&'a SessionName; 2], &'a Path);
861
+ impl Drop for Cleanup<'_> {
862
+ fn drop(&mut self) {
863
+ for session in self.1 { let _ = self.0.kill_session(session); }
864
+ let _ = std::fs::remove_dir_all(self.2);
865
+ }
866
+ }
867
+ let _cleanup = Cleanup(&transport, [&session, &foreign], &root);
868
+ let env = BTreeMap::new();
869
+ let argv = vec!["sh".to_string(), "-c".to_string(), "exit 0".to_string()];
870
+ let first = transport.spawn_first_with_worker_shell_wrapper(
871
+ &session, &WindowName::new("assistant"), &argv, &workspace, &env, &[], "pi",
872
+ ).unwrap();
873
+ transport.set_session_owner_with_generation(&session, &workspace, "round2-owned", "g").unwrap();
874
+ transport.preserve_session_before_stop(&session, &[crate::transport::Target::Pane(first.pane_id.clone())]).unwrap();
875
+ transport.kill_pane(&first.pane_id).unwrap();
876
+ let worker = transport.spawn_into_with_worker_shell_wrapper(
877
+ &session, &WindowName::new("assistant"), &argv, &workspace, &env, &[], "pi",
878
+ ).unwrap();
879
+ let foreign_worker = transport.spawn_first(
880
+ &foreign, &WindowName::new("other"), &["sleep".to_string(), "60".to_string()],
881
+ &foreign_workspace, &env,
882
+ ).unwrap();
883
+ transport.set_session_owner_with_generation(&foreign, &foreign_workspace, "round2-foreign", "foreign-g").unwrap();
884
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(3);
885
+ let marker = format!("{} 0", crate::tmux_backend::worker_provider_exit_marker("pi"));
886
+ loop {
887
+ // This controlled fixture prints nothing: the worker wrapper
888
+ // and its inert tail supply these two lines. provider_exit_status
889
+ // is a leader-only receipt, not a worker-wrapper observation.
890
+ let captured = transport.capture(
891
+ &crate::transport::Target::Pane(worker.pane_id.clone()),
892
+ crate::transport::CaptureRange::Full,
893
+ ).unwrap();
894
+ if captured.text.contains(&marker)
895
+ && captured.text.contains("Provider exited; this pane no longer accepts input.")
896
+ {
897
+ break;
898
+ }
899
+ assert!(std::time::Instant::now() < deadline, "worker must reach its inert exit wrapper: {}", captured.text);
900
+ std::thread::sleep(std::time::Duration::from_millis(20));
901
+ }
902
+ assert_eq!(transport.pane_is_dead(&worker.pane_id).unwrap(), Some(false));
903
+ let worker_pid = worker.child_pid.unwrap();
904
+ assert!(crate::platform::process::pid_is_alive(worker_pid));
905
+ let state = json!({
906
+ "team_key": "round2-owned", "active_team_key": "round2-owned",
907
+ "session_name": session.as_str(), "tmux_socket": endpoint.to_str().unwrap(),
908
+ "generation": "g", "is_external_leader": true,
909
+ "agents": {"assistant": {
910
+ "status": "running", "provider": "pi", "model": "openai-codex/gpt-6-luna", "effort": "max",
911
+ "window": "assistant", "pane_id": worker.pane_id.as_str(), "pane_pid": worker_pid,
912
+ "spawned_at": "new-worker-birth-after-start"
913
+ }}
914
+ });
915
+ crate::state::persist::save_runtime_state(&workspace, &state).unwrap();
916
+ let result = shutdown_with_transport_and_state(&workspace, true, None, &transport, Some(state)).unwrap();
917
+ assert_eq!(result["ok"], true, "{result}");
918
+ assert_eq!(result["residuals"], json!({"sessions": [], "processes": [], "owned_files": []}), "{result}");
919
+ assert!(!transport.has_session(&session).unwrap());
920
+ assert!(!crate::platform::process::pid_is_alive(worker_pid));
921
+ assert!(transport.has_session(&foreign).unwrap());
922
+ assert!(crate::platform::process::pid_is_alive(foreign_worker.child_pid.unwrap()));
923
+ }
797
924
 
798
925
  #[test]
799
926
  fn leader_process_protection_does_not_alias_panes_across_endpoints() {
@@ -2750,15 +2877,12 @@ pub mod lifecycle_port {
2750
2877
  force: bool,
2751
2878
  allow_fresh: bool,
2752
2879
  team: Option<&str>,
2880
+ role_config: &crate::lifecycle::role_config::RoleConfigPatch,
2753
2881
  ) -> Result<Value, CliError> {
2882
+ let _ = force; // Never grants authority to stop an already-running seat.
2754
2883
  let agent_id = crate::model::ids::AgentId::new(agent);
2755
- match crate::lifecycle::start_agent(
2756
- workspace,
2757
- &agent_id,
2758
- force,
2759
- true,
2760
- allow_fresh,
2761
- team,
2884
+ match crate::lifecycle::role_config::start_agent_from_role(
2885
+ workspace, &agent_id, role_config, allow_fresh, team,
2762
2886
  ) {
2763
2887
  Ok(crate::lifecycle::StartAgentOutcome::Running {
2764
2888
  env,
@@ -2876,15 +3000,12 @@ pub mod lifecycle_port {
2876
3000
  role_file: &str,
2877
3001
  team: Option<&str>,
2878
3002
  force: bool,
3003
+ role_config: &crate::lifecycle::role_config::RoleConfigPatch,
2879
3004
  ) -> Result<Value, CliError> {
2880
3005
  let agent_id = crate::model::ids::AgentId::new(agent);
2881
- match crate::lifecycle::add_agent_force(
2882
- workspace,
2883
- &agent_id,
2884
- Path::new(role_file),
2885
- true,
2886
- team,
2887
- force,
3006
+ let source = (!role_file.is_empty()).then(|| Path::new(role_file));
3007
+ match crate::lifecycle::role_config::add_agent_from_role(
3008
+ workspace, &agent_id, source, role_config, force, team,
2888
3009
  ) {
2889
3010
  Ok(report) => Ok(agent_action_value(
2890
3011
  agent,
@@ -152,8 +152,8 @@ pub(crate) const COMMAND_SPECS: &[CommandSpec] = &[
152
152
  CommandSpec { name: "wait", tier: CommandTier::Core, category: CommandCategory::Daily, kind: CommandKind::Dispatch(DispatchKind::Wait), summary: "block until a task result is stored", usage: "usage: team-agent wait --task TASK [--workspace WORKSPACE] [--json]", default_help: false, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
153
153
  CommandSpec { name: "restart", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Restart), summary: "restart the selected team", usage: "usage: team-agent restart [WORKSPACE] [--team TEAM] [--allow-fresh] [--session-converge-deadline SECONDS] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
154
154
  CommandSpec { name: "shutdown", tier: CommandTier::Core, category: CommandCategory::TeamLifecycle, kind: CommandKind::Dispatch(DispatchKind::Shutdown), summary: "stop the selected team", usage: "usage: team-agent shutdown [--workspace WORKSPACE] [--team TEAM] [--keep-logs] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
155
- CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "add or force-recreate a worker", usage: "usage: team-agent add-agent AGENT --role-file FILE [--force] [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
156
- CommandSpec { name: "start-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StartAgent), summary: "start an existing worker", usage: "usage: team-agent start-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--force] [--allow-fresh] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
155
+ CommandSpec { name: "add-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::AddAgent), summary: "create and start a new worker", usage: "usage: team-agent add-agent AGENT [--role-file FILE] [--provider NAME] [--bypass true|false] [--model SLUG] [--effort LEVEL] [--prompt TEXT] [--profile NAME] [--force] [--workspace WORKSPACE] [--team TEAM] [--json] (provider/bypass required from CLI or role file; conflicting values rejected; force only recreates a confirmed dead registered pane)", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
156
+ CommandSpec { name: "start-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StartAgent), summary: "update and start a stopped worker (stop first if running)", usage: "usage: team-agent start-agent AGENT [--model SLUG] [--effort LEVEL] [--bypass true|false] [--prompt TEXT] [--profile NAME] [--provider NAME] [--workspace WORKSPACE] [--team TEAM] [--allow-fresh] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
157
157
  CommandSpec { name: "stop-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::StopAgent), summary: "stop a worker", usage: "usage: team-agent stop-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },
158
158
  CommandSpec { name: "reset-agent", tier: CommandTier::Core, category: CommandCategory::WorkerLifecycle, kind: CommandKind::Dispatch(DispatchKind::ResetAgent), summary: "reset a worker session", usage: "usage: team-agent reset-agent AGENT [--workspace WORKSPACE] [--team TEAM] [--discard-session] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::Conditional, sunset: None, action: None, governance: None },
159
159
  CommandSpec { name: "claim-leader", tier: CommandTier::Guided, category: CommandCategory::GuidedRecovery, kind: CommandKind::Dispatch(DispatchKind::ClaimLeader), summary: "bind this pane when doctor reports rebind_required", usage: "usage: team-agent claim-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]", default_help: true, command_help: true, suggestion_index: true, token_usage: TokenUsage::No, sunset: None, action: None, governance: None },