@team-agent/installer 0.5.8 → 0.5.10

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.
@@ -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(
@@ -24,10 +52,34 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
24
52
  let (resolved, transport) =
25
53
  match crate::cli::named_address::resolve_name_for_cli(&args.workspace, to_name) {
26
54
  Ok(resolved) => resolved,
27
- Err(error) if args.json => {
28
- return Ok(CmdResult::from_json(error.to_json(), args.json));
55
+ Err(error) => {
56
+ // E6 (0.5.9 offline-mailbox-toname-design §3.1/§6.2): when
57
+ // the resolver refuses with `leader_not_attached`, the team
58
+ // itself may still be alive (worker + coordinator running
59
+ // without a bound leader). Third-party senders in that
60
+ // shape must land in the offline mailbox — same
61
+ // canonical team.db row + queued_until_leader_attach
62
+ // status the coordinator/attach hook replay through the
63
+ // existing pipeline exactly once. Owner-scope refusals
64
+ // (same workspace as target) keep the actionable attach
65
+ // hint — E6 owner copy is documented as
66
+ // `run team-agent attach-leader`.
67
+ if let Some(mut value) = maybe_enqueue_offline_leader_mailbox(
68
+ &args.workspace,
69
+ to_name,
70
+ &content,
71
+ &args.sender,
72
+ args.task.as_deref(),
73
+ &error,
74
+ )? {
75
+ add_send_reminder_if_ok(&mut value);
76
+ return Ok(cmd_send_result(value, args.json));
77
+ }
78
+ if args.json {
79
+ return Ok(CmdResult::from_json(error.to_json(), args.json));
80
+ }
81
+ return Err(CliError::Usage(error.n38_message()));
29
82
  }
30
- Err(error) => return Err(CliError::Usage(error.n38_message())),
31
83
  };
32
84
  let mut value = send_to_named_pane_direct(
33
85
  &args.workspace,
@@ -758,6 +810,119 @@ fn add_send_reminder_if_ok(value: &mut Value) {
758
810
  }
759
811
  }
760
812
 
813
+ /// E6 (0.5.9 offline-mailbox-toname-design §§3.1/6.2/8, real-machine
814
+ /// escape evidence
815
+ /// `.team/artifacts/0.5.9-subscription-gate.md` +
816
+ /// `.team/evidence/0.5.9-subscription-gate-20260707T143241Z-4645/`):
817
+ /// when the `--to-name <ws>::<team>/leader` resolver refused with
818
+ /// `leader_not_attached`, decide whether the target team is still alive
819
+ /// (worker + coordinator running without a bound leader) and, if so,
820
+ /// enqueue the mailbox row so `attach-leader` replays it exactly once.
821
+ ///
822
+ /// Only queues for third-party senders (sender workspace ≠ target
823
+ /// workspace). Owner-scope refusals stay refused so status/diagnose can
824
+ /// keep pushing the operator toward `attach-leader`.
825
+ fn maybe_enqueue_offline_leader_mailbox(
826
+ sender_workspace: &Path,
827
+ to_name: &str,
828
+ content: &str,
829
+ sender: &str,
830
+ task_id: Option<&str>,
831
+ error: &crate::cli::named_address::NamedAddressError,
832
+ ) -> Result<Option<Value>, CliError> {
833
+ if error.kind != crate::cli::named_address::NamedAddressErrorKind::LeaderNotAttached {
834
+ return Ok(None);
835
+ }
836
+ let parsed = match crate::cli::named_address::parse_leader_target_workspace_and_team(
837
+ sender_workspace,
838
+ to_name,
839
+ ) {
840
+ Ok(Some(v)) => v,
841
+ Ok(None) => return Ok(None),
842
+ Err(_) => return Ok(None),
843
+ };
844
+ let (target_workspace, team_key) = parsed;
845
+ // Owner-scope refusal: sender workspace == target workspace. Keep
846
+ // the actionable attach hint (owner sees status/diagnose copy that
847
+ // points at `attach-leader`).
848
+ let sender_canonical = std::fs::canonicalize(sender_workspace)
849
+ .unwrap_or_else(|_| sender_workspace.to_path_buf());
850
+ let target_canonical = std::fs::canonicalize(&target_workspace)
851
+ .unwrap_or_else(|_| target_workspace.clone());
852
+ if sender_canonical == target_canonical {
853
+ return Ok(None);
854
+ }
855
+ // Verify the target team is actually alive on this host — mailbox
856
+ // is only for `team live + leader unattached`. Fail-closed otherwise
857
+ // so we never leave a message in a permanently-dead workspace's DB.
858
+ let state = match crate::state::persist::load_runtime_state(&target_workspace) {
859
+ Ok(s) => s,
860
+ Err(_) => return Ok(None),
861
+ };
862
+ let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
863
+ if !team_alive {
864
+ return Ok(None);
865
+ }
866
+ let event_log = crate::event_log::EventLog::new(&target_workspace);
867
+ let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
868
+ let outcome = messaging::enqueue_leader_mailbox_until_attach(
869
+ &target_workspace,
870
+ &team_key,
871
+ content,
872
+ task.as_ref(),
873
+ sender,
874
+ &event_log,
875
+ )
876
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
877
+ let message_id = outcome
878
+ .message_id
879
+ .clone()
880
+ .unwrap_or_else(|| "".to_string());
881
+ Ok(Some(json!({
882
+ "ok": true,
883
+ "status": "queued_until_leader_attach",
884
+ "message_status": "queued_until_leader_attach",
885
+ "channel": "leader_mailbox",
886
+ "delivered": false,
887
+ "to_name": to_name,
888
+ "target_workspace": target_workspace.display().to_string(),
889
+ "team_key": team_key,
890
+ "recipient": "leader",
891
+ "leader_attached": false,
892
+ "message_id": message_id,
893
+ })))
894
+ }
895
+
896
+ /// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
897
+ /// - target workspace has state and the team key is present + not archived/down;
898
+ /// - AND at least one live tmux fact — a persisted `session_name` OR any
899
+ /// agent with a recorded pane on the recorded socket.
900
+ ///
901
+ /// We deliberately do NOT poll coordinator health here — enqueuing is
902
+ /// safe even when the coordinator is transiently down; attach-leader
903
+ /// itself replays via `requeue_blocked_leader_messages` regardless.
904
+ fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
905
+ let team = state
906
+ .get("teams")
907
+ .and_then(|v| v.as_object())
908
+ .and_then(|teams| teams.get(team_key));
909
+ let Some(team) = team else {
910
+ return false;
911
+ };
912
+ let status = team
913
+ .get("status")
914
+ .and_then(|v| v.as_str())
915
+ .unwrap_or("alive");
916
+ if matches!(status, "archived" | "down" | "stopped") {
917
+ return false;
918
+ }
919
+ // A recorded session_name is enough — target's coordinator/attach
920
+ // path will re-verify tmux presence when the replay fires.
921
+ team.get("session_name")
922
+ .and_then(|v| v.as_str())
923
+ .is_some_and(|s| !s.is_empty())
924
+ }
925
+
761
926
  fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
762
927
  let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
763
928
  ExitCode::Error
@@ -913,6 +1078,208 @@ fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
913
1078
  }
914
1079
  }
915
1080
 
1081
+ /// E7 (0.5.9 host-leader-registry-design §8.3): resolve `NAME` through
1082
+ /// `~/.team-agent/leaders`, then delegate to the E6 leader delivery path
1083
+ /// so a resolved live target physically injects and a leader-not-attached
1084
+ /// target queues via `enqueue_leader_mailbox_until_attach`. Ambiguous
1085
+ /// short names refuse with `name_ambiguous` and expose `candidates` —
1086
+ /// no priority heuristic ever picks a winner (host-leader-registry-design §5.2).
1087
+ ///
1088
+ /// Return shape reserves the following markers for downstream consumers:
1089
+ /// - `resolved_via = "host_leader_registry"` when a registry entry
1090
+ /// selected the canonical target (E7 test 2).
1091
+ /// - `reason = "leader_name_not_found"` for missing entries; `reason =
1092
+ /// "registry_stale"` when canonical validation refuses; `reason =
1093
+ /// "name_ambiguous"` for collisions with a candidate list including
1094
+ /// `workspace_hash` and `stable_qualified_name`.
1095
+ ///
1096
+ /// The first slice ships the marker/return-shape surface so E6 wiring is
1097
+ /// available at the CLI; the full canonical-validate loop follows in a
1098
+ /// later commit alongside the registry read implementation.
1099
+ pub fn send_to_canonical_leader_target(
1100
+ sender_workspace: &std::path::Path,
1101
+ name: &str,
1102
+ content: &str,
1103
+ sender: &str,
1104
+ task_id: Option<&str>,
1105
+ ) -> Result<serde_json::Value, CliError> {
1106
+ // Resolve NAME through the registry. Ambiguity is decided *before*
1107
+ // canonical validation so an ambiguous short name never picks a
1108
+ // winner — even when only one candidate happens to be live. Send
1109
+ // uses the no-GC listing so stale entries can still refuse with
1110
+ // `registry_stale` — the leaders CLI is the one that prunes.
1111
+ let classified = crate::leader::registry::list_validated_no_gc();
1112
+ let mut candidates_all: Vec<crate::leader::registry::LeaderRegistryEntry> = Vec::new();
1113
+ for (entry, _status, _reason) in &classified {
1114
+ let matches = entry.delivery_name == name
1115
+ || entry.qualified_name == name
1116
+ || entry.stable_qualified_name == name
1117
+ || entry.aliases.iter().any(|a| a == name);
1118
+ if matches {
1119
+ candidates_all.push(entry.clone());
1120
+ }
1121
+ }
1122
+ if candidates_all.is_empty() {
1123
+ return Ok(serde_json::json!({
1124
+ "ok": false,
1125
+ "status": "refused",
1126
+ "reason": "leader_name_not_found",
1127
+ "requested_name": name,
1128
+ "resolved_via": "host_leader_registry",
1129
+ "candidates": Vec::<serde_json::Value>::new(),
1130
+ "workspace_hash": null,
1131
+ "stable_qualified_name": null,
1132
+ "channel": "leader_mailbox",
1133
+ "delivered": false,
1134
+ "message_status": "queued_until_leader_attach",
1135
+ "action": "run `team-agent leaders` to see registered leaders; retry with a qualified name",
1136
+ "registry_stale": false,
1137
+ }));
1138
+ }
1139
+ if candidates_all.len() > 1 {
1140
+ let cand_json: Vec<serde_json::Value> = candidates_all
1141
+ .iter()
1142
+ .map(|e| {
1143
+ serde_json::json!({
1144
+ "name": e.qualified_name,
1145
+ "workspace": e.workspace.display().to_string(),
1146
+ "team_key": e.team_key,
1147
+ "workspace_hash": e.workspace_hash,
1148
+ "stable_qualified_name": e.stable_qualified_name,
1149
+ })
1150
+ })
1151
+ .collect();
1152
+ return Ok(serde_json::json!({
1153
+ "ok": false,
1154
+ "status": "refused",
1155
+ "reason": "name_ambiguous",
1156
+ "requested_name": name,
1157
+ "resolved_via": "host_leader_registry",
1158
+ "candidates": cand_json,
1159
+ "channel": "leader_mailbox",
1160
+ "delivered": false,
1161
+ "action": "run `team-agent leaders` and retry with the qualified name",
1162
+ }));
1163
+ }
1164
+ let entry = candidates_all.into_iter().next().ok_or_else(|| {
1165
+ CliError::Runtime("internal: candidate list must have at least one entry".to_string())
1166
+ })?;
1167
+ // Canonical-validate the entry against target workspace state. Send
1168
+ // through the same E6 --to-name path so live inject and mailbox both
1169
+ // funnel through one code path.
1170
+ let (status, reason) = crate::leader::registry::classify(&entry);
1171
+ if status == "STALE" {
1172
+ // Check whether the underlying team is still alive — if so we
1173
+ // may still queue via the E6 mailbox path (leader-not-attached
1174
+ // shape). If the workspace/team is gone we refuse `registry_stale`.
1175
+ let state = crate::state::persist::load_runtime_state(&entry.workspace).ok();
1176
+ let team_alive = state
1177
+ .as_ref()
1178
+ .and_then(|s| s.get("teams"))
1179
+ .and_then(|v| v.as_object())
1180
+ .and_then(|teams| teams.get(&entry.team_key))
1181
+ .and_then(|t| t.get("status"))
1182
+ .and_then(serde_json::Value::as_str)
1183
+ .map(|s| s == "alive" || s.is_empty())
1184
+ .unwrap_or(false);
1185
+ if !team_alive {
1186
+ return Ok(serde_json::json!({
1187
+ "ok": false,
1188
+ "status": "refused",
1189
+ "reason": "registry_stale",
1190
+ "requested_name": name,
1191
+ "resolved_via": "host_leader_registry",
1192
+ "stale_reason": reason,
1193
+ "workspace_hash": entry.workspace_hash,
1194
+ "stable_qualified_name": entry.stable_qualified_name,
1195
+ "channel": "leader_mailbox",
1196
+ "delivered": false,
1197
+ "action": "target team is not alive; run `team-agent leaders` for current state",
1198
+ }));
1199
+ }
1200
+ // Team alive but leader unattached → E6 mailbox.
1201
+ let event_log = crate::event_log::EventLog::new(&entry.workspace);
1202
+ let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
1203
+ let outcome = crate::messaging::enqueue_leader_mailbox_until_attach(
1204
+ &entry.workspace,
1205
+ &entry.team_key,
1206
+ content,
1207
+ task.as_ref(),
1208
+ sender,
1209
+ &event_log,
1210
+ )
1211
+ .map_err(|e| CliError::Runtime(e.to_string()))?;
1212
+ return Ok(serde_json::json!({
1213
+ "ok": true,
1214
+ "status": "queued_until_leader_attach",
1215
+ "message_status": "queued_until_leader_attach",
1216
+ "channel": "leader_mailbox",
1217
+ "delivered": false,
1218
+ "resolved_via": "host_leader_registry",
1219
+ "requested_name": name,
1220
+ "to_leader": entry.qualified_name,
1221
+ "target_workspace": entry.workspace.display().to_string(),
1222
+ "workspace_hash": entry.workspace_hash,
1223
+ "stable_qualified_name": entry.stable_qualified_name,
1224
+ "team_key": entry.team_key,
1225
+ "message_id": outcome.message_id,
1226
+ }));
1227
+ }
1228
+ // LIVE: canonical-validated. Delegate to the E6 --to-name path via a
1229
+ // synthesized `<workspace>::<team_key>/leader` name so live inject +
1230
+ // mailbox both go through one code path.
1231
+ let to_name = format!(
1232
+ "{}::{}/leader",
1233
+ entry.workspace.display(),
1234
+ entry.team_key
1235
+ );
1236
+ let (resolved, transport) =
1237
+ match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name) {
1238
+ Ok(r) => r,
1239
+ Err(err) => {
1240
+ // Named-address refusal — surface it verbatim but tag as
1241
+ // registry-resolved so callers can trace the origin.
1242
+ let mut body = err.to_json();
1243
+ if let Some(obj) = body.as_object_mut() {
1244
+ obj.insert(
1245
+ "resolved_via".to_string(),
1246
+ serde_json::Value::String("host_leader_registry".to_string()),
1247
+ );
1248
+ obj.insert(
1249
+ "delivered".to_string(),
1250
+ serde_json::Value::Bool(false),
1251
+ );
1252
+ }
1253
+ return Ok(body);
1254
+ }
1255
+ };
1256
+ let mut value = send_to_named_pane_direct(
1257
+ sender_workspace,
1258
+ transport.as_ref(),
1259
+ &resolved,
1260
+ content,
1261
+ sender,
1262
+ task_id,
1263
+ true,
1264
+ )?;
1265
+ if let Some(obj) = value.as_object_mut() {
1266
+ obj.insert(
1267
+ "resolved_via".to_string(),
1268
+ serde_json::Value::String("host_leader_registry".to_string()),
1269
+ );
1270
+ obj.insert(
1271
+ "to_leader".to_string(),
1272
+ serde_json::Value::String(entry.qualified_name.clone()),
1273
+ );
1274
+ // Honest delivered marker. `send_to_named_pane_direct` sets `ok`
1275
+ // to whether physical inject verified — mirror that as
1276
+ // `delivered`.
1277
+ let ok = obj.get("ok").and_then(serde_json::Value::as_bool).unwrap_or(false);
1278
+ obj.insert("delivered".to_string(), serde_json::Value::Bool(ok));
1279
+ }
1280
+ Ok(value)
1281
+ }
1282
+
916
1283
  #[cfg(test)]
917
1284
  mod e23_tests {
918
1285
  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
 
@@ -159,6 +159,7 @@ fn named_send_args(
159
159
  message_id: None,
160
160
  pane: pane.map(str::to_string),
161
161
  to_name: to_name.map(str::to_string),
162
+ to_leader: None,
162
163
  }
163
164
  }
164
165
 
@@ -328,11 +329,16 @@ fn resolve_leader_name_not_live() {
328
329
  .with_targets(vec![pane("other", "codex", "%other")]);
329
330
 
330
331
  let err = resolve_name_with_transport(&ws, "alpha/leader", &transport).unwrap_err();
331
- assert_eq!(err.kind, NamedAddressErrorKind::NameNotLive);
332
+ // 0.5.9 E6 taxonomy split: `--to-name <team>/leader` when the team
333
+ // exists but the leader receiver's pane is missing is now
334
+ // `leader_not_attached` (not the coarser `name_not_live`), so a
335
+ // third-party sender can be told what to do without being pushed
336
+ // toward `claim-leader`/`takeover` (owner-only actions).
337
+ assert_eq!(err.kind, NamedAddressErrorKind::LeaderNotAttached);
332
338
  let n38 = err.n38_message();
333
- assert!(n38.contains("claim-leader"), "{n38}");
334
339
  assert!(n38.contains("attach-leader"), "{n38}");
335
- assert!(n38.contains("takeover"), "{n38}");
340
+ assert!(!n38.contains("claim-leader"), "third-party copy must not suggest claim-leader: {n38}");
341
+ assert!(!n38.contains("takeover"), "third-party copy must not suggest takeover: {n38}");
336
342
  let _ = std::fs::remove_dir_all(&ws);
337
343
  }
338
344
 
@@ -448,6 +448,7 @@ use super::*;
448
448
  message_id: None,
449
449
  pane: None,
450
450
  to_name: None,
451
+ to_leader: None,
451
452
  }
452
453
  }
453
454
 
@@ -334,6 +334,11 @@ pub struct SendArgs {
334
334
  /// `target` / `targets` / `--pane`; resolves workspace/team/agent or leader
335
335
  /// name to the current live pane before using direct pane injection.
336
336
  pub to_name: Option<String>,
337
+ /// E7 (0.5.9 host-leader-registry-design): `--to-leader NAME` resolves
338
+ /// NAME through `~/.team-agent/leaders`, canonical-validates, and then
339
+ /// delegates to the E6 leader delivery path (live inject or offline
340
+ /// mailbox with `queued_until_leader_attach`).
341
+ pub to_leader: Option<String>,
337
342
  }
338
343
 
339
344
  /// E23 worker-side emergency fallback for `team_orchestrator.send_message`
@@ -617,6 +622,15 @@ pub struct SessionsArgs {
617
622
  pub team: Option<String>,
618
623
  }
619
624
 
625
+ /// E7 (0.5.9 host-leader-registry-design §4.1): `team-agent leaders`
626
+ /// enumerates the host discovery index and classifies each entry as
627
+ /// LIVE, STALE, or AMBIGUOUS after re-validating against canonical
628
+ /// workspace state.
629
+ #[derive(Debug, Clone, PartialEq, Eq)]
630
+ pub struct LeadersArgs {
631
+ pub json: bool,
632
+ }
633
+
620
634
  /// `validate [spec=team.spec.yaml] --json`(`parser.py:120`)。
621
635
  #[derive(Debug, Clone, PartialEq, Eq)]
622
636
  pub struct ValidateArgs {
@@ -50,7 +50,7 @@ pub fn attach_leader(
50
50
  if let Some(endpoint) = target.endpoint.as_ref() {
51
51
  receiver.tmux_socket = Some(endpoint.clone());
52
52
  }
53
- let validation = validate_attach_target(workspace, &state, &target.info);
53
+ let validation = validate_attach_target(workspace, &state, &target.info, provider);
54
54
  if validation.is_err() {
55
55
  let pane_info = pane_info_value(&target.info);
56
56
  let targets_value = Value::Array(targets.iter().map(|target| pane_info_value(&target.info)).collect());
@@ -66,6 +66,7 @@ pub fn attach_leader(
66
66
  LeaseSource::Manual,
67
67
  &event_log,
68
68
  )? {
69
+ if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
69
70
  let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
70
71
  return Ok(LeaseResult {
71
72
  ok: true,
@@ -98,6 +99,7 @@ pub fn attach_leader(
98
99
  super::LeaderEvent::ReceiverAttached.name(),
99
100
  json!({"pane_id": pane_id.as_str(), "owner_epoch": epoch.0}),
100
101
  )?;
102
+ if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
101
103
  let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
102
104
  return Ok(LeaseResult {
103
105
  ok: true,
@@ -121,6 +123,7 @@ pub fn attach_leader(
121
123
  super::LeaderEvent::ReceiverAttached.name(),
122
124
  json!({"pane_id": pane_id.as_str(), "owner_epoch": next_epoch.0}),
123
125
  )?;
126
+ if let Some(endpoint) = target.endpoint.as_deref() { quiet_fake_leader_pane_echo(provider, &target.info, endpoint); }
124
127
  let _ = requeue_exhausted_watchers_after_attach(workspace, &state, &event_log, &pane_id)?;
125
128
  Ok(LeaseResult {
126
129
  ok: true,
@@ -134,6 +137,63 @@ pub fn attach_leader(
134
137
  })
135
138
  }
136
139
 
140
+ /// 0.5.9 (E6 real-machine e2e wiring): Fake-provider leader panes in the
141
+ /// e2e harness run `/bin/cat`, which lets the TTY driver echo every
142
+ /// injected byte AND then prints the same bytes on stdout — so pane
143
+ /// capture ends up with two copies of every delivered token. Real Codex/
144
+ /// Claude/Copilot binaries drive the pane through their own TUI and never
145
+ /// echo raw input, so they don't need this. Attach-leader is the
146
+ /// canonical binding hook for this pane, so it's the right place to
147
+ /// disable the TTY echo bit once. Best-effort: any failure is silent
148
+ /// (Fake-provider binding stays useful even without echo suppression).
149
+ ///
150
+ /// N16/CP-1 (`.team/anchors/REQUIREMENTS.md`): all tmux invocations MUST
151
+ /// go through the `TmuxBackend` single entry point. The pane's tty query
152
+ /// is done via `TmuxBackend::for_tmux_endpoint(endpoint).query(...,
153
+ /// PaneField::PaneTty)` — socket-scoped by construction. The `stty` call
154
+ /// itself is not a tmux operation, so it uses `Command` directly.
155
+ fn quiet_fake_leader_pane_echo(provider: Provider, target: &PaneInfo, endpoint: &str) {
156
+ use crate::transport::{PaneField, Target as TransportTarget};
157
+ if !matches!(provider, Provider::Fake) {
158
+ return;
159
+ }
160
+ if endpoint.is_empty() {
161
+ return;
162
+ }
163
+ let backend = crate::tmux_backend::TmuxBackend::for_tmux_endpoint(endpoint);
164
+ let Ok(Some(tty)) = <crate::tmux_backend::TmuxBackend as crate::transport::Transport>::query(
165
+ &backend,
166
+ &TransportTarget::Pane(target.pane_id.clone()),
167
+ PaneField::PaneTty,
168
+ ) else {
169
+ return;
170
+ };
171
+ if tty.trim().is_empty() {
172
+ return;
173
+ }
174
+ // stty against the pane's tty flips the terminal driver's echo bit
175
+ // without asking the pane process (`/bin/cat`) to interpret any
176
+ // command. Best-effort: failure is silent.
177
+ //
178
+ // Cross-platform tty-file flag: macOS/BSD uses `-f <file>`; GNU
179
+ // coreutils on Linux uses `-F <file>`. CI runs on Linux and the
180
+ // previous BSD-only invocation silently no-op'd there — the token
181
+ // was double-injected because echo stayed on. Try `-F` first (Linux
182
+ // is the CI baseline), then fall back to `-f` on macOS. Both are
183
+ // safe to run: the wrong-platform variant just fails silently.
184
+ let tty = tty.trim();
185
+ let linux_ok = std::process::Command::new("stty")
186
+ .args(["-F", tty, "-echo"])
187
+ .output()
188
+ .map(|o| o.status.success())
189
+ .unwrap_or(false);
190
+ if !linux_ok {
191
+ let _ = std::process::Command::new("stty")
192
+ .args(["-f", tty, "-echo"])
193
+ .output();
194
+ }
195
+ }
196
+
137
197
  /// Explicit app-server leader binding. Validates the supplied socket/thread tuple
138
198
  /// before writing the typed physical-channel anchor through the lease primitive.
139
199
  pub fn attach_app_server_leader(
@@ -824,9 +884,26 @@ fn validate_attach_target(
824
884
  workspace: &Path,
825
885
  state: &Value,
826
886
  target: &PaneInfo,
887
+ requested_provider: Provider,
827
888
  ) -> Result<(), &'static str> {
828
- let Some(claim_target) = claim_target_from_pane_info(workspace, target) else {
829
- return Err("leader_pane_validation_failed");
889
+ // 0.5.9 (E6 real-machine e2e wiring): an explicit `--provider fake`
890
+ // is the operator's declaration that this pane is a fake-provider
891
+ // stub for tests / fixtures. The normal pane-command attribution
892
+ // path can't recognize `/bin/cat` (or any bare shell process) as a
893
+ // provider, so honoring the explicit request here is the only way
894
+ // real-machine E6 acceptance can spin up a leader without shipping
895
+ // a real Codex/Claude/Copilot binary. This is a targeted escape
896
+ // hatch — `Provider::Fake` is not selectable from the user-facing
897
+ // provider list, only wired in test/fixture flows.
898
+ let claim_target = match claim_target_from_pane_info(workspace, target) {
899
+ Some(target) => Some(target),
900
+ None if matches!(requested_provider, Provider::Fake) => None,
901
+ None => return Err("leader_pane_validation_failed"),
902
+ };
903
+ let Some(claim_target) = claim_target else {
904
+ // Fake provider: skip session-uuid check since attribution was
905
+ // bypassed. Nothing else to validate.
906
+ return Ok(());
830
907
  };
831
908
  let recorded_uuid = get_path_str(state, &["team_owner", "leader_session_uuid"])
832
909
  .or_else(|| get_path_str(state, &["leader_receiver", "leader_session_uuid"]));
@@ -81,6 +81,7 @@ pub mod lease;
81
81
  pub mod owner_bind;
82
82
  pub mod provider_attribution;
83
83
  pub mod rediscover;
84
+ pub mod registry;
84
85
  pub mod start;
85
86
  pub mod takeover;
86
87
  pub mod types;