@team-agent/installer 0.5.104 → 0.5.105

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.
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.105"
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.105"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -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() {
@@ -1430,9 +1430,107 @@ fn restart_args(args: &[String], cwd: &Path) -> Result<RestartArgs, CliError> {
1430
1430
  })
1431
1431
  }
1432
1432
 
1433
+ // Keep strict role CLI parsing separate from the legacy shared parser.
1434
+ fn role_agent_args(args: &[String], add: bool) -> Result<(ParsedArgs, crate::lifecycle::role_config::RoleConfigPatch), CliError> {
1435
+ let mut role = crate::lifecycle::role_config::RoleConfigPatch::default();
1436
+ let mut forwarded = Vec::new();
1437
+ let mut seen = std::collections::BTreeSet::new();
1438
+ let mut index = 0;
1439
+ while index < args.len() {
1440
+ let argument = &args[index];
1441
+ if !argument.starts_with('-') {
1442
+ forwarded.push(argument.clone());
1443
+ index += 1;
1444
+ continue;
1445
+ }
1446
+ let (flag, inline) = argument.split_once('=').map_or((argument.as_str(), None), |(key, value)| (key, Some(value)));
1447
+ if !seen.insert(flag.to_string()) { return Err(CliError::Usage(format!("duplicate {flag}"))); }
1448
+ if matches!(flag, "--json" | "--allow-fresh" | "--force") {
1449
+ if inline.is_some() || (add && flag == "--allow-fresh") {
1450
+ return Err(CliError::Usage(format!("unsupported option: {argument}")));
1451
+ }
1452
+ forwarded.push(flag.to_string());
1453
+ index += 1;
1454
+ continue;
1455
+ }
1456
+ if !matches!(flag, "--provider" | "--model" | "--effort" | "--bypass" | "--prompt" | "--profile" | "--workspace" | "--team" | "--role-file")
1457
+ || (!add && flag == "--role-file") {
1458
+ return Err(CliError::Usage(format!("unknown option: {flag}")));
1459
+ }
1460
+ let value = if let Some(value) = inline { value.to_string() } else {
1461
+ index += 1;
1462
+ args.get(index).filter(|value| !value.starts_with("--"))
1463
+ .ok_or_else(|| CliError::Usage(format!("missing value for {flag}")))?.clone()
1464
+ };
1465
+ if value.trim().is_empty() { return Err(CliError::Usage(format!("empty value for {flag}"))); }
1466
+ match flag {
1467
+ "--provider" => {
1468
+ let provider = crate::provider::wire::parse_provider(&value)
1469
+ .ok_or_else(|| CliError::Usage(format!("unknown provider: {value}")))?;
1470
+ role.provider = Some(crate::provider::wire::provider_wire(provider).to_string());
1471
+ }
1472
+ "--bypass" => role.bypass = Some(match value.as_str() {
1473
+ "true" => true, "false" => false,
1474
+ _ => return Err(CliError::Usage("--bypass requires true or false".into())),
1475
+ }),
1476
+ "--model" => role.model = Some(value),
1477
+ "--effort" => {
1478
+ if crate::model::enums::ProviderEffort::parse(&value).is_none() {
1479
+ return Err(CliError::Usage(format!("unknown effort: {value}")));
1480
+ }
1481
+ role.effort = Some(value);
1482
+ }
1483
+ "--prompt" => role.prompt = Some(value),
1484
+ "--profile" => role.profile = Some(value),
1485
+ _ => { forwarded.push(flag.to_string()); forwarded.push(value); }
1486
+ }
1487
+ index += 1;
1488
+ }
1489
+ let parsed = parse_args(&forwarded);
1490
+ if add && parsed.role_file.is_none() {
1491
+ if role.provider.is_none() { return Err(CliError::Usage("add-agent requires --provider <name>".into())); }
1492
+ if role.bypass.is_none() { return Err(CliError::Usage("add-agent requires --bypass <true|false>".into())); }
1493
+ }
1494
+ if parsed.positionals.len() != 1 { return Err(CliError::Usage("expected exactly one agent id".into())); }
1495
+ Ok((parsed, role))
1496
+ }
1497
+
1498
+ #[cfg(test)]
1499
+ mod role_cli_tests {
1500
+ use super::*;
1501
+ fn strings(values: &[&str]) -> Vec<String> { values.iter().map(|value| (*value).to_string()).collect() }
1502
+
1503
+ #[test]
1504
+ fn add_requires_decisions_without_a_template_but_accepts_file_input() {
1505
+ for args in [vec!["w"], vec!["w", "--provider", "pi"], vec!["w", "--bypass", "false"]] {
1506
+ assert!(role_agent_args(&strings(&args), true).is_err());
1507
+ }
1508
+ assert!(role_agent_args(&strings(&["w", "--role-file", "w.md"]), true).is_ok());
1509
+ let (parsed, _) = role_agent_args(&strings(&["w", "--role-file", "w.md", "--force"]), true).unwrap();
1510
+ assert!(parsed.force);
1511
+ assert!(role_agent_args(&strings(&["w", "--role-file", "w.md", "--force=true"]), true).is_err());
1512
+ assert!(role_agent_args(&strings(&["w", "--role-file", "w.md", "--allow-fresh"]), true).is_err());
1513
+ let (_, patch) = role_agent_args(&strings(&["w", "--provider=pi", "--bypass=false"]), true).unwrap();
1514
+ assert_eq!(patch.provider.as_deref(), Some("pi"));
1515
+ assert_eq!(patch.bypass, Some(false));
1516
+ }
1517
+
1518
+ #[test]
1519
+ fn strict_role_options_reject_ambiguous_values() {
1520
+ for args in [
1521
+ vec!["w", "--bypass"], vec!["w", "--bypass", "yes"],
1522
+ vec!["w", "--no-bypass"], vec!["w", "--bypass", "true", "--bypass=false"],
1523
+ vec!["w", "--unknown", "x"], vec!["w", "extra"],
1524
+ ] { assert!(role_agent_args(&strings(&args), false).is_err()); }
1525
+ let (_, patch) = role_agent_args(&strings(&["w"]), false).unwrap();
1526
+ assert_eq!(patch, crate::lifecycle::role_config::RoleConfigPatch::default());
1527
+ }
1528
+ }
1529
+
1433
1530
  fn start_agent_args(args: &[String], cwd: &Path) -> Result<StartAgentArgs, CliError> {
1434
- let parsed = parse_args(args);
1531
+ let (parsed, role_config) = role_agent_args(args, false)?;
1435
1532
  Ok(StartAgentArgs {
1533
+ role_config,
1436
1534
  agent: required_pos(&parsed, 0, "agent")?,
1437
1535
  workspace: workspace(&parsed, cwd),
1438
1536
  team: parsed.team,
@@ -1466,14 +1564,13 @@ fn reset_agent_args(args: &[String], cwd: &Path) -> Result<ResetAgentArgs, CliEr
1466
1564
  }
1467
1565
 
1468
1566
  fn add_agent_args(args: &[String], cwd: &Path) -> Result<AddAgentArgs, CliError> {
1469
- let parsed = parse_args(args);
1567
+ let (parsed, role_config) = role_agent_args(args, true)?;
1470
1568
  Ok(AddAgentArgs {
1569
+ role_config,
1471
1570
  agent: required_pos(&parsed, 0, "agent")?,
1472
1571
  workspace: workspace(&parsed, cwd),
1473
1572
  team: parsed.team,
1474
- role_file: parsed
1475
- .role_file
1476
- .ok_or_else(|| CliError::Usage("missing --role-file".to_string()))?,
1573
+ role_file: parsed.role_file.unwrap_or_default(),
1477
1574
  force: parsed.force,
1478
1575
  json: parsed.json,
1479
1576
  })
@@ -2750,15 +2750,12 @@ pub mod lifecycle_port {
2750
2750
  force: bool,
2751
2751
  allow_fresh: bool,
2752
2752
  team: Option<&str>,
2753
+ role_config: &crate::lifecycle::role_config::RoleConfigPatch,
2753
2754
  ) -> Result<Value, CliError> {
2755
+ let _ = force; // Never grants authority to stop an already-running seat.
2754
2756
  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,
2757
+ match crate::lifecycle::role_config::start_agent_from_role(
2758
+ workspace, &agent_id, role_config, allow_fresh, team,
2762
2759
  ) {
2763
2760
  Ok(crate::lifecycle::StartAgentOutcome::Running {
2764
2761
  env,
@@ -2876,15 +2873,12 @@ pub mod lifecycle_port {
2876
2873
  role_file: &str,
2877
2874
  team: Option<&str>,
2878
2875
  force: bool,
2876
+ role_config: &crate::lifecycle::role_config::RoleConfigPatch,
2879
2877
  ) -> Result<Value, CliError> {
2880
2878
  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,
2879
+ let source = (!role_file.is_empty()).then(|| Path::new(role_file));
2880
+ match crate::lifecycle::role_config::add_agent_from_role(
2881
+ workspace, &agent_id, source, role_config, force, team,
2888
2882
  ) {
2889
2883
  Ok(report) => Ok(agent_action_value(
2890
2884
  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 },
@@ -317,6 +317,7 @@ fn cli_add_agent_duplicate_id_surfaces_real_error() {
317
317
  let dup_role = team.join("dup-role.md");
318
318
  std::fs::write(&dup_role, DELEG_VALID_ROLE).unwrap(); // role file must exist
319
319
  let args = AddAgentArgs {
320
+ role_config: Default::default(),
320
321
  agent: "implementer".to_string(),
321
322
  workspace: team,
322
323
  team: None,
@@ -475,6 +475,7 @@ pub struct RestartArgs {
475
475
  /// `start-agent`(`parser.py:369`)。
476
476
  #[derive(Debug, Clone, PartialEq, Eq)]
477
477
  pub struct StartAgentArgs {
478
+ pub role_config: crate::lifecycle::role_config::RoleConfigPatch,
478
479
  pub agent: String,
479
480
  pub workspace: PathBuf,
480
481
  pub team: Option<String>,
@@ -502,9 +503,10 @@ pub struct ResetAgentArgs {
502
503
  pub json: bool,
503
504
  }
504
505
 
505
- /// `add-agent`(`parser.py:395`)。`--role-file` 必需。
506
+ /// `add-agent`: provider and bypass are explicit CLI or role-file decisions.
506
507
  #[derive(Debug, Clone, PartialEq, Eq)]
507
508
  pub struct AddAgentArgs {
509
+ pub role_config: crate::lifecycle::role_config::RoleConfigPatch,
508
510
  pub agent: String,
509
511
  pub workspace: PathBuf,
510
512
  pub team: Option<String>,
@@ -418,7 +418,7 @@ pub(crate) fn add_agent_with_transport_force(
418
418
  /// returns: 新席报告;成功后还要过快照的一致性校验
419
419
  /// errors: 角色文件不存在先行返回 Compile;摘除、加回或一致性校验失败时按快照恢复并返回错误
420
420
  /// ---
421
- pub(super) fn force_recreate_with_transport_locked(
421
+ pub(crate) fn force_recreate_with_transport_locked(
422
422
  run_workspace: &Path,
423
423
  team_dir: &Path,
424
424
  agent_id: &AgentId,
@@ -546,7 +546,7 @@ pub(super) fn add_agent_with_transport_at_paths(
546
546
  }
547
547
 
548
548
  #[allow(clippy::too_many_arguments)]
549
- fn add_agent_with_transport_at_paths_locked(
549
+ pub(crate) fn add_agent_with_transport_at_paths_locked(
550
550
  run_workspace: &Path,
551
551
  team_dir: &Path,
552
552
  agent_id: &AgentId,
@@ -683,6 +683,7 @@ fn add_agent_with_transport_at_paths_reserved(
683
683
  &spec_path,
684
684
  pre_spec_text.as_deref(),
685
685
  None,
686
+ Some(&canonical_team_key),
686
687
  agent_id,
687
688
  "state_upsert_failed",
688
689
  );
@@ -705,6 +706,7 @@ fn add_agent_with_transport_at_paths_reserved(
705
706
  &spec_path,
706
707
  pre_spec_text.as_deref(),
707
708
  None,
709
+ Some(&canonical_team_key),
708
710
  agent_id,
709
711
  "start_agent_failed",
710
712
  );
@@ -721,6 +723,7 @@ fn add_agent_with_transport_at_paths_reserved(
721
723
  &spec_path,
722
724
  pre_spec_text.as_deref(),
723
725
  None,
726
+ Some(&canonical_team_key),
724
727
  agent_id,
725
728
  "start_agent_noop",
726
729
  );
@@ -734,6 +737,7 @@ fn add_agent_with_transport_at_paths_reserved(
734
737
  &spec_path,
735
738
  pre_spec_text.as_deref(),
736
739
  None,
740
+ Some(&canonical_team_key),
737
741
  agent_id,
738
742
  "added_agent_paused",
739
743
  );
@@ -55,6 +55,7 @@ pub(super) fn rollback_add_agent_atomic(
55
55
  spec_path: &Path,
56
56
  pre_spec_text: Option<&str>,
57
57
  pre_runtime_state: Option<&serde_json::Value>,
58
+ team_key: Option<&str>,
58
59
  agent_id: &AgentId,
59
60
  reason: &str,
60
61
  ) {
@@ -86,7 +87,7 @@ pub(super) fn rollback_add_agent_atomic(
86
87
  crate::state::repository::StateRepository::new(run_workspace)
87
88
  .save(
88
89
  crate::state::repository::StateWriteIntent::AgentRollback {
89
- team_key: None,
90
+ team_key,
90
91
  agent_id: agent_id.as_str(),
91
92
  },
92
93
  state,
@@ -94,7 +95,7 @@ pub(super) fn rollback_add_agent_atomic(
94
95
  .is_ok()
95
96
  } else {
96
97
  // No prior runtime state — drop just the agent we added (load → strip → save).
97
- if let Ok(mut state) = crate::state::persist::load_runtime_state(run_workspace) {
98
+ if let Ok(mut state) = crate::state::projection::select_runtime_state(run_workspace, team_key) {
98
99
  if let Some(agents) = state
99
100
  .get_mut("agents")
100
101
  .and_then(serde_json::Value::as_object_mut)
@@ -105,7 +106,8 @@ pub(super) fn rollback_add_agent_atomic(
105
106
  .get_mut("teams")
106
107
  .and_then(serde_json::Value::as_object_mut)
107
108
  {
108
- for team in teams.values_mut() {
109
+ for (key, team) in teams.iter_mut() {
110
+ if team_key.is_some_and(|selected| selected != key) { continue; }
109
111
  if let Some(agents) = team
110
112
  .get_mut("agents")
111
113
  .and_then(serde_json::Value::as_object_mut)
@@ -117,7 +119,7 @@ pub(super) fn rollback_add_agent_atomic(
117
119
  crate::state::repository::StateRepository::new(run_workspace)
118
120
  .save(
119
121
  crate::state::repository::StateWriteIntent::AgentRollback {
120
- team_key: None,
122
+ team_key,
121
123
  agent_id: agent_id.as_str(),
122
124
  },
123
125
  &state,
@@ -39,7 +39,7 @@ fn set_yaml_map_value(value: &mut Value, key: &str, next: Value) -> Result<(), L
39
39
  Ok(())
40
40
  }
41
41
 
42
- fn dump_role_frontmatter(meta: &Value) -> String {
42
+ pub(crate) fn dump_role_frontmatter(meta: &Value) -> String {
43
43
  let Value::Map(pairs) = meta else {
44
44
  return yaml::dumps(meta);
45
45
  };
@@ -327,8 +327,13 @@ pub(crate) fn write_spec_atomic(spec_path: &Path, spec: &Value) -> Result<(), Li
327
327
  .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
328
328
  }
329
329
  let tmp = spec_path.with_extension(format!("tmp-{}", std::process::id()));
330
- std::fs::write(&tmp, yaml::dumps(spec))
331
- .map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", tmp.display())))?;
330
+ if let Err(e) = std::fs::write(&tmp, yaml::dumps(spec)) {
331
+ let _ = std::fs::remove_file(&tmp);
332
+ return Err(LifecycleError::StatePersist(format!(
333
+ "{}: {e}",
334
+ tmp.display()
335
+ )));
336
+ }
332
337
  if let Err(e) = std::fs::rename(&tmp, spec_path) {
333
338
  let _ = std::fs::remove_file(&tmp);
334
339
  return Err(LifecycleError::StatePersist(format!(
@@ -402,7 +402,10 @@ pub(super) use readiness::*;
402
402
  mod add_agent;
403
403
  pub(super) use add_agent::*;
404
404
  pub use add_agent::{add_agent, add_agent_force};
405
- pub(crate) use add_agent::{add_agent_with_transport, add_agent_with_transport_force};
405
+ pub(crate) use add_agent::{
406
+ add_agent_with_transport, add_agent_with_transport_force,
407
+ add_agent_with_transport_at_paths_locked, force_recreate_with_transport_locked,
408
+ };
406
409
 
407
410
  pub(crate) mod add_agent_state;
408
411
  pub(super) use add_agent_state::*;
@@ -57,6 +57,7 @@ pub(crate) mod pane_input_lock;
57
57
  pub(crate) mod profile_launch;
58
58
  pub(crate) mod profile_smoke;
59
59
  pub mod restart;
60
+ pub mod role_config;
60
61
  pub mod types;
61
62
  pub(crate) mod worker_command_context;
62
63
 
@@ -37,6 +37,37 @@ use super::team_state::write_team_state;
37
37
  use super::*;
38
38
  use crate::lifecycle::lock::{acquire_agent_lifecycle_lock, LifecycleLockRequest};
39
39
 
40
+ /// CLI start is orthogonal to stop: refuse any live target before editing its role.
41
+ /// Transport uncertainty is a refusal, not permission to spawn a duplicate.
42
+ pub(crate) fn ensure_agent_not_running(
43
+ state: &serde_json::Value,
44
+ agent_id: &AgentId,
45
+ transport: &dyn crate::transport::Transport,
46
+ ) -> Result<(), LifecycleError> {
47
+ let agent = state.get("agents").and_then(|agents| agents.get(agent_id.as_str()))
48
+ .ok_or_else(|| LifecycleError::RequirementUnmet(format!("agent {agent_id} not found")))?;
49
+ let session = state_session_name(state);
50
+ let window = agent_window(agent, agent_id);
51
+ let pane = agent.get("pane_id").and_then(serde_json::Value::as_str);
52
+ let targets = transport.list_targets()
53
+ .map_err(|error| LifecycleError::Transport(error.to_string()))?;
54
+ let live = targets.iter().find(|target| {
55
+ target.session == session
56
+ && target.window_name.as_ref().is_some_and(|name| name.as_str() == window)
57
+ && (pane == Some(target.pane_id.as_str())
58
+ || !crate::lifecycle::launch::state_uses_adaptive_layout(state))
59
+ && agent_pane_live_by_id(transport, &target.pane_id)
60
+ });
61
+ if let Some(target) = live {
62
+ Err(LifecycleError::RequirementUnmet(format!(
63
+ "agent {agent_id} is already running (duplicate / active cohort); use stop-agent first; cohort proof: session={} window={window} pane={}",
64
+ session.as_str(), target.pane_id.as_str(),
65
+ )))
66
+ } else {
67
+ Ok(())
68
+ }
69
+ }
70
+
40
71
  /// ---
41
72
  /// purpose: 起或复活一席的对外入口
42
73
  /// params:
@@ -77,7 +77,7 @@ pub(crate) mod remove;
77
77
  mod selection;
78
78
  mod team_state;
79
79
 
80
- pub(crate) use agent::start_agent_at_paths;
80
+ pub(crate) use agent::{start_agent_at_paths, ensure_agent_not_running};
81
81
  pub use agent::{reset_agent, start_agent, stop_agent};
82
82
  pub(crate) use agent::{
83
83
  reset_agent_with_transport, start_agent_with_transport, stop_agent_with_transport,