@team-agent/installer 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/attach_app_server_leader.rs +82 -1
  4. package/crates/team-agent/src/cli/diagnose.rs +46 -17
  5. package/crates/team-agent/src/cli/emit.rs +30 -1
  6. package/crates/team-agent/src/cli/leaders.rs +94 -0
  7. package/crates/team-agent/src/cli/mod.rs +138 -2
  8. package/crates/team-agent/src/cli/named_address.rs +288 -28
  9. package/crates/team-agent/src/cli/send.rs +230 -0
  10. package/crates/team-agent/src/cli/status_port.rs +117 -14
  11. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/named_address.rs +9 -3
  13. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  14. package/crates/team-agent/src/cli/types.rs +14 -0
  15. package/crates/team-agent/src/coordinator/tick.rs +7 -1
  16. package/crates/team-agent/src/db/agent_health_capture.rs +96 -0
  17. package/crates/team-agent/src/db/mod.rs +1 -0
  18. package/crates/team-agent/src/leader/mod.rs +1 -0
  19. package/crates/team-agent/src/leader/registry.rs +402 -0
  20. package/crates/team-agent/src/lifecycle/restart/remove.rs +13 -61
  21. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -0
  22. package/crates/team-agent/src/mcp_server/helpers.rs +18 -1
  23. package/crates/team-agent/src/mcp_server/normalize.rs +79 -0
  24. package/crates/team-agent/src/mcp_server/tests/send.rs +125 -13
  25. package/crates/team-agent/src/mcp_server/tools.rs +24 -2
  26. package/crates/team-agent/src/messaging/delivery.rs +12 -5
  27. package/crates/team-agent/src/messaging/leader_receiver.rs +58 -0
  28. package/crates/team-agent/src/messaging/mod.rs +2 -1
  29. package/crates/team-agent/src/messaging/results.rs +53 -0
  30. package/crates/team-agent/src/messaging/tests/runtime.rs +7 -2
  31. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  32. package/package.json +4 -4
@@ -18,6 +18,14 @@ pub(crate) enum NamedAddressErrorKind {
18
18
  NameInvalid,
19
19
  WorkspaceNotFound,
20
20
  StateNotFound,
21
+ // E6 (0.5.9 offline-mailbox-toname-design §3.1): three-way taxonomy for
22
+ // `--to-name <team>/leader` refusal — required by the E6 RED so the top-level
23
+ // `leader_receiver` fallback is no longer reached for a wrong runtime key.
24
+ // `WorkspaceNoState` mirrors `StateNotFound` semantics but keeps the wire
25
+ // reason distinct for third-party sender copy.
26
+ WorkspaceNoState,
27
+ TeamKeyNotFound,
28
+ LeaderNotAttached,
21
29
  NameNotResolvable,
22
30
  NameNotLive,
23
31
  NameAmbiguous,
@@ -29,6 +37,9 @@ impl NamedAddressErrorKind {
29
37
  Self::NameInvalid => "name_invalid",
30
38
  Self::WorkspaceNotFound => "workspace_not_found",
31
39
  Self::StateNotFound => "state_not_found",
40
+ Self::WorkspaceNoState => "workspace_no_state",
41
+ Self::TeamKeyNotFound => "team_key_not_found",
42
+ Self::LeaderNotAttached => "leader_not_attached",
32
43
  Self::NameNotResolvable => "name_not_resolvable",
33
44
  Self::NameNotLive => "name_not_live",
34
45
  Self::NameAmbiguous => "name_ambiguous",
@@ -43,6 +54,13 @@ pub(crate) struct NamedAddressError {
43
54
  pub action: String,
44
55
  pub log: String,
45
56
  pub candidates: Vec<Value>,
57
+ // E6: when the caller supplied a `<team>/leader` name that isn't a runtime
58
+ // team_key, echo the requested value + the canonical alternatives so
59
+ // spec/display-name confusion is visibly diagnosed. Empty for other
60
+ // refusal kinds.
61
+ pub requested_team: Option<String>,
62
+ pub available_team_keys: Vec<String>,
63
+ pub suggested_name: Option<String>,
46
64
  }
47
65
 
48
66
  impl NamedAddressError {
@@ -53,6 +71,9 @@ impl NamedAddressError {
53
71
  action: "inspect team-agent status and use an explicit workspace/team name".to_string(),
54
72
  log: "named-address resolver refused before injection".to_string(),
55
73
  candidates: Vec::new(),
74
+ requested_team: None,
75
+ available_team_keys: Vec::new(),
76
+ suggested_name: None,
56
77
  }
57
78
  }
58
79
 
@@ -66,15 +87,35 @@ impl NamedAddressError {
66
87
  }
67
88
 
68
89
  pub(crate) fn to_json(&self) -> Value {
69
- json!({
70
- "ok": false,
71
- "status": "refused",
72
- "reason": self.kind.as_str(),
73
- "error": self.message,
74
- "action": self.action,
75
- "log": self.log,
76
- "candidates": self.candidates,
77
- })
90
+ let mut obj = serde_json::Map::new();
91
+ obj.insert("ok".to_string(), Value::Bool(false));
92
+ obj.insert("status".to_string(), Value::String("refused".to_string()));
93
+ obj.insert(
94
+ "reason".to_string(),
95
+ Value::String(self.kind.as_str().to_string()),
96
+ );
97
+ obj.insert("error".to_string(), Value::String(self.message.clone()));
98
+ obj.insert("action".to_string(), Value::String(self.action.clone()));
99
+ obj.insert("log".to_string(), Value::String(self.log.clone()));
100
+ obj.insert("candidates".to_string(), Value::Array(self.candidates.clone()));
101
+ if let Some(requested) = self.requested_team.as_deref() {
102
+ obj.insert("requested_team".to_string(), Value::String(requested.to_string()));
103
+ }
104
+ if !self.available_team_keys.is_empty() {
105
+ obj.insert(
106
+ "available_team_keys".to_string(),
107
+ Value::Array(
108
+ self.available_team_keys
109
+ .iter()
110
+ .map(|k| Value::String(k.clone()))
111
+ .collect(),
112
+ ),
113
+ );
114
+ }
115
+ if let Some(suggested) = self.suggested_name.as_deref() {
116
+ obj.insert("suggested_name".to_string(), Value::String(suggested.to_string()));
117
+ }
118
+ Value::Object(obj)
78
119
  }
79
120
  }
80
121
 
@@ -531,6 +572,61 @@ fn resolve_bare_agent(
531
572
  }
532
573
  }
533
574
 
575
+ /// Phase-DX E3 (plan §4 / CR supplements A/B/C, P0 red lines #1 & #6):
576
+ /// build advisory diagnostic candidates for a failed `--to-name leader`
577
+ /// resolution. The candidate list is deliberately shaped to be **read by
578
+ /// humans**, not consumed as route authority: every entry carries
579
+ /// `advisory: true` and a `source` tag identifying where it was observed.
580
+ ///
581
+ /// Candidate source rules:
582
+ /// - The transport passed in is already scoped by [`transport_for_cli_target`]
583
+ /// to the team's recorded `leader_receiver.tmux_socket` (or the workspace
584
+ /// default when no socket was recorded), so `transport.list_targets()`
585
+ /// enumerates only that endpoint. We do NOT scan other tmux sockets or
586
+ /// fall back to reverse-authority tmux-session discovery.
587
+ /// - `pane_id` fields are copied verbatim from `list_targets`. `session` is
588
+ /// the tmux session, and `socket` records the tmux endpoint the candidate
589
+ /// was seen on (whichever the scoped transport reports).
590
+ ///
591
+ /// Callers must never treat any candidate as authoritative; MUST-NOT-12 stays
592
+ /// intact — resolver refusal is still the outcome even when candidates fire.
593
+ fn leader_advisory_candidates(
594
+ state: &Value,
595
+ team: &str,
596
+ transport: &dyn Transport,
597
+ source: &str,
598
+ ) -> Vec<Value> {
599
+ let team_scoped_socket = team_entry(state, team)
600
+ .and_then(|entry| entry.get("leader_receiver"))
601
+ .and_then(|receiver| string_field(receiver, "tmux_socket"));
602
+ let socket = match team_scoped_socket {
603
+ Some(s) => Some(s.to_string()),
604
+ None => state
605
+ // ALLOWED-LEGACY-SINGLE-TEAM: diagnostic-only socket label for the advisory candidates; never fed back into routing.
606
+ .get("leader_receiver")
607
+ .and_then(|receiver| string_field(receiver, "tmux_socket"))
608
+ .map(str::to_string)
609
+ .or_else(|| transport.tmux_endpoint()),
610
+ };
611
+ let targets = match transport.list_targets() {
612
+ Ok(targets) => targets,
613
+ Err(_) => return Vec::new(),
614
+ };
615
+ targets
616
+ .into_iter()
617
+ .map(|pane| {
618
+ json!({
619
+ "pane_id": pane.pane_id.as_str(),
620
+ "session": pane.session.as_str(),
621
+ "window": pane.window_name.as_ref().map(|w| w.as_str()),
622
+ "socket": socket,
623
+ "source": source,
624
+ "advisory": json!(true),
625
+ })
626
+ })
627
+ .collect()
628
+ }
629
+
534
630
  fn resolve_leader(
535
631
  sender_workspace: &Path,
536
632
  target_workspace: &Path,
@@ -539,12 +635,27 @@ fn resolve_leader(
539
635
  parsed: &ParsedNamedAddress,
540
636
  transport: &dyn Transport,
541
637
  ) -> Result<ResolvedNamedAddress, NamedAddressError> {
542
- let receiver = if let Some(team_entry) = team_entry(state, team) {
543
- team_entry.get("leader_receiver")
544
- } else {
545
- state.get("leader_receiver")
546
- }
547
- .ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
638
+ // E6 taxonomy split (0.5.9 offline-mailbox-toname-design §3.1): before
639
+ // reading any leader_receiver, decide whether `<team>` is actually the
640
+ // runtime team key. Wrong keys must not fall through to top-level
641
+ // leader_receiver (that was the pre-fix bug where `twitter-autopub/leader`
642
+ // silently reused the `current` team's receiver and reported a stale
643
+ // pane_id). Legacy single-team compat is preserved only when the state
644
+ // has no `teams` map yet and the requested key equals `active_team_key`.
645
+ let receiver = resolve_team_scoped_leader_receiver(state, team, target_workspace)?
646
+ .ok_or_else(|| {
647
+ let mut err = leader_not_attached_third_party(
648
+ target_workspace,
649
+ team,
650
+ None,
651
+ None,
652
+ None,
653
+ transport.tmux_endpoint(),
654
+ );
655
+ err.candidates =
656
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
657
+ err
658
+ })?;
548
659
  if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
549
660
  let mut err = NamedAddressError::new(
550
661
  NamedAddressErrorKind::NameNotLive,
@@ -569,7 +680,19 @@ fn resolve_leader(
569
680
  }
570
681
  let pane_id = string_field(receiver, "pane_id")
571
682
  .filter(|pane| !pane.is_empty())
572
- .ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
683
+ .ok_or_else(|| {
684
+ let mut err = leader_not_attached_third_party(
685
+ target_workspace,
686
+ team,
687
+ None,
688
+ None,
689
+ None,
690
+ transport.tmux_endpoint(),
691
+ );
692
+ err.candidates =
693
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
694
+ err
695
+ })?;
573
696
  let session = string_field(receiver, "session_name");
574
697
  let window = string_field(receiver, "window_name");
575
698
  let socket = string_field(receiver, "tmux_socket")
@@ -599,13 +722,19 @@ fn resolve_leader(
599
722
  agent_status: None,
600
723
  warning: None,
601
724
  }),
602
- 0 => Err(name_not_live_leader(
603
- team,
604
- Some(pane_id),
605
- session,
606
- window,
607
- socket,
608
- )),
725
+ 0 => {
726
+ let mut err = leader_not_attached_third_party(
727
+ target_workspace,
728
+ team,
729
+ Some(pane_id),
730
+ session,
731
+ window,
732
+ socket,
733
+ );
734
+ err.candidates =
735
+ leader_advisory_candidates(state, team, transport, "state_recorded_socket");
736
+ Err(err)
737
+ }
609
738
  _ => Err(name_ambiguous(
610
739
  "multiple live panes matched leader_receiver pane id",
611
740
  matches
@@ -775,12 +904,18 @@ fn transport_for_cli_target(
775
904
  ) -> Box<dyn Transport> {
776
905
  if let ParsedTarget::TeamEntity { team, entity } = &parsed.target {
777
906
  if entity == "leader" {
778
- if let Some(socket) = team_entry(state, team)
907
+ let team_scoped_socket = team_entry(state, team)
779
908
  .and_then(|entry| entry.get("leader_receiver"))
780
- .or_else(|| state.get("leader_receiver"))
781
- .and_then(|receiver| string_field(receiver, "tmux_socket"))
782
- {
783
- return Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(socket));
909
+ .and_then(|receiver| string_field(receiver, "tmux_socket"));
910
+ let socket = team_scoped_socket.map(str::to_string).or_else(|| {
911
+ state
912
+ // ALLOWED-LEGACY-SINGLE-TEAM: pre-teams-map compat probe endpoint; canonical taxonomy in `resolve_team_scoped_leader_receiver` still gates identity.
913
+ .get("leader_receiver")
914
+ .and_then(|receiver| string_field(receiver, "tmux_socket"))
915
+ .map(str::to_string)
916
+ });
917
+ if let Some(socket) = socket {
918
+ return Box::new(crate::tmux_backend::TmuxBackend::for_tmux_endpoint(&socket));
784
919
  }
785
920
  }
786
921
  if let Some(entry) = team_entry(state, team) {
@@ -866,6 +1001,46 @@ fn team_entry<'a>(state: &'a Value, team: &str) -> Option<&'a Value> {
866
1001
  .and_then(|teams| teams.get(team))
867
1002
  }
868
1003
 
1004
+ /// E6 (0.5.9 offline-mailbox-toname-design §3.1): decide which
1005
+ /// `leader_receiver` object, if any, applies to `<team>/leader` before any
1006
+ /// downstream code reads it. Wrong runtime keys are rejected here with
1007
+ /// `team_key_not_found` — the pre-fix behavior was to silently reuse the
1008
+ /// top-level `leader_receiver` (the `current` team's receiver in the gate
1009
+ /// evidence), producing misleading `expected_pane=<missing>` messages.
1010
+ ///
1011
+ /// Legacy single-team compat is preserved only when the state has no
1012
+ /// `teams` map at all AND the requested team equals `active_team_key`.
1013
+ /// That branch is a documented exception to the E6 fallback grep guard;
1014
+ /// the exception is tagged inline with `ALLOWED-LEGACY-SINGLE-TEAM` so
1015
+ /// the guard admits it explicitly (see the R2 rework in
1016
+ /// `.team/artifacts/059-impl-cr-verdict.md §4`). The exception is
1017
+ /// scheduled to be removed when B1 canonical state layout lands
1018
+ /// (`.team/artifacts/next-version-staged-plan.md §5 Phase-Foundation-1`),
1019
+ /// at which point every runtime state carries a `teams` map and the
1020
+ /// legacy branch becomes unreachable dead code.
1021
+ fn resolve_team_scoped_leader_receiver<'a>(
1022
+ state: &'a Value,
1023
+ team: &str,
1024
+ target_workspace: &Path,
1025
+ ) -> Result<Option<&'a Value>, NamedAddressError> {
1026
+ if let Some(entry) = team_entry(state, team) {
1027
+ return Ok(entry.get("leader_receiver"));
1028
+ }
1029
+ if is_legacy_single_team_active(state, team) {
1030
+ // ALLOWED-LEGACY-SINGLE-TEAM: pre-teams-map compat; state has no teams map AND request matches active_team_key.
1031
+ return Ok(state.get("leader_receiver"));
1032
+ }
1033
+ Err(team_key_not_found(target_workspace, state, team))
1034
+ }
1035
+
1036
+ fn is_legacy_single_team_active(state: &Value, team: &str) -> bool {
1037
+ state.get("teams").is_none()
1038
+ && state
1039
+ .get("active_team_key")
1040
+ .and_then(Value::as_str)
1041
+ .is_some_and(|k| k == team)
1042
+ }
1043
+
869
1044
  fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
870
1045
  value
871
1046
  .get(key)
@@ -944,6 +1119,91 @@ fn name_not_live_worker(
944
1119
  err
945
1120
  }
946
1121
 
1122
+ /// E6 (0.5.9 offline-mailbox §3.1): requested `<team>` is not a runtime team_key.
1123
+ /// The refusal echoes the requested string plus the canonical alternatives so
1124
+ /// the caller can retry with `::<canonical_key>/leader`. The action copy is
1125
+ /// deliberately neutral — no `claim-leader` / `takeover` — because a third-party
1126
+ /// sender must never be told to seize the target team's leader.
1127
+ fn team_key_not_found(target_workspace: &Path, state: &Value, team: &str) -> NamedAddressError {
1128
+ let available = available_team_keys(state);
1129
+ let workspace = target_workspace.display();
1130
+ let mut err = NamedAddressError::new(
1131
+ NamedAddressErrorKind::TeamKeyNotFound,
1132
+ format!(
1133
+ "team key `{team}` is not a runtime key in target workspace `{workspace}` (spec/display name may differ from runtime key)"
1134
+ ),
1135
+ );
1136
+ let suggested_key = available.first().cloned();
1137
+ err.action = match suggested_key.as_deref() {
1138
+ Some(k) => format!(
1139
+ "Retry with the canonical key, e.g. `{workspace}::{k}/leader`; check `team-agent status --workspace {workspace} --json` for team_key"
1140
+ ),
1141
+ None => format!(
1142
+ "No runtime team keys are present; check `team-agent status --workspace {workspace} --json`"
1143
+ ),
1144
+ };
1145
+ err.log = format!(
1146
+ "requested_team={team} available_team_keys={:?} workspace={workspace}",
1147
+ available
1148
+ );
1149
+ err.requested_team = Some(team.to_string());
1150
+ err.suggested_name = suggested_key
1151
+ .as_deref()
1152
+ .map(|k| format!("{workspace}::{k}/leader"));
1153
+ err.available_team_keys = available;
1154
+ err
1155
+ }
1156
+
1157
+ /// E6 (0.5.9 offline-mailbox §3.1): key hit but the leader receiver is either
1158
+ /// absent or not live. `leader_not_attached_third_party` is the neutral variant
1159
+ /// used by the resolver: it never suggests `claim-leader` / `takeover` so the
1160
+ /// caller (which may be a third-party sender) never sees ownership-seizing copy.
1161
+ /// The owner-facing action (attach-leader) belongs to `status_port`
1162
+ /// pending_leader_notifications and diagnose.
1163
+ fn leader_not_attached_third_party(
1164
+ target_workspace: &Path,
1165
+ team: &str,
1166
+ pane_id: Option<&str>,
1167
+ session: Option<&str>,
1168
+ window: Option<&str>,
1169
+ socket: Option<String>,
1170
+ ) -> NamedAddressError {
1171
+ let workspace = target_workspace.display();
1172
+ let mut err = NamedAddressError::new(
1173
+ NamedAddressErrorKind::LeaderNotAttached,
1174
+ "target team leader is not attached on its recorded tmux endpoint",
1175
+ );
1176
+ err.action = format!(
1177
+ "Ask the target team owner to run `team-agent attach-leader --workspace {workspace} --team {team}`; the sender-side offline mailbox will requeue the message once attach succeeds"
1178
+ );
1179
+ err.log = format!(
1180
+ "name={team}/leader expected_pane={} expected_session={} expected_window={} tmux_socket={}",
1181
+ pane_id.unwrap_or("<missing>"),
1182
+ session.unwrap_or("<unknown>"),
1183
+ window.unwrap_or("<unknown>"),
1184
+ socket.unwrap_or_else(|| "<unknown>".to_string())
1185
+ );
1186
+ err.requested_team = Some(team.to_string());
1187
+ err
1188
+ }
1189
+
1190
+ pub(crate) fn available_team_keys(state: &Value) -> Vec<String> {
1191
+ let mut keys: Vec<String> = state
1192
+ .get("teams")
1193
+ .and_then(Value::as_object)
1194
+ .map(|teams| teams.keys().cloned().collect())
1195
+ .unwrap_or_default();
1196
+ if keys.is_empty() {
1197
+ if let Some(active) = state.get("active_team_key").and_then(Value::as_str) {
1198
+ if !active.is_empty() {
1199
+ keys.push(active.to_string());
1200
+ }
1201
+ }
1202
+ }
1203
+ keys.sort();
1204
+ keys
1205
+ }
1206
+
947
1207
  fn name_not_live_leader(
948
1208
  team: &str,
949
1209
  pane_id: Option<&str>,
@@ -7,6 +7,34 @@ 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
+ if let Some(ref to_leader) = args.to_leader {
11
+ // E7 (0.5.9 host-leader-registry-design §4.2): `--to-leader NAME`
12
+ // resolves NAME through `~/.team-agent/leaders`, canonical-validates
13
+ // the entry, and delegates to the same E6 leader delivery path
14
+ // (`send_to_canonical_leader_target`) — so live inject and offline
15
+ // mailbox (`queued_until_leader_attach` / `leader_mailbox`) both
16
+ // funnel through one code path. Mutually exclusive with
17
+ // `--to-name`, TARGET/--to, `--pane`.
18
+ if args.to_name.is_some()
19
+ || args.pane.is_some()
20
+ || args.target.is_some()
21
+ || args.targets.is_some()
22
+ {
23
+ return Err(CliError::Usage(
24
+ "--to-leader and --to-name/--pane/TARGET/--to are mutually exclusive: \
25
+ --to-leader resolves a host leader delivery name via the leader registry"
26
+ .to_string(),
27
+ ));
28
+ }
29
+ let content = args.message.join(" ");
30
+ if content.is_empty() {
31
+ return Err(CliError::Usage(
32
+ "--to-leader requires a non-empty message".to_string(),
33
+ ));
34
+ }
35
+ let value = send_to_canonical_leader_target(&args.workspace, to_leader, &content, &args.sender, args.task.as_deref())?;
36
+ return Ok(cmd_send_result(value, args.json));
37
+ }
10
38
  if let Some(ref to_name) = args.to_name {
11
39
  if args.pane.is_some() || args.target.is_some() || args.targets.is_some() {
12
40
  return Err(CliError::Usage(
@@ -913,6 +941,208 @@ fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
913
941
  }
914
942
  }
915
943
 
944
+ /// E7 (0.5.9 host-leader-registry-design §8.3): resolve `NAME` through
945
+ /// `~/.team-agent/leaders`, then delegate to the E6 leader delivery path
946
+ /// so a resolved live target physically injects and a leader-not-attached
947
+ /// target queues via `enqueue_leader_mailbox_until_attach`. Ambiguous
948
+ /// short names refuse with `name_ambiguous` and expose `candidates` —
949
+ /// no priority heuristic ever picks a winner (host-leader-registry-design §5.2).
950
+ ///
951
+ /// Return shape reserves the following markers for downstream consumers:
952
+ /// - `resolved_via = "host_leader_registry"` when a registry entry
953
+ /// selected the canonical target (E7 test 2).
954
+ /// - `reason = "leader_name_not_found"` for missing entries; `reason =
955
+ /// "registry_stale"` when canonical validation refuses; `reason =
956
+ /// "name_ambiguous"` for collisions with a candidate list including
957
+ /// `workspace_hash` and `stable_qualified_name`.
958
+ ///
959
+ /// The first slice ships the marker/return-shape surface so E6 wiring is
960
+ /// available at the CLI; the full canonical-validate loop follows in a
961
+ /// later commit alongside the registry read implementation.
962
+ pub fn send_to_canonical_leader_target(
963
+ sender_workspace: &std::path::Path,
964
+ name: &str,
965
+ content: &str,
966
+ sender: &str,
967
+ task_id: Option<&str>,
968
+ ) -> Result<serde_json::Value, CliError> {
969
+ // Resolve NAME through the registry. Ambiguity is decided *before*
970
+ // canonical validation so an ambiguous short name never picks a
971
+ // winner — even when only one candidate happens to be live. Send
972
+ // uses the no-GC listing so stale entries can still refuse with
973
+ // `registry_stale` — the leaders CLI is the one that prunes.
974
+ let classified = crate::leader::registry::list_validated_no_gc();
975
+ let mut candidates_all: Vec<crate::leader::registry::LeaderRegistryEntry> = Vec::new();
976
+ for (entry, _status, _reason) in &classified {
977
+ let matches = entry.delivery_name == name
978
+ || entry.qualified_name == name
979
+ || entry.stable_qualified_name == name
980
+ || entry.aliases.iter().any(|a| a == name);
981
+ if matches {
982
+ candidates_all.push(entry.clone());
983
+ }
984
+ }
985
+ if candidates_all.is_empty() {
986
+ return Ok(serde_json::json!({
987
+ "ok": false,
988
+ "status": "refused",
989
+ "reason": "leader_name_not_found",
990
+ "requested_name": name,
991
+ "resolved_via": "host_leader_registry",
992
+ "candidates": Vec::<serde_json::Value>::new(),
993
+ "workspace_hash": null,
994
+ "stable_qualified_name": null,
995
+ "channel": "leader_mailbox",
996
+ "delivered": false,
997
+ "message_status": "queued_until_leader_attach",
998
+ "action": "run `team-agent leaders` to see registered leaders; retry with a qualified name",
999
+ "registry_stale": false,
1000
+ }));
1001
+ }
1002
+ if candidates_all.len() > 1 {
1003
+ let cand_json: Vec<serde_json::Value> = candidates_all
1004
+ .iter()
1005
+ .map(|e| {
1006
+ serde_json::json!({
1007
+ "name": e.qualified_name,
1008
+ "workspace": e.workspace.display().to_string(),
1009
+ "team_key": e.team_key,
1010
+ "workspace_hash": e.workspace_hash,
1011
+ "stable_qualified_name": e.stable_qualified_name,
1012
+ })
1013
+ })
1014
+ .collect();
1015
+ return Ok(serde_json::json!({
1016
+ "ok": false,
1017
+ "status": "refused",
1018
+ "reason": "name_ambiguous",
1019
+ "requested_name": name,
1020
+ "resolved_via": "host_leader_registry",
1021
+ "candidates": cand_json,
1022
+ "channel": "leader_mailbox",
1023
+ "delivered": false,
1024
+ "action": "run `team-agent leaders` and retry with the qualified name",
1025
+ }));
1026
+ }
1027
+ let entry = candidates_all.into_iter().next().ok_or_else(|| {
1028
+ CliError::Runtime("internal: candidate list must have at least one entry".to_string())
1029
+ })?;
1030
+ // Canonical-validate the entry against target workspace state. Send
1031
+ // through the same E6 --to-name path so live inject and mailbox both
1032
+ // funnel through one code path.
1033
+ let (status, reason) = crate::leader::registry::classify(&entry);
1034
+ if status == "STALE" {
1035
+ // Check whether the underlying team is still alive — if so we
1036
+ // may still queue via the E6 mailbox path (leader-not-attached
1037
+ // shape). If the workspace/team is gone we refuse `registry_stale`.
1038
+ let state = crate::state::persist::load_runtime_state(&entry.workspace).ok();
1039
+ let team_alive = state
1040
+ .as_ref()
1041
+ .and_then(|s| s.get("teams"))
1042
+ .and_then(|v| v.as_object())
1043
+ .and_then(|teams| teams.get(&entry.team_key))
1044
+ .and_then(|t| t.get("status"))
1045
+ .and_then(serde_json::Value::as_str)
1046
+ .map(|s| s == "alive" || s.is_empty())
1047
+ .unwrap_or(false);
1048
+ if !team_alive {
1049
+ return Ok(serde_json::json!({
1050
+ "ok": false,
1051
+ "status": "refused",
1052
+ "reason": "registry_stale",
1053
+ "requested_name": name,
1054
+ "resolved_via": "host_leader_registry",
1055
+ "stale_reason": reason,
1056
+ "workspace_hash": entry.workspace_hash,
1057
+ "stable_qualified_name": entry.stable_qualified_name,
1058
+ "channel": "leader_mailbox",
1059
+ "delivered": false,
1060
+ "action": "target team is not alive; run `team-agent leaders` for current state",
1061
+ }));
1062
+ }
1063
+ // Team alive but leader unattached → E6 mailbox.
1064
+ let event_log = crate::event_log::EventLog::new(&entry.workspace);
1065
+ let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
1066
+ let outcome = crate::messaging::enqueue_leader_mailbox_until_attach(
1067
+ &entry.workspace,
1068
+ &entry.team_key,
1069
+ content,
1070
+ task.as_ref(),
1071
+ sender,
1072
+ &event_log,
1073
+ )
1074
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
1075
+ return Ok(serde_json::json!({
1076
+ "ok": true,
1077
+ "status": "queued_until_leader_attach",
1078
+ "message_status": "queued_until_leader_attach",
1079
+ "channel": "leader_mailbox",
1080
+ "delivered": false,
1081
+ "resolved_via": "host_leader_registry",
1082
+ "requested_name": name,
1083
+ "to_leader": entry.qualified_name,
1084
+ "target_workspace": entry.workspace.display().to_string(),
1085
+ "workspace_hash": entry.workspace_hash,
1086
+ "stable_qualified_name": entry.stable_qualified_name,
1087
+ "team_key": entry.team_key,
1088
+ "message_id": outcome.message_id,
1089
+ }));
1090
+ }
1091
+ // LIVE: canonical-validated. Delegate to the E6 --to-name path via a
1092
+ // synthesized `<workspace>::<team_key>/leader` name so live inject +
1093
+ // mailbox both go through one code path.
1094
+ let to_name = format!(
1095
+ "{}::{}/leader",
1096
+ entry.workspace.display(),
1097
+ entry.team_key
1098
+ );
1099
+ let (resolved, transport) =
1100
+ match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name) {
1101
+ Ok(r) => r,
1102
+ Err(err) => {
1103
+ // Named-address refusal — surface it verbatim but tag as
1104
+ // registry-resolved so callers can trace the origin.
1105
+ let mut body = err.to_json();
1106
+ if let Some(obj) = body.as_object_mut() {
1107
+ obj.insert(
1108
+ "resolved_via".to_string(),
1109
+ serde_json::Value::String("host_leader_registry".to_string()),
1110
+ );
1111
+ obj.insert(
1112
+ "delivered".to_string(),
1113
+ serde_json::Value::Bool(false),
1114
+ );
1115
+ }
1116
+ return Ok(body);
1117
+ }
1118
+ };
1119
+ let mut value = send_to_named_pane_direct(
1120
+ sender_workspace,
1121
+ transport.as_ref(),
1122
+ &resolved,
1123
+ content,
1124
+ sender,
1125
+ task_id,
1126
+ true,
1127
+ )?;
1128
+ if let Some(obj) = value.as_object_mut() {
1129
+ obj.insert(
1130
+ "resolved_via".to_string(),
1131
+ serde_json::Value::String("host_leader_registry".to_string()),
1132
+ );
1133
+ obj.insert(
1134
+ "to_leader".to_string(),
1135
+ serde_json::Value::String(entry.qualified_name.clone()),
1136
+ );
1137
+ // Honest delivered marker. `send_to_named_pane_direct` sets `ok`
1138
+ // to whether physical inject verified — mirror that as
1139
+ // `delivered`.
1140
+ let ok = obj.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false);
1141
+ obj.insert("delivered".to_string(), serde_json::Value::Bool(ok));
1142
+ }
1143
+ Ok(value)
1144
+ }
1145
+
916
1146
  #[cfg(test)]
917
1147
  mod e23_tests {
918
1148
  use super::*;