@team-agent/installer 0.5.6 → 0.5.7

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
@@ -575,7 +575,7 @@ dependencies = [
575
575
 
576
576
  [[package]]
577
577
  name = "team-agent"
578
- version = "0.5.6"
578
+ version = "0.5.7"
579
579
  dependencies = [
580
580
  "anyhow",
581
581
  "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.6"
12
+ version = "0.5.7"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -2314,12 +2314,29 @@ pub mod lifecycle_port {
2314
2314
  force: bool,
2315
2315
  team: Option<&str>,
2316
2316
  ) -> Result<Value, CliError> {
2317
+ let agent_id = crate::model::ids::AgentId::new(agent);
2318
+ match crate::lifecycle::remove_agent_flag_requirements(workspace, &agent_id, team) {
2319
+ Ok(requirements) => {
2320
+ if !remove_agent_missing_flags(from_spec, confirm, force, &requirements).is_empty() {
2321
+ return Ok(remove_agent_flag_refusal(
2322
+ workspace,
2323
+ agent,
2324
+ team,
2325
+ from_spec,
2326
+ confirm,
2327
+ force,
2328
+ &requirements,
2329
+ ));
2330
+ }
2331
+ }
2332
+ Err(error) if confirm => return Ok(error_value(error)),
2333
+ Err(_) => {}
2334
+ }
2317
2335
  if !confirm {
2318
2336
  return Ok(
2319
2337
  json!({"ok": false, "agent_id": agent, "error": "remove-agent requires --confirm"}),
2320
2338
  );
2321
2339
  }
2322
- let agent_id = crate::model::ids::AgentId::new(agent);
2323
2340
  match crate::lifecycle::remove_agent(workspace, &agent_id, from_spec, force, team) {
2324
2341
  Ok(report @ crate::lifecycle::RemoveAgentOutcome::Removed { .. }) => Ok(json!({
2325
2342
  "ok": true,
@@ -2343,9 +2360,121 @@ pub mod lifecycle_port {
2343
2360
  "error": "agent is running; remove-agent requires --force",
2344
2361
  "action": "rerun with --force to stop and remove the running agent",
2345
2362
  })),
2363
+ Ok(crate::lifecycle::RemoveAgentOutcome::RefusedRequiredFlags { .. }) => Ok(json!({
2364
+ "ok": false,
2365
+ "agent_id": agent,
2366
+ "status": "refused",
2367
+ "reason": "remove_agent_flags_required",
2368
+ "error": "remove-agent required flags changed; rerun the command from the latest refusal",
2369
+ })),
2346
2370
  Err(e) => Ok(error_value(e)),
2347
2371
  }
2348
2372
  }
2373
+
2374
+ fn remove_agent_flag_refusal(
2375
+ workspace: &Path,
2376
+ agent: &str,
2377
+ team: Option<&str>,
2378
+ from_spec: bool,
2379
+ confirm: bool,
2380
+ force: bool,
2381
+ requirements: &crate::lifecycle::RemoveAgentFlagRequirements,
2382
+ ) -> Value {
2383
+ let required_flags = remove_agent_required_flags(requirements);
2384
+ let missing_flags = remove_agent_missing_flags(from_spec, confirm, force, requirements);
2385
+ let reason = if missing_flags.len() == 1 {
2386
+ match missing_flags[0] {
2387
+ "--confirm" => "confirm_required",
2388
+ "--from-spec" => "from_spec_confirm_required",
2389
+ "--force" => "force_required",
2390
+ _ => "remove_agent_flags_required",
2391
+ }
2392
+ } else {
2393
+ "remove_agent_flags_required"
2394
+ };
2395
+ let command = remove_agent_command(workspace, agent, team, &required_flags);
2396
+ let required = required_flags.join(" ");
2397
+ json!({
2398
+ "ok": false,
2399
+ "agent_id": agent,
2400
+ "status": "refused",
2401
+ "reason": reason,
2402
+ "error": format!("remove-agent requires {required} for this agent"),
2403
+ "action": format!("rerun: {command}"),
2404
+ "command": command,
2405
+ "missing_flags": missing_flags,
2406
+ "required_flags": required_flags,
2407
+ "state": {
2408
+ "from_spec_required": requirements.from_spec_required,
2409
+ "running": requirements.force_required,
2410
+ "has_session": requirements.has_session,
2411
+ },
2412
+ })
2413
+ }
2414
+
2415
+ fn remove_agent_missing_flags(
2416
+ from_spec: bool,
2417
+ confirm: bool,
2418
+ force: bool,
2419
+ requirements: &crate::lifecycle::RemoveAgentFlagRequirements,
2420
+ ) -> Vec<&'static str> {
2421
+ remove_agent_required_flags(requirements)
2422
+ .into_iter()
2423
+ .filter(|flag| match *flag {
2424
+ "--from-spec" => !from_spec,
2425
+ "--confirm" => !confirm,
2426
+ "--force" => !force,
2427
+ _ => false,
2428
+ })
2429
+ .collect()
2430
+ }
2431
+
2432
+ fn remove_agent_required_flags(
2433
+ requirements: &crate::lifecycle::RemoveAgentFlagRequirements,
2434
+ ) -> Vec<&'static str> {
2435
+ let mut flags = Vec::new();
2436
+ if requirements.from_spec_required {
2437
+ flags.push("--from-spec");
2438
+ }
2439
+ flags.push("--confirm");
2440
+ if requirements.force_required {
2441
+ flags.push("--force");
2442
+ }
2443
+ flags
2444
+ }
2445
+
2446
+ fn remove_agent_command(
2447
+ workspace: &Path,
2448
+ agent: &str,
2449
+ team: Option<&str>,
2450
+ required_flags: &[&str],
2451
+ ) -> String {
2452
+ let mut parts = vec![
2453
+ "team-agent".to_string(),
2454
+ "remove-agent".to_string(),
2455
+ shell_arg(agent),
2456
+ "--workspace".to_string(),
2457
+ shell_arg(&workspace.to_string_lossy()),
2458
+ ];
2459
+ if let Some(team) = team {
2460
+ parts.push("--team".to_string());
2461
+ parts.push(shell_arg(team));
2462
+ }
2463
+ parts.extend(required_flags.iter().map(|flag| (*flag).to_string()));
2464
+ parts.join(" ")
2465
+ }
2466
+
2467
+ fn shell_arg(raw: &str) -> String {
2468
+ if !raw.is_empty()
2469
+ && raw
2470
+ .bytes()
2471
+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b':'))
2472
+ {
2473
+ raw.to_string()
2474
+ } else {
2475
+ format!("'{}'", raw.replace('\'', "'\\''"))
2476
+ }
2477
+ }
2349
2478
  /// `runtime.acknowledge_idle`(`cmd_acknowledge_idle`)。
2350
2479
  pub fn acknowledge_idle(workspace: &Path, team: Option<&str>) -> Result<Value, CliError> {
2351
2480
  let mut state = crate::state::persist::load_runtime_state(workspace)
@@ -39,7 +39,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
39
39
  args.json,
40
40
  )?;
41
41
  add_send_reminder_if_ok(&mut value);
42
- return Ok(CmdResult::from_json(value, args.json));
42
+ return Ok(cmd_send_result(value, args.json));
43
43
  }
44
44
  // F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
45
45
  // Mutually exclusive with target / --to (agent-name routing).
@@ -68,7 +68,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
68
68
  args.json,
69
69
  )?;
70
70
  add_send_reminder_if_ok(&mut value);
71
- return Ok(CmdResult::from_json(value, args.json));
71
+ return Ok(cmd_send_result(value, args.json));
72
72
  }
73
73
  let selected = crate::state::selector::resolve_active_team(
74
74
  &args.workspace,
@@ -88,7 +88,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
88
88
  if let Some(amb) =
89
89
  routing_ambiguous_value(&selected.run_workspace, args, &target, &content, &opts)
90
90
  {
91
- return Ok(CmdResult::from_json(amb, args.json));
91
+ return Ok(cmd_send_result(amb, args.json));
92
92
  }
93
93
  let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
94
94
  if opts.watch_result {
@@ -101,7 +101,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
101
101
  }
102
102
  }
103
103
  add_send_reminder_if_ok(&mut value);
104
- Ok(CmdResult::from_json(value, args.json))
104
+ Ok(cmd_send_result(value, args.json))
105
105
  }
106
106
 
107
107
  /// F1 (0.3.26): direct pane-id send — bypasses agent-name routing + team
@@ -752,8 +752,98 @@ fn add_send_reminder_if_ok(value: &mut Value) {
752
752
  if value.get("ok").and_then(Value::as_bool) != Some(true) {
753
753
  return;
754
754
  }
755
+ let reminder = send_reminder_for_value(value);
755
756
  if let Some(obj) = value.as_object_mut() {
756
- obj.insert("reminder".to_string(), json!(crate::cli::SEND_REMINDER));
757
+ obj.insert("reminder".to_string(), json!(reminder));
758
+ }
759
+ }
760
+
761
+ fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
762
+ let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
763
+ ExitCode::Error
764
+ } else {
765
+ ExitCode::Ok
766
+ };
767
+ if as_json {
768
+ CmdResult::from_json(value, true)
769
+ } else {
770
+ CmdResult {
771
+ output: CmdOutput::Human(send_human_output(&value)),
772
+ exit,
773
+ as_json: false,
774
+ }
775
+ }
776
+ }
777
+
778
+ fn send_human_output(value: &Value) -> String {
779
+ let mut parts = vec![
780
+ send_human_field(value, "ok"),
781
+ format!("status: {}", send_human_status(value)),
782
+ send_human_field(value, "message_id"),
783
+ format!("target: {}", send_human_target(value)),
784
+ ];
785
+ for key in ["verification", "stage", "reason", "channel"] {
786
+ if !value.get(key).is_none_or(Value::is_null) {
787
+ parts.push(send_human_field(value, key));
788
+ }
789
+ }
790
+ parts.join(" ")
791
+ }
792
+
793
+ fn send_human_field(value: &Value, key: &str) -> String {
794
+ let rendered = value
795
+ .get(key)
796
+ .map(send_human_value)
797
+ .unwrap_or_else(|| "None".to_string());
798
+ format!("{key}: {rendered}")
799
+ }
800
+
801
+ fn send_human_target(value: &Value) -> String {
802
+ ["target", "agent_id", "pane_id", "to_name"]
803
+ .iter()
804
+ .find_map(|key| value.get(*key).filter(|v| !v.is_null()))
805
+ .map(send_human_value)
806
+ .unwrap_or_else(|| "None".to_string())
807
+ }
808
+
809
+ fn send_human_status(value: &Value) -> String {
810
+ value
811
+ .get("status")
812
+ .map(send_human_value)
813
+ .unwrap_or_else(|| {
814
+ if value.get("ok").and_then(Value::as_bool) == Some(true) {
815
+ "delivered".to_string()
816
+ } else {
817
+ "failed".to_string()
818
+ }
819
+ })
820
+ }
821
+
822
+ fn send_human_value(value: &Value) -> String {
823
+ let text = match value {
824
+ Value::Null => "None".to_string(),
825
+ Value::Bool(true) => "True".to_string(),
826
+ Value::Bool(false) => "False".to_string(),
827
+ Value::Number(n) => n.to_string(),
828
+ Value::String(s) => s.clone(),
829
+ Value::Array(_) | Value::Object(_) => {
830
+ serde_json::to_string(value).unwrap_or_else(|_| "None".to_string())
831
+ }
832
+ };
833
+ text.replace(['\r', '\n'], " ")
834
+ }
835
+
836
+ fn send_reminder_for_value(value: &Value) -> &'static str {
837
+ let delivered = value.get("delivered").and_then(Value::as_bool);
838
+ let status = value.get("status").and_then(Value::as_str);
839
+ let delivery_status = value.get("delivery_status").and_then(Value::as_str);
840
+ if delivered == Some(false)
841
+ || matches!(status, Some("queued"))
842
+ || matches!(delivery_status, Some("pending"))
843
+ {
844
+ "Message queued; coordinator will notify when the worker receives it. Do not poll the worker terminal with capture-pane."
845
+ } else {
846
+ crate::cli::SEND_REMINDER
757
847
  }
758
848
  }
759
849
 
@@ -493,6 +493,60 @@ fn seed_team_spec(ws: &std::path::Path) {
493
493
  .unwrap();
494
494
  }
495
495
  #[test]
496
+ fn remove_agent_spec_running_refusal_lists_all_required_flags_once() {
497
+ let ws = tmp_workspace();
498
+ seed_remove_agent_workspace(&ws, "running");
499
+ let out = json_output(
500
+ cmd_remove_agent(&RemoveAgentArgs {
501
+ agent: "fake_impl".to_string(),
502
+ workspace: ws.clone(),
503
+ team: None,
504
+ from_spec: false,
505
+ confirm: false,
506
+ force: false,
507
+ json: true,
508
+ })
509
+ .unwrap(),
510
+ );
511
+ assert_eq!(out["ok"], json!(false));
512
+ assert_eq!(out["status"], json!("refused"));
513
+ assert_eq!(out["reason"], json!("remove_agent_flags_required"));
514
+ for flag in ["--from-spec", "--confirm", "--force"] {
515
+ assert!(
516
+ out["error"].as_str().is_some_and(|s| s.contains(flag))
517
+ && out["action"].as_str().is_some_and(|s| s.contains(flag))
518
+ && out["command"].as_str().is_some_and(|s| s.contains(flag)),
519
+ "refusal must mention {flag} in error/action/copyable command; got {out}"
520
+ );
521
+ }
522
+ let with_confirm = json_output(
523
+ cmd_remove_agent(&RemoveAgentArgs {
524
+ agent: "fake_impl".to_string(),
525
+ workspace: ws.clone(),
526
+ team: None,
527
+ from_spec: false,
528
+ confirm: true,
529
+ force: false,
530
+ json: true,
531
+ })
532
+ .unwrap(),
533
+ );
534
+ assert_eq!(with_confirm["reason"], json!("remove_agent_flags_required"));
535
+ for flag in ["--from-spec", "--confirm", "--force"] {
536
+ assert!(
537
+ with_confirm["error"].as_str().is_some_and(|s| s.contains(flag))
538
+ && with_confirm["action"].as_str().is_some_and(|s| s.contains(flag))
539
+ && with_confirm["command"].as_str().is_some_and(|s| s.contains(flag)),
540
+ "refusal with --confirm must still give the full command including {flag}; got {with_confirm}"
541
+ );
542
+ }
543
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
544
+ assert!(
545
+ state["agents"].get("fake_impl").is_some(),
546
+ "refused remove-agent must not delete the spec-defined running agent"
547
+ );
548
+ }
549
+ #[test]
496
550
  fn remove_agent_running_refusal_is_not_success_envelope() {
497
551
  let ws = tmp_workspace();
498
552
  seed_remove_agent_workspace(&ws, "running");
@@ -451,6 +451,28 @@ use super::*;
451
451
  }
452
452
  }
453
453
 
454
+ fn queued_send_args_fixture(json: bool) -> SendArgs {
455
+ let ws = deleg_uniq_dir("send-human");
456
+ let _ = crate::message_store::MessageStore::open(&ws).unwrap();
457
+ crate::state::persist::save_runtime_state(
458
+ &ws,
459
+ &json!({
460
+ "active_team_key": "current",
461
+ "teams": {"current": {"agents": {"w1": {"provider": "codex"}}}}
462
+ }),
463
+ )
464
+ .unwrap();
465
+ SendArgs {
466
+ workspace: ws,
467
+ target: Some("w1".into()),
468
+ team: None,
469
+ task: None,
470
+ watch_result: false,
471
+ json,
472
+ ..send_args_fixture()
473
+ }
474
+ }
475
+
454
476
  #[test]
455
477
  fn send_options_negates_no_ack_and_no_wait_and_carries_watch() {
456
478
  // golden (commands.py:172,174,176): requires_ack=not no_ack; wait_visible=not no_wait;
@@ -494,7 +516,11 @@ use super::*;
494
516
  fn cmd_send_joins_message_with_single_space() {
495
517
  // golden (commands.py:169): " ".join(["hello","world","foo"]) == "hello world foo".
496
518
  // Drive cmd_send; the joined content must reach send_message (RED until ported).
497
- let r = cmd_send(&send_args_fixture()).expect("cmd_send returns CmdResult");
519
+ let args = SendArgs {
520
+ json: true,
521
+ ..send_args_fixture()
522
+ };
523
+ let r = cmd_send(&args).expect("cmd_send returns CmdResult");
498
524
  // The delegate's DeliveryOutcome -> Json must carry an `ok` key feeding exit-code.
499
525
  match r.output {
500
526
  CmdOutput::Json(ref v) => {
@@ -510,11 +536,92 @@ use super::*;
510
536
  }
511
537
  }
512
538
 
539
+ #[test]
540
+ fn cmd_send_default_human_output_is_one_line_without_false_delivered() {
541
+ let r = cmd_send(&queued_send_args_fixture(false)).expect("cmd_send returns CmdResult");
542
+ assert!(!r.as_json);
543
+ let text = emit(&r.output, r.as_json).expect("send should render human text");
544
+ let lines: Vec<_> = text.lines().collect();
545
+ assert_eq!(lines.len(), 1, "default send output must be one line: {text}");
546
+ assert!(
547
+ lines[0].contains("ok:")
548
+ && lines[0].contains("status:")
549
+ && lines[0].contains("message_id:")
550
+ && lines[0].contains("target:"),
551
+ "default send output must keep only the core fields; got {text}"
552
+ );
553
+ assert!(
554
+ !text.contains("delivered"),
555
+ "queued send output must not claim or mention delivered; got {text}"
556
+ );
557
+ for hidden in [
558
+ "agent_id:",
559
+ "sender:",
560
+ "message_status:",
561
+ "verification:",
562
+ "stage:",
563
+ "reason:",
564
+ "channel:",
565
+ "reminder:",
566
+ ] {
567
+ assert!(
568
+ !text.contains(hidden),
569
+ "default send output should hide {hidden} unless needed; got {text}"
570
+ );
571
+ }
572
+ }
573
+
574
+ #[test]
575
+ fn cmd_send_json_shape_keeps_056_fields() {
576
+ let args = queued_send_args_fixture(true);
577
+ let r = cmd_send(&args).expect("cmd_send returns CmdResult");
578
+ let v = match r.output {
579
+ CmdOutput::Json(v) => v,
580
+ other => panic!("--json send must emit Json, got {other:?}"),
581
+ };
582
+ let obj = v.as_object().expect("--json send output must be object");
583
+ for key in [
584
+ "ok",
585
+ "status",
586
+ "delivery_status",
587
+ "delivered",
588
+ "target",
589
+ "agent_id",
590
+ "content_length_bytes",
591
+ "sender",
592
+ "message_id",
593
+ "message_status",
594
+ "verification",
595
+ "stage",
596
+ "reason",
597
+ "channel",
598
+ "reminder",
599
+ ] {
600
+ assert!(obj.contains_key(key), "--json send shape lost {key}: {v}");
601
+ }
602
+ assert_eq!(v.get("verification"), Some(&serde_json::Value::Null));
603
+ assert_eq!(v.get("stage"), Some(&serde_json::Value::Null));
604
+ assert_eq!(v.get("reason"), Some(&serde_json::Value::Null));
605
+ assert_eq!(v.get("channel"), Some(&serde_json::Value::Null));
606
+ assert_eq!(v.get("delivered").and_then(|d| d.as_bool()), Some(false));
607
+ assert!(
608
+ !v.get("reminder")
609
+ .and_then(|reminder| reminder.as_str())
610
+ .unwrap_or_default()
611
+ .contains("Message delivered."),
612
+ "queued JSON reminder must not contradict delivered:false: {v}"
613
+ );
614
+ }
615
+
513
616
  #[test]
514
617
  fn cmd_send_watch_result_does_not_register_before_delivery() {
515
618
  // 0.5.x send contract: --watch-result may only advertise a watcher after
516
619
  // initial worker delivery is physically proven.
517
- let r = cmd_send(&send_args_fixture()).expect("cmd_send returns CmdResult");
620
+ let args = SendArgs {
621
+ json: true,
622
+ ..send_args_fixture()
623
+ };
624
+ let r = cmd_send(&args).expect("cmd_send returns CmdResult");
518
625
  let v = match r.output {
519
626
  CmdOutput::Json(v) => v,
520
627
  other => panic!("expected Json, got {other:?}"),
@@ -60,15 +60,37 @@ pub fn remove_agent_with_transport(
60
60
  )
61
61
  }
62
62
 
63
- fn remove_agent_at_paths(
63
+ pub fn remove_agent_flag_requirements(
64
+ workspace: &Path,
65
+ agent_id: &AgentId,
66
+ team: Option<&str>,
67
+ ) -> Result<RemoveAgentFlagRequirements, LifecycleError> {
68
+ let paths = lifecycle_paths(workspace, team)?;
69
+ let transport =
70
+ lifecycle_worker_tmux_backend_for_selected_state(&paths.run_workspace, team)?;
71
+ Ok(remove_agent_preflight(
72
+ &paths.run_workspace,
73
+ &paths.spec_workspace,
74
+ agent_id,
75
+ team,
76
+ &transport,
77
+ )?
78
+ .requirements)
79
+ }
80
+
81
+ struct RemoveAgentPreflight {
82
+ state: serde_json::Value,
83
+ spec: YamlValue,
84
+ requirements: RemoveAgentFlagRequirements,
85
+ }
86
+
87
+ fn remove_agent_preflight(
64
88
  workspace: &Path,
65
89
  spec_workspace: &Path,
66
90
  agent_id: &AgentId,
67
- from_spec: bool,
68
- force: bool,
69
91
  team: Option<&str>,
70
92
  transport: &dyn crate::transport::Transport,
71
- ) -> Result<RemoveAgentOutcome, LifecycleError> {
93
+ ) -> Result<RemoveAgentPreflight, LifecycleError> {
72
94
  // golden agents.py:34-41: resolve_team_scoped_state FIRST (surfaces the team_target_ambiguous /
73
95
  // team_target_unresolved refusal before the owner gate), THEN the owner gate, THEN load_spec +
74
96
  // _find_worker (unknown-worker raise). Mirror the stop/reset wiring so remove is byte-symmetric:
@@ -80,27 +102,84 @@ fn remove_agent_at_paths(
80
102
  return Err(unknown_worker(agent_id));
81
103
  };
82
104
  let dynamic_agent = is_dynamic_agent(&state, spec_agent, agent_id);
83
- if !dynamic_agent && !from_spec {
84
- return Ok(RemoveAgentOutcome::RefusedFromSpecConfirm {
85
- agent_id: agent_id.clone(),
86
- });
87
- }
88
- if agent_is_running(&state, agent_id, transport) && !force {
89
- return Ok(RemoveAgentOutcome::RefusedForceRequired {
105
+ let force_required = agent_is_running(&state, agent_id, transport);
106
+ let has_session = state
107
+ .get("session_name")
108
+ .and_then(|v| v.as_str())
109
+ .is_some_and(|s| !s.is_empty())
110
+ || state
111
+ .get("agents")
112
+ .and_then(|v| v.get(agent_id.as_str()))
113
+ .is_some_and(agent_has_session);
114
+ Ok(RemoveAgentPreflight {
115
+ state,
116
+ spec,
117
+ requirements: RemoveAgentFlagRequirements {
90
118
  agent_id: agent_id.clone(),
119
+ from_spec_required: !dynamic_agent,
120
+ force_required,
121
+ has_session,
122
+ },
123
+ })
124
+ }
125
+
126
+ fn agent_has_session(agent: &serde_json::Value) -> bool {
127
+ ["session_id", "_pending_session_id", "rollout_path"]
128
+ .iter()
129
+ .any(|key| {
130
+ agent
131
+ .get(key)
132
+ .and_then(|v| v.as_str())
133
+ .is_some_and(|s| !s.is_empty())
134
+ })
135
+ }
136
+
137
+ fn remove_agent_at_paths(
138
+ workspace: &Path,
139
+ spec_workspace: &Path,
140
+ agent_id: &AgentId,
141
+ from_spec: bool,
142
+ force: bool,
143
+ team: Option<&str>,
144
+ transport: &dyn crate::transport::Transport,
145
+ ) -> Result<RemoveAgentOutcome, LifecycleError> {
146
+ let preflight = remove_agent_preflight(workspace, spec_workspace, agent_id, team, transport)?;
147
+ let missing_from_spec = preflight.requirements.from_spec_required && !from_spec;
148
+ let missing_force = preflight.requirements.force_required && !force;
149
+ if missing_from_spec || missing_force {
150
+ return Ok(if missing_from_spec && missing_force {
151
+ RemoveAgentOutcome::RefusedRequiredFlags {
152
+ agent_id: agent_id.clone(),
153
+ from_spec_required: true,
154
+ force_required: true,
155
+ }
156
+ } else if missing_from_spec {
157
+ RemoveAgentOutcome::RefusedFromSpecConfirm {
158
+ agent_id: agent_id.clone(),
159
+ }
160
+ } else {
161
+ RemoveAgentOutcome::RefusedForceRequired {
162
+ agent_id: agent_id.clone(),
163
+ }
91
164
  });
92
165
  }
93
166
  let paths = LifecyclePathRefs {
94
167
  run_workspace: workspace,
95
168
  spec_workspace,
96
169
  };
97
- let mut rollback = RemoveRollback::capture(paths.run_workspace, paths.spec_workspace, &spec, &state, agent_id)?;
98
- rollback.restore_running = force && agent_is_running(&state, agent_id, transport);
170
+ let mut rollback = RemoveRollback::capture(
171
+ paths.run_workspace,
172
+ paths.spec_workspace,
173
+ &preflight.spec,
174
+ &preflight.state,
175
+ agent_id,
176
+ )?;
177
+ rollback.restore_running = force && preflight.requirements.force_required;
99
178
  let result = remove_agent_inner(
100
179
  &paths,
101
180
  agent_id,
102
- &spec,
103
- state,
181
+ &preflight.spec,
182
+ preflight.state,
104
183
  force,
105
184
  team,
106
185
  transport,
@@ -50,7 +50,7 @@ pub use rebuild::{
50
50
  restart, restart_candidates, restart_with_session_convergence_deadline, restart_with_transport,
51
51
  restart_with_transport_with_readiness_deadline, select_restart_state,
52
52
  };
53
- pub use remove::{remove_agent, remove_agent_with_transport};
53
+ pub use remove::{remove_agent, remove_agent_flag_requirements, remove_agent_with_transport};
54
54
  pub use selection::{
55
55
  classify_first_send_at, classify_restart_plan, decide_start_mode, python_type_name,
56
56
  };
@@ -609,6 +609,15 @@ pub struct ForkAgentReport {
609
609
  pub session_id: Option<SessionId>,
610
610
  }
611
611
 
612
+ /// Read-only remove-agent flag requirements for the target's current state.
613
+ #[derive(Debug, Clone, PartialEq, Eq)]
614
+ pub struct RemoveAgentFlagRequirements {
615
+ pub agent_id: AgentId,
616
+ pub from_spec_required: bool,
617
+ pub force_required: bool,
618
+ pub has_session: bool,
619
+ }
620
+
612
621
  /// `remove_agent(...)` 结果(`agents.py:54/56/150`)。`_RemoveRollback` 快照
613
622
  /// spec/state/team_state/role_file/agent_health 字节级回滚(Gap 16)。
614
623
  #[derive(Debug, Clone, PartialEq, Eq)]
@@ -624,6 +633,12 @@ pub enum RemoveAgentOutcome {
624
633
  RefusedFromSpecConfirm { agent_id: AgentId },
625
634
  /// 运行中未传 force(`agents.py:56`)。
626
635
  RefusedForceRequired { agent_id: AgentId },
636
+ /// Multiple required destructive flags are absent; report them together.
637
+ RefusedRequiredFlags {
638
+ agent_id: AgentId,
639
+ from_spec_required: bool,
640
+ force_required: bool,
641
+ },
627
642
  }
628
643
 
629
644
  /// `restart(...)` 结果(`orchestration.py:114/142/387`)。Route B:**先全量验证**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.6",
24
- "@team-agent/cli-darwin-x64": "0.5.6",
25
- "@team-agent/cli-linux-x64": "0.5.6"
23
+ "@team-agent/cli-darwin-arm64": "0.5.7",
24
+ "@team-agent/cli-darwin-x64": "0.5.7",
25
+ "@team-agent/cli-linux-x64": "0.5.7"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",