@team-agent/installer 0.5.8 → 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.
@@ -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
 
@@ -555,12 +596,18 @@ fn leader_advisory_candidates(
555
596
  transport: &dyn Transport,
556
597
  source: &str,
557
598
  ) -> Vec<Value> {
558
- let socket = team_entry(state, team)
599
+ let team_scoped_socket = team_entry(state, team)
559
600
  .and_then(|entry| entry.get("leader_receiver"))
560
- .or_else(|| state.get("leader_receiver"))
561
- .and_then(|receiver| string_field(receiver, "tmux_socket"))
562
- .map(str::to_string)
563
- .or_else(|| transport.tmux_endpoint());
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
+ };
564
611
  let targets = match transport.list_targets() {
565
612
  Ok(targets) => targets,
566
613
  Err(_) => return Vec::new(),
@@ -588,17 +635,27 @@ fn resolve_leader(
588
635
  parsed: &ParsedNamedAddress,
589
636
  transport: &dyn Transport,
590
637
  ) -> Result<ResolvedNamedAddress, NamedAddressError> {
591
- let receiver = if let Some(team_entry) = team_entry(state, team) {
592
- team_entry.get("leader_receiver")
593
- } else {
594
- state.get("leader_receiver")
595
- }
596
- .ok_or_else(|| {
597
- let mut err = name_not_live_leader(team, None, None, None, transport.tmux_endpoint());
598
- err.candidates =
599
- leader_advisory_candidates(state, team, transport, "state_recorded_socket");
600
- err
601
- })?;
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
+ })?;
602
659
  if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
603
660
  let mut err = NamedAddressError::new(
604
661
  NamedAddressErrorKind::NameNotLive,
@@ -624,8 +681,14 @@ fn resolve_leader(
624
681
  let pane_id = string_field(receiver, "pane_id")
625
682
  .filter(|pane| !pane.is_empty())
626
683
  .ok_or_else(|| {
627
- let mut err =
628
- name_not_live_leader(team, None, None, None, transport.tmux_endpoint());
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
+ );
629
692
  err.candidates =
630
693
  leader_advisory_candidates(state, team, transport, "state_recorded_socket");
631
694
  err
@@ -660,8 +723,14 @@ fn resolve_leader(
660
723
  warning: None,
661
724
  }),
662
725
  0 => {
663
- let mut err =
664
- name_not_live_leader(team, Some(pane_id), session, window, socket);
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
+ );
665
734
  err.candidates =
666
735
  leader_advisory_candidates(state, team, transport, "state_recorded_socket");
667
736
  Err(err)
@@ -835,12 +904,18 @@ fn transport_for_cli_target(
835
904
  ) -> Box<dyn Transport> {
836
905
  if let ParsedTarget::TeamEntity { team, entity } = &parsed.target {
837
906
  if entity == "leader" {
838
- if let Some(socket) = team_entry(state, team)
907
+ let team_scoped_socket = team_entry(state, team)
839
908
  .and_then(|entry| entry.get("leader_receiver"))
840
- .or_else(|| state.get("leader_receiver"))
841
- .and_then(|receiver| string_field(receiver, "tmux_socket"))
842
- {
843
- 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));
844
919
  }
845
920
  }
846
921
  if let Some(entry) = team_entry(state, team) {
@@ -926,6 +1001,46 @@ fn team_entry<'a>(state: &'a Value, team: &str) -> Option<&'a Value> {
926
1001
  .and_then(|teams| teams.get(team))
927
1002
  }
928
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
+
929
1044
  fn string_field<'a>(value: &'a Value, key: &str) -> Option<&'a str> {
930
1045
  value
931
1046
  .get(key)
@@ -1004,6 +1119,91 @@ fn name_not_live_worker(
1004
1119
  err
1005
1120
  }
1006
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
+
1007
1207
  fn name_not_live_leader(
1008
1208
  team: &str,
1009
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::*;
@@ -710,14 +710,20 @@ use rusqlite::params;
710
710
  limit: usize,
711
711
  ) -> Result<Value, CliError> {
712
712
  let limit = i64::try_from(limit).unwrap_or(i64::MAX);
713
+ // E6 (0.5.9 offline-mailbox §6.6): also surface `queued_until_leader_attach`
714
+ // rows (third-party sends into the leader mailbox) so the target owner can
715
+ // see them alongside the rebind-required failures. Channel wire label
716
+ // distinguishes the two so the operator can tell the two shapes apart.
713
717
  let sql = match owner_team_id {
714
718
  Some(_) => {
715
719
  "select message_id, sender, status, error, created_at, delivery_attempts
716
720
  from messages
717
721
  where recipient = 'leader'
718
- and status = 'failed'
719
- and error = 'leader_not_attached'
720
722
  and owner_team_id = ?1
723
+ and (
724
+ (status = 'failed' and error = 'leader_not_attached')
725
+ or status = 'queued_until_leader_attach'
726
+ )
721
727
  order by created_at desc
722
728
  limit ?2"
723
729
  }
@@ -725,8 +731,10 @@ use rusqlite::params;
725
731
  "select message_id, sender, status, error, created_at, delivery_attempts
726
732
  from messages
727
733
  where recipient = 'leader'
728
- and status = 'failed'
729
- and error = 'leader_not_attached'
734
+ and (
735
+ (status = 'failed' and error = 'leader_not_attached')
736
+ or status = 'queued_until_leader_attach'
737
+ )
730
738
  order by created_at desc
731
739
  limit ?1"
732
740
  }
@@ -735,14 +743,20 @@ use rusqlite::params;
735
743
  .prepare(sql)
736
744
  .map_err(|e| CliError::Runtime(e.to_string()))?;
737
745
  let map_row = |row: &rusqlite::Row<'_>| {
746
+ let status: String = row.get(2)?;
747
+ let channel = if status == "queued_until_leader_attach" {
748
+ "leader_mailbox"
749
+ } else {
750
+ "rebind_required"
751
+ };
738
752
  Ok(json!({
739
753
  "message_id": row.get::<_, String>(0)?,
740
754
  "sender": row.get::<_, Option<String>>(1)?,
741
- "status": row.get::<_, String>(2)?,
755
+ "status": status,
742
756
  "error": row.get::<_, Option<String>>(3)?,
743
757
  "created_at": row.get::<_, Option<String>>(4)?,
744
758
  "delivery_attempts": row.get::<_, i64>(5)?,
745
- "channel": "rebind_required",
759
+ "channel": channel,
746
760
  "action": "run team-agent attach-leader or team-agent takeover",
747
761
  }))
748
762
  };
@@ -51,6 +51,7 @@ use super::*;
51
51
  message_id: None,
52
52
  pane: None,
53
53
  to_name: None,
54
+ to_leader: None,
54
55
  };
55
56
  let _ = cmd_send(&args);
56
57