@team-agent/installer 0.3.25 → 0.3.27

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 (29) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/emit.rs +1 -0
  4. package/crates/team-agent/src/cli/mod.rs +193 -7
  5. package/crates/team-agent/src/cli/send.rs +106 -0
  6. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  7. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +270 -13
  8. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  9. package/crates/team-agent/src/cli/types.rs +7 -0
  10. package/crates/team-agent/src/coordinator/tests/energy.rs +1 -0
  11. package/crates/team-agent/src/coordinator/tests/health_sync.rs +1 -0
  12. package/crates/team-agent/src/coordinator/tests/spine.rs +1 -0
  13. package/crates/team-agent/src/coordinator/tests/takeover.rs +1 -0
  14. package/crates/team-agent/src/leader/lease.rs +57 -0
  15. package/crates/team-agent/src/leader/start.rs +1 -0
  16. package/crates/team-agent/src/lifecycle/launch.rs +9 -0
  17. package/crates/team-agent/src/lifecycle/restart/agent.rs +55 -0
  18. package/crates/team-agent/src/messaging/delivery.rs +83 -3
  19. package/crates/team-agent/src/messaging/leader_receiver.rs +61 -24
  20. package/crates/team-agent/src/messaging/tests/runtime.rs +2 -0
  21. package/crates/team-agent/src/messaging/tests/spine.rs +1 -0
  22. package/crates/team-agent/src/tmux_backend/tests.rs +227 -25
  23. package/crates/team-agent/src/tmux_backend.rs +409 -105
  24. package/crates/team-agent/src/transport/test_support.rs +1 -0
  25. package/crates/team-agent/src/transport/tests/mod.rs +1 -0
  26. package/crates/team-agent/src/transport/tests/wire.rs +2 -1
  27. package/crates/team-agent/src/transport.rs +77 -0
  28. package/npm/install.mjs +222 -14
  29. 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.25"
569
+ version = "0.3.27"
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.25"
12
+ version = "0.3.27"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -887,6 +887,7 @@ fn send_args(args: &[String], cwd: &Path) -> Result<SendArgs, CliError> {
887
887
  confirm_human: parsed.confirm_human,
888
888
  json: parsed.json,
889
889
  message_id: parsed.message_id,
890
+ pane: parsed.pane.clone(),
890
891
  })
891
892
  }
892
893
 
@@ -392,9 +392,23 @@ pub mod lifecycle_port {
392
392
  .and_then(Value::as_str)
393
393
  .filter(|session| !session.is_empty())
394
394
  .map(crate::transport::SessionName::new);
395
+ // E49 (0.3.24 P0, shutdown kills leader CLI): for managed leader topology
396
+ // the target session may carry the leader anchor pane. The pre-fix code
397
+ // pushed it into `to_kill` and then issued `kill_session` — which ended
398
+ // the leader pane (= leader CLI). When the target session has a live
399
+ // anchor pane in `list_targets`, spare it instead.
400
+ let leader_anchor_ids = collect_state_leader_anchor_pane_ids(state);
401
+ let live_targets = transport.list_targets().unwrap_or_default();
395
402
  let mut to_kill = Vec::new();
403
+ let mut target_spared_for_anchor: Option<crate::transport::SessionName> = None;
396
404
  if let Some(target) = target {
397
- if sessions.is_empty()
405
+ let target_has_anchor = live_targets.iter().any(|t| {
406
+ t.session.as_str() == target.as_str()
407
+ && leader_anchor_ids.contains(t.pane_id.as_str())
408
+ });
409
+ if target_has_anchor {
410
+ target_spared_for_anchor = Some(target);
411
+ } else if sessions.is_empty()
398
412
  || sessions
399
413
  .iter()
400
414
  .any(|session| session.as_str() == target.as_str())
@@ -402,7 +416,7 @@ pub mod lifecycle_port {
402
416
  to_kill.push(target);
403
417
  }
404
418
  }
405
- let spared = sessions
419
+ let mut spared = sessions
406
420
  .iter()
407
421
  .filter(|session| {
408
422
  !to_kill
@@ -411,6 +425,14 @@ pub mod lifecycle_port {
411
425
  })
412
426
  .cloned()
413
427
  .collect::<Vec<_>>();
428
+ if let Some(anchored) = target_spared_for_anchor {
429
+ if !spared
430
+ .iter()
431
+ .any(|s| s.as_str() == anchored.as_str())
432
+ {
433
+ spared.push(anchored);
434
+ }
435
+ }
414
436
  let _ = event_log.write(
415
437
  "shutdown.kill_server_skipped_managed_leader",
416
438
  json!({
@@ -628,13 +650,63 @@ pub mod lifecycle_port {
628
650
  let mut spared_sessions = Vec::new();
629
651
  deadline.check("kill_session")?;
630
652
  if let Some(session) = session_name.as_ref() {
631
- push_unique_session(&mut killed_sessions, session.clone());
632
- if let Err(error) = transport.kill_session(session) {
633
- if !tmux_absent_error(&error.to_string()) {
634
- kill_error = Some(error.to_string());
653
+ // E49 (0.3.24 P0, shutdown kills leader CLI): managed-leader topology
654
+ // means the leader pane lives INSIDE state.session_name. The pre-fix
655
+ // `transport.kill_session(state.session_name)` ended the leader pane —
656
+ // i.e. terminated the user's own CLI. User truth: shutdown must never
657
+ // close the leader CLI that launched it. Detect via state.is_external_leader
658
+ // (state_is_managed_leader = !external) AND the presence of a leader
659
+ // anchor pane id (leader_receiver / team_owner) that lives in this
660
+ // session per `list_targets`. When managed-leader, switch to per-pane
661
+ // cleanup: kill the workers individually and SPARE the leader's pane.
662
+ //
663
+ // External leader (is_external_leader=true) keeps the unconditional
664
+ // kill_session — the team session is a disposable worker session in
665
+ // that topology, the leader pane lives elsewhere.
666
+ let leader_anchor_ids = collect_state_leader_anchor_pane_ids(&state);
667
+ let live_targets_now = transport.list_targets().unwrap_or_default();
668
+ let session_has_leader_anchor = live_targets_now.iter().any(|target| {
669
+ target.session.as_str() == session.as_str()
670
+ && leader_anchor_ids.contains(target.pane_id.as_str())
671
+ });
672
+ if crate::state::projection::state_is_managed_leader(&state)
673
+ && session_has_leader_anchor
674
+ {
675
+ let _ = event_log_write_session_spared(
676
+ &run_workspace,
677
+ session,
678
+ &leader_anchor_ids,
679
+ );
680
+ push_unique_session(&mut spared_sessions, session.clone());
681
+ let worker_panes = collect_session_worker_panes(
682
+ &state,
683
+ session.as_str(),
684
+ &leader_anchor_ids,
685
+ &live_targets_now,
686
+ );
687
+ for pane in &worker_panes {
688
+ if let Err(error) = transport.kill_pane(pane) {
689
+ if !tmux_absent_error(&error.to_string()) {
690
+ kill_error.get_or_insert_with(|| error.to_string());
691
+ }
692
+ }
635
693
  }
694
+ let _ = crate::lifecycle::display::close_team_display_backends(
695
+ &run_workspace,
696
+ session,
697
+ );
698
+ } else {
699
+ push_unique_session(&mut killed_sessions, session.clone());
700
+ if let Err(error) = transport.kill_session(session) {
701
+ if !tmux_absent_error(&error.to_string()) {
702
+ kill_error = Some(error.to_string());
703
+ }
704
+ }
705
+ let _ = crate::lifecycle::display::close_team_display_backends(
706
+ &run_workspace,
707
+ session,
708
+ );
636
709
  }
637
- let _ = crate::lifecycle::display::close_team_display_backends(&run_workspace, session);
638
710
  }
639
711
  deadline.check("reap_workspace_residuals")?;
640
712
  reap_workspace_process_residuals(
@@ -1368,6 +1440,120 @@ pub mod lifecycle_port {
1368
1440
  out
1369
1441
  }
1370
1442
 
1443
+ /// E49 (0.3.24 P0, shutdown kills leader CLI): collect worker pane ids on the
1444
+ /// given `session` from state.agents + teams[*].agents, EXCLUDING any pane id
1445
+ /// in `leader_anchor_ids`. If an agent has no `pane_id` but its window appears
1446
+ /// in `live_targets` under the same session, fall back to the live `pane_id`
1447
+ /// for that window — so we still kill the worker pane via the structural
1448
+ /// addressing path rather than the session-level `kill_session` that would
1449
+ /// also end the leader pane (the E49 bug).
1450
+ fn collect_session_worker_panes(
1451
+ state: &Value,
1452
+ session: &str,
1453
+ leader_anchor_ids: &std::collections::BTreeSet<String>,
1454
+ live_targets: &[crate::transport::PaneInfo],
1455
+ ) -> Vec<crate::transport::PaneId> {
1456
+ let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1457
+ let mut out: Vec<crate::transport::PaneId> = Vec::new();
1458
+ collect_session_worker_panes_from_agents(
1459
+ state.get("agents"),
1460
+ session,
1461
+ leader_anchor_ids,
1462
+ live_targets,
1463
+ &mut seen,
1464
+ &mut out,
1465
+ );
1466
+ if let Some(teams) = state.get("teams").and_then(Value::as_object) {
1467
+ for (_, team_state) in teams {
1468
+ collect_session_worker_panes_from_agents(
1469
+ team_state.get("agents"),
1470
+ session,
1471
+ leader_anchor_ids,
1472
+ live_targets,
1473
+ &mut seen,
1474
+ &mut out,
1475
+ );
1476
+ }
1477
+ }
1478
+ out
1479
+ }
1480
+
1481
+ fn collect_session_worker_panes_from_agents(
1482
+ agents: Option<&Value>,
1483
+ session: &str,
1484
+ leader_anchor_ids: &std::collections::BTreeSet<String>,
1485
+ live_targets: &[crate::transport::PaneInfo],
1486
+ seen: &mut std::collections::BTreeSet<String>,
1487
+ out: &mut Vec<crate::transport::PaneId>,
1488
+ ) {
1489
+ let Some(map) = agents.and_then(Value::as_object) else {
1490
+ return;
1491
+ };
1492
+ for (agent_id, agent) in map {
1493
+ let claimed_pane = agent
1494
+ .get("pane_id")
1495
+ .and_then(Value::as_str)
1496
+ .filter(|s| !s.is_empty());
1497
+ // Try state-claimed pane id first; cross-check with live_targets to
1498
+ // skip dead state entries (a stale pane_id from a respawn).
1499
+ if let Some(pane_id) = claimed_pane {
1500
+ if leader_anchor_ids.contains(pane_id) {
1501
+ continue;
1502
+ }
1503
+ let in_live = live_targets
1504
+ .iter()
1505
+ .any(|info| info.pane_id.as_str() == pane_id);
1506
+ if in_live && seen.insert(pane_id.to_string()) {
1507
+ out.push(crate::transport::PaneId::new(pane_id));
1508
+ continue;
1509
+ }
1510
+ }
1511
+ // Fallback: agent window + session match a live pane (E43 lineage:
1512
+ // tmux respawn-pane bumps the %id while the window stays). Use the
1513
+ // live pane id for that window.
1514
+ let window = agent
1515
+ .get("window")
1516
+ .and_then(Value::as_str)
1517
+ .filter(|s| !s.is_empty())
1518
+ .unwrap_or(agent_id.as_str());
1519
+ if let Some(info) = live_targets.iter().find(|info| {
1520
+ info.session.as_str() == session
1521
+ && info
1522
+ .window_name
1523
+ .as_ref()
1524
+ .is_some_and(|name| name.as_str() == window)
1525
+ }) {
1526
+ let pane_str = info.pane_id.as_str();
1527
+ if leader_anchor_ids.contains(pane_str) {
1528
+ continue;
1529
+ }
1530
+ if seen.insert(pane_str.to_string()) {
1531
+ out.push(info.pane_id.clone());
1532
+ }
1533
+ }
1534
+ }
1535
+ }
1536
+
1537
+ /// E49 (0.3.24 P0): emit a structured event documenting the session was
1538
+ /// spared because it carries a managed-leader anchor pane. Lets operators
1539
+ /// see why kill_session was skipped.
1540
+ fn event_log_write_session_spared(
1541
+ workspace: &Path,
1542
+ session: &crate::transport::SessionName,
1543
+ leader_anchor_ids: &std::collections::BTreeSet<String>,
1544
+ ) -> Result<(), CliError> {
1545
+ let event_log = crate::event_log::EventLog::new(workspace);
1546
+ let _ = event_log.write(
1547
+ "shutdown.session_spared_managed_leader",
1548
+ json!({
1549
+ "session": session.as_str(),
1550
+ "reason": "managed_leader_anchor_in_session",
1551
+ "leader_anchor_pane_ids": leader_anchor_ids.iter().collect::<Vec<_>>(),
1552
+ }),
1553
+ );
1554
+ Ok(())
1555
+ }
1556
+
1371
1557
  /// 单帧扫 team_owner.pane_id + leader_receiver.pane_id → BTreeSet 累加。
1372
1558
  fn push_anchor_pane_id(state: &Value, out: &mut std::collections::BTreeSet<String>) {
1373
1559
  for key in &["team_owner", "leader_receiver"] {
@@ -7,6 +7,32 @@ use crate::messaging::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, Delivery
7
7
  /// `cmd_send`(`commands.py:164`)。解析 target(`--to` fanout / 单 target / `*`)→ [`MessageTarget`],
8
8
  /// 拼 [`SendOptions`](no_ack→requires_ack 取反、no_wait→wait_visible 取反等)→ `messaging::send_message`。
9
9
  pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
10
+ // F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
11
+ // Mutually exclusive with target / --to (agent-name routing).
12
+ if let Some(ref pane_id) = args.pane {
13
+ if args.target.is_some() || args.targets.is_some() {
14
+ return Err(CliError::Usage(
15
+ "--pane and TARGET/--to are mutually exclusive: \
16
+ --pane bypasses agent-name routing and injects directly into the \
17
+ specified tmux pane (cross-team capable)"
18
+ .to_string(),
19
+ ));
20
+ }
21
+ let content = args.message.join(" ");
22
+ if content.is_empty() {
23
+ return Err(CliError::Usage("--pane requires a non-empty message".to_string()));
24
+ }
25
+ let value = send_to_pane_direct(
26
+ &args.workspace,
27
+ pane_id,
28
+ &content,
29
+ &args.sender,
30
+ args.task.as_deref(),
31
+ args.team.as_deref(),
32
+ args.json,
33
+ )?;
34
+ return Ok(CmdResult::from_json(value, args.json));
35
+ }
10
36
  let selected = crate::state::selector::resolve_active_team(
11
37
  &args.workspace,
12
38
  args.team.as_deref(),
@@ -37,6 +63,86 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
37
63
  Ok(CmdResult::from_json(value, args.json))
38
64
  }
39
65
 
66
+ /// F1 (0.3.26): direct pane-id send — bypasses agent-name routing + team
67
+ /// membership check. Constructs `Target::Pane`, renders the message with
68
+ /// the standard protocol block (Team Agent message from sender + token),
69
+ /// injects via the default tmux backend, and surfaces the inject report as
70
+ /// a JSON result. Cross-team capable: no restriction on which tmux session
71
+ /// or socket the pane belongs to.
72
+ fn send_to_pane_direct(
73
+ workspace: &Path,
74
+ pane_id: &str,
75
+ content: &str,
76
+ sender: &str,
77
+ task_id: Option<&str>,
78
+ team: Option<&str>,
79
+ json: bool,
80
+ ) -> Result<serde_json::Value, CliError> {
81
+ use crate::messaging::delivery::render_message;
82
+ use crate::transport::{InjectPayload, Key, PaneId, Target};
83
+
84
+ let message_id = format!("pane_send_{}", chrono::Utc::now().timestamp_millis());
85
+ let rendered = render_message(sender, task_id, content, &message_id);
86
+ let target = Target::Pane(PaneId::new(pane_id));
87
+ let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
88
+ .unwrap_or_else(|_| workspace.to_path_buf());
89
+ let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
90
+ &run_workspace,
91
+ team,
92
+ )
93
+ .unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&run_workspace));
94
+ let event_log = crate::event_log::EventLog::new(&run_workspace);
95
+ // Warn if the pane is not in the team's known agents (cross-team usage).
96
+ let state = crate::state::persist::load_runtime_state(&run_workspace).ok();
97
+ let in_team = state
98
+ .as_ref()
99
+ .and_then(|s| s.get("agents"))
100
+ .and_then(serde_json::Value::as_object)
101
+ .is_some_and(|agents| {
102
+ agents.values().any(|agent| {
103
+ agent
104
+ .get("pane_id")
105
+ .and_then(serde_json::Value::as_str)
106
+ .is_some_and(|p| p == pane_id)
107
+ })
108
+ });
109
+ if !in_team {
110
+ eprintln!(
111
+ "warning: pane {pane_id} is not in the team's known agents — \
112
+ cross-team delivery (F1)"
113
+ );
114
+ }
115
+ let transport: &dyn crate::transport::Transport = &transport;
116
+ let report = transport
117
+ .inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
118
+ .map_err(|e| CliError::Runtime(format!("inject to pane {pane_id} failed: {e}")))?;
119
+ let _ = event_log.write(
120
+ "send.pane_direct",
121
+ serde_json::json!({
122
+ "pane_id": pane_id,
123
+ "sender": sender,
124
+ "message_id": message_id,
125
+ "submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
126
+ "inject_verification": format!("{:?}", report.inject_verification),
127
+ "in_team": in_team,
128
+ }),
129
+ );
130
+ let ok = matches!(
131
+ report.submit_verification,
132
+ crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck
133
+ | crate::transport::SubmitVerification::PastedContentPromptAbsentAfterSubmit
134
+ | crate::transport::SubmitVerification::KeySentAfterVisibleToken { .. }
135
+ );
136
+ Ok(serde_json::json!({
137
+ "ok": ok,
138
+ "pane_id": pane_id,
139
+ "message_id": message_id,
140
+ "submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
141
+ "inject_verification": format!("{:?}", report.inject_verification),
142
+ "in_team": in_team,
143
+ }))
144
+ }
145
+
40
146
  pub fn cmd_fallback_send_leader(args: &FallbackSendLeaderArgs) -> Result<CmdResult, CliError> {
41
147
  if let Some(value) = fallback_business_refusal(&args.primary_error, args.json) {
42
148
  return Ok(value);
@@ -49,6 +49,7 @@ use super::*;
49
49
  confirm_human: false,
50
50
  json: true,
51
51
  message_id: None,
52
+ pane: None,
52
53
  };
53
54
  let _ = cmd_send(&args);
54
55