@team-agent/installer 0.5.49 → 0.5.51

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 (90) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +6 -4
  4. package/crates/team-agent/src/cli/emit.rs +91 -39
  5. package/crates/team-agent/src/cli/mod.rs +33 -15
  6. package/crates/team-agent/src/cli/named_address.rs +82 -53
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +103 -1308
  13. package/crates/team-agent/src/cli/spec.rs +2 -2
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
  25. package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
  26. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
  27. package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
  28. package/crates/team-agent/src/cli/types.rs +5 -8
  29. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  30. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  31. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  32. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  33. package/crates/team-agent/src/db/message_store.rs +154 -44
  34. package/crates/team-agent/src/event_log.rs +73 -0
  35. package/crates/team-agent/src/leader/start.rs +28 -4
  36. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  37. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  38. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  39. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  41. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  42. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  43. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  44. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  45. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  46. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  48. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  49. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  50. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  51. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  52. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  53. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  54. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  55. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  56. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  57. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  58. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  59. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  60. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  61. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  62. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  63. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
  64. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  65. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  66. package/crates/team-agent/src/mcp_server/mod.rs +2 -2
  67. package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
  68. package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
  69. package/crates/team-agent/src/mcp_server/tools.rs +26 -15
  70. package/crates/team-agent/src/mcp_server/wire.rs +2 -18
  71. package/crates/team-agent/src/messaging/activity.rs +4 -2
  72. package/crates/team-agent/src/messaging/address.rs +86 -0
  73. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  74. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  75. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  76. package/crates/team-agent/src/messaging/mod.rs +11 -2
  77. package/crates/team-agent/src/messaging/persist.rs +309 -0
  78. package/crates/team-agent/src/messaging/results.rs +16 -24
  79. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  80. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  81. package/crates/team-agent/src/messaging/send.rs +133 -58
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +2 -1
  88. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  89. package/crates/team-agent/src/state/repository.rs +59 -16
  90. package/package.json +4 -4
@@ -0,0 +1,333 @@
1
+ use crate::cli::{CliError, CmdOutput, CmdResult, ExitCode};
2
+ use crate::messaging::{
3
+ DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus, MessageTarget, SendOptions,
4
+ };
5
+ use serde_json::{json, Value};
6
+
7
+ pub(super) fn watch_notice_json(target: &MessageTarget, opts: &SendOptions) -> Value {
8
+ let agent_id = match target {
9
+ MessageTarget::Single(agent) => agent.clone(),
10
+ MessageTarget::Broadcast => "*".to_string(),
11
+ MessageTarget::Fanout(recipients) => recipients
12
+ .first()
13
+ .cloned()
14
+ .unwrap_or_else(|| "-".to_string()),
15
+ };
16
+ json!({
17
+ "status": "registered",
18
+ "watcher_id": format!("watch-{agent_id}"),
19
+ "task_id": opts.task_id.as_ref().map(|t| t.as_str().to_string()),
20
+ "agent_id": agent_id,
21
+ "notice": "Team Agent will collect the result and notify the leader when this task reports completion."
22
+ })
23
+ }
24
+
25
+ /// 0.5.45 naming-addressing (design §3.5, RED-2/RED-3 positional):
26
+ /// after `messaging::send_message` refuses with `target_not_in_team`
27
+ /// for a Single non-special short id, attach scope-safe advisory
28
+ /// suggestions to the outbound JSON envelope. Candidate source =
29
+ /// selected team's projected `agents` map (never the raw workspace
30
+ /// `teams`) so sibling teams cannot leak. Zero DB write, zero inject
31
+ /// — the refusal exit code is unchanged.
32
+ pub(super) fn attach_positional_typo_suggestions(
33
+ value: &mut Value,
34
+ target: &MessageTarget,
35
+ selected_state: &Value,
36
+ ) {
37
+ use crate::model::name_similarity::{rank, Candidate};
38
+ let requested = match target {
39
+ MessageTarget::Single(id) if id != "*" && id != "leader" => id.clone(),
40
+ _ => return,
41
+ };
42
+ let Some(obj) = value.as_object_mut() else {
43
+ return;
44
+ };
45
+ if obj.get("reason").and_then(Value::as_str) != Some("target_not_in_team") {
46
+ return;
47
+ }
48
+ let team_key = selected_state
49
+ .get("active_team_key")
50
+ .or_else(|| selected_state.get("team_key"))
51
+ .and_then(Value::as_str)
52
+ .unwrap_or("");
53
+ let candidates: Vec<Candidate<String>> = selected_state
54
+ .get("agents")
55
+ .and_then(Value::as_object)
56
+ .map(|agents| {
57
+ agents
58
+ .keys()
59
+ .map(|agent_id| Candidate {
60
+ match_key: agent_id.clone(),
61
+ stable_key: agent_id.clone(),
62
+ payload: agent_id.clone(),
63
+ })
64
+ .collect()
65
+ })
66
+ .unwrap_or_default();
67
+ let ranked = rank(&requested, &candidates);
68
+ let candidate_values: Vec<Value> = ranked
69
+ .iter()
70
+ .map(|agent_id| {
71
+ json!({
72
+ "name": agent_id,
73
+ "team_key": team_key,
74
+ "agent_id": agent_id,
75
+ "advisory": true,
76
+ })
77
+ })
78
+ .collect();
79
+ obj.insert("requested_name".to_string(), json!(requested));
80
+ if let Some(best) = ranked.first() {
81
+ obj.insert("suggested_name".to_string(), json!(best));
82
+ }
83
+ obj.insert("candidates".to_string(), Value::Array(candidate_values));
84
+ }
85
+
86
+ pub(super) fn delivery_outcome_json(
87
+ outcome: &DeliveryOutcome,
88
+ target: &MessageTarget,
89
+ content: &str,
90
+ opts: &SendOptions,
91
+ ) -> Value {
92
+ // Pre-release 0.4.0 user directive: send result MUST NOT carry the
93
+ // message body — neither in human form (cli/emit.rs) NOR in --json.
94
+ // External consumers who need the message content read it via `inbox`,
95
+ // not from the send response. We surface `content_length_bytes` as a
96
+ // size sanity field so callers can verify the body size they intended
97
+ // to send arrived intact without exposing the body itself.
98
+ let target_wire = target_json(target);
99
+ json!({
100
+ "ok": outcome.ok,
101
+ "status": delivery_status_wire(outcome.status),
102
+ "delivery_status": api_delivery_status(outcome),
103
+ "delivered": delivery_proven(outcome.status),
104
+ "target": target_wire,
105
+ "agent_id": first_target(target),
106
+ "content_length_bytes": content.len(),
107
+ "sender": opts.sender,
108
+ "message_id": outcome.message_id,
109
+ "message_status": outcome.message_status.0,
110
+ "verification": outcome.verification,
111
+ "stage": outcome.stage.map(delivery_stage_wire),
112
+ "reason": outcome.reason.map(delivery_refusal_wire),
113
+ "channel": outcome.channel,
114
+ })
115
+ }
116
+
117
+ pub(super) fn api_delivery_status(outcome: &DeliveryOutcome) -> &'static str {
118
+ if delivery_proven(outcome.status) {
119
+ return "delivered";
120
+ }
121
+ if matches!(outcome.status, DeliveryStatus::Queued) && outcome.message_status.0 == "accepted" {
122
+ return "pending";
123
+ }
124
+ delivery_status_wire(outcome.status)
125
+ }
126
+
127
+ pub(super) fn delivery_proven(status: DeliveryStatus) -> bool {
128
+ matches!(
129
+ status,
130
+ DeliveryStatus::Delivered
131
+ | DeliveryStatus::AlreadyDelivered
132
+ | DeliveryStatus::BroadcastDelivered
133
+ | DeliveryStatus::FanoutDelivered
134
+ )
135
+ }
136
+
137
+ pub(super) fn add_send_reminder_if_ok(value: &mut Value) {
138
+ if value.get("ok").and_then(Value::as_bool) != Some(true) {
139
+ return;
140
+ }
141
+ let reminder = send_reminder_for_value(value);
142
+ if let Some(obj) = value.as_object_mut() {
143
+ obj.insert("reminder".to_string(), json!(reminder));
144
+ }
145
+ }
146
+
147
+ /// E6 (0.5.9 offline-mailbox-toname-design §§3.1/6.2/8, real-machine
148
+ /// escape evidence
149
+ /// `.team/artifacts/0.5.9-subscription-gate.md` +
150
+ /// `.team/evidence/0.5.9-subscription-gate-20260707T143241Z-4645/`):
151
+ /// when the `--to-name <ws>::<team>/leader` resolver refused with
152
+ /// `leader_not_attached`, decide whether the target team is still alive
153
+ /// (worker + coordinator running without a bound leader) and, if so,
154
+ /// enqueue the mailbox row so `attach-leader` replays it exactly once.
155
+ ///
156
+ /// Only queues for third-party senders (sender workspace ≠ target
157
+ /// workspace). Owner-scope refusals stay refused so status/diagnose can
158
+ /// keep pushing the operator toward `attach-leader`.
159
+ pub(super) fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
160
+ let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
161
+ ExitCode::Error
162
+ } else {
163
+ ExitCode::Ok
164
+ };
165
+ if as_json {
166
+ CmdResult::from_json(value, true)
167
+ } else {
168
+ CmdResult {
169
+ output: CmdOutput::Human(send_human_output(&value)),
170
+ exit,
171
+ as_json: false,
172
+ }
173
+ }
174
+ }
175
+
176
+ pub(super) fn send_human_output(value: &Value) -> String {
177
+ let mut parts = vec![
178
+ send_human_field(value, "ok"),
179
+ format!("status: {}", send_human_status(value)),
180
+ send_human_field(value, "message_id"),
181
+ format!("target: {}", send_human_target(value)),
182
+ ];
183
+ for key in ["verification", "stage", "reason", "channel"] {
184
+ if !value.get(key).is_none_or(Value::is_null) {
185
+ parts.push(send_human_field(value, key));
186
+ }
187
+ }
188
+ // 0.5.45 naming-addressing (design §3.4/§3.5, RED-3 positional):
189
+ // when the refusal envelope carries a scope-safe suggestion,
190
+ // surface it verbatim in human output so users can copy the
191
+ // right short id. `requested_name` echoes the typo, `suggested_
192
+ // name` is the copyable canonical.
193
+ if let Some(requested) = value
194
+ .get("requested_name")
195
+ .and_then(Value::as_str)
196
+ .filter(|s| !s.is_empty())
197
+ {
198
+ parts.push(format!("requested_name: {requested}"));
199
+ }
200
+ if let Some(suggested) = value
201
+ .get("suggested_name")
202
+ .and_then(Value::as_str)
203
+ .filter(|s| !s.is_empty())
204
+ {
205
+ parts.push(format!(
206
+ "Did you mean `{suggested}`? suggested_name: {suggested}"
207
+ ));
208
+ }
209
+ parts.join(" ")
210
+ }
211
+
212
+ pub(super) fn send_human_field(value: &Value, key: &str) -> String {
213
+ let rendered = value
214
+ .get(key)
215
+ .map(send_human_value)
216
+ .unwrap_or_else(|| "None".to_string());
217
+ format!("{key}: {rendered}")
218
+ }
219
+
220
+ pub(super) fn send_human_target(value: &Value) -> String {
221
+ ["target", "agent_id", "pane_id", "to_name"]
222
+ .iter()
223
+ .find_map(|key| value.get(*key).filter(|v| !v.is_null()))
224
+ .map(send_human_value)
225
+ .unwrap_or_else(|| "None".to_string())
226
+ }
227
+
228
+ pub(super) fn send_human_status(value: &Value) -> String {
229
+ value
230
+ .get("status")
231
+ .map(send_human_value)
232
+ .unwrap_or_else(|| {
233
+ if value.get("ok").and_then(Value::as_bool) == Some(true) {
234
+ "delivered".to_string()
235
+ } else {
236
+ "failed".to_string()
237
+ }
238
+ })
239
+ }
240
+
241
+ pub(super) fn send_human_value(value: &Value) -> String {
242
+ let text = match value {
243
+ Value::Null => "None".to_string(),
244
+ Value::Bool(true) => "True".to_string(),
245
+ Value::Bool(false) => "False".to_string(),
246
+ Value::Number(n) => n.to_string(),
247
+ Value::String(s) => s.clone(),
248
+ Value::Array(_) | Value::Object(_) => {
249
+ serde_json::to_string(value).unwrap_or_else(|_| "None".to_string())
250
+ }
251
+ };
252
+ text.replace(['\r', '\n'], " ")
253
+ }
254
+
255
+ pub(super) fn send_reminder_for_value(value: &Value) -> &'static str {
256
+ let delivered = value.get("delivered").and_then(Value::as_bool);
257
+ let status = value.get("status").and_then(Value::as_str);
258
+ let delivery_status = value.get("delivery_status").and_then(Value::as_str);
259
+ if delivered == Some(false)
260
+ || matches!(status, Some("queued"))
261
+ || matches!(delivery_status, Some("pending"))
262
+ {
263
+ "Message queued; coordinator will notify when the worker receives it. Do not poll the worker terminal with capture-pane."
264
+ } else {
265
+ crate::cli::SEND_REMINDER
266
+ }
267
+ }
268
+
269
+ pub(super) fn target_json(target: &MessageTarget) -> Value {
270
+ match target {
271
+ MessageTarget::Single(agent) => json!(agent),
272
+ MessageTarget::Broadcast => json!("*"),
273
+ MessageTarget::Fanout(recipients) => json!(recipients),
274
+ }
275
+ }
276
+
277
+ pub(super) fn first_target(target: &MessageTarget) -> String {
278
+ match target {
279
+ MessageTarget::Single(agent) => agent.clone(),
280
+ MessageTarget::Broadcast => "*".to_string(),
281
+ MessageTarget::Fanout(recipients) => recipients.first().cloned().unwrap_or_default(),
282
+ }
283
+ }
284
+
285
+ pub(super) fn delivery_status_wire(status: DeliveryStatus) -> &'static str {
286
+ match status {
287
+ DeliveryStatus::Delivered => "delivered",
288
+ DeliveryStatus::Failed => "failed",
289
+ DeliveryStatus::Queued => "queued",
290
+ DeliveryStatus::Blocked => "blocked",
291
+ DeliveryStatus::Refused => "refused",
292
+ DeliveryStatus::Degraded => "degraded",
293
+ DeliveryStatus::RetryScheduled => "retry_scheduled",
294
+ DeliveryStatus::TrustAutoAnswerExhausted => "trust_auto_answer_exhausted",
295
+ DeliveryStatus::AlreadyDelivered => "already_delivered",
296
+ DeliveryStatus::FallbackLog => "fallback_log",
297
+ DeliveryStatus::BroadcastDelivered => "broadcast_delivered",
298
+ DeliveryStatus::BroadcastPartial => "broadcast_partial",
299
+ DeliveryStatus::FanoutDelivered => "fanout_delivered",
300
+ DeliveryStatus::FanoutPartial => "fanout_partial",
301
+ }
302
+ }
303
+
304
+ pub(super) fn delivery_refusal_wire(reason: DeliveryRefusal) -> &'static str {
305
+ match reason {
306
+ DeliveryRefusal::TargetNotInTeam => "target_not_in_team",
307
+ DeliveryRefusal::HumanConfirmationRequired => "human_confirmation_required",
308
+ DeliveryRefusal::MissingPermissions => "missing_permissions",
309
+ DeliveryRefusal::RecipientBusy => "recipient_busy",
310
+ DeliveryRefusal::UnknownRecipient => "unknown_recipient",
311
+ DeliveryRefusal::TmuxTargetMissing => "tmux_target_missing",
312
+ DeliveryRefusal::MessageAlreadyClaimed => "message_already_claimed",
313
+ DeliveryRefusal::LeaderNotAttached => "leader_not_attached",
314
+ DeliveryRefusal::CoordinatorUnavailable => "coordinator_unavailable",
315
+ DeliveryRefusal::NoCallerPane => "no_caller_pane",
316
+ DeliveryRefusal::TeamOwnerMismatch => "team_owner_mismatch",
317
+ DeliveryRefusal::Ambiguous => "ambiguous",
318
+ DeliveryRefusal::RecipientPaneInNonInputMode => "recipient_pane_in_non_input_mode",
319
+ DeliveryRefusal::SessionDrift => "session_drift",
320
+ DeliveryRefusal::Duplicate => "duplicate",
321
+ DeliveryRefusal::RoutingAmbiguous => "routing_ambiguous",
322
+ DeliveryRefusal::EmptyTargetList => "empty_target_list",
323
+ }
324
+ }
325
+
326
+ pub(super) fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
327
+ match stage {
328
+ DeliveryStage::TrustAutoAnswerDismissalWait => "trust_auto_answer_dismissal_wait",
329
+ DeliveryStage::Inject => "inject",
330
+ DeliveryStage::Submit => "submit",
331
+ DeliveryStage::VisibleCheck => "visible_check",
332
+ }
333
+ }
@@ -0,0 +1,361 @@
1
+ use super::mailbox::maybe_enqueue_offline_leader_mailbox;
2
+ use super::persist::persist_resolved_target;
3
+ use crate::cli::{CliError, SendArgs};
4
+ use crate::messaging::{MessageTarget, TrustedSender};
5
+ use serde_json::{json, Value};
6
+
7
+ pub(super) fn warn_send_alias(flag: &str) {
8
+ let spec = crate::cli::spec::command_spec("send");
9
+ let sunset = spec
10
+ .and_then(|spec| spec.sunset)
11
+ .unwrap_or("next compatibility release");
12
+ let action = spec
13
+ .and_then(|spec| spec.action)
14
+ .unwrap_or("use positional logical TARGET addressing");
15
+ eprintln!("warning: {flag} is deprecated; sunset: {sunset}; action: {action}");
16
+ }
17
+
18
+ pub(super) fn logical_to_from_args(
19
+ args: &SendArgs,
20
+ host_leader_to: Option<&str>,
21
+ ) -> Result<String, CliError> {
22
+ if args.to_name.is_some()
23
+ && (args.target.is_some() || args.targets.is_some() || args.to_leader.is_some())
24
+ {
25
+ return Err(CliError::Usage(
26
+ "--to-name and --pane/TARGET/--to are mutually exclusive".to_string(),
27
+ ));
28
+ }
29
+ let supplied = [
30
+ args.target.is_some(),
31
+ args.targets.is_some(),
32
+ args.to_name.is_some(),
33
+ args.to_leader.is_some(),
34
+ ]
35
+ .into_iter()
36
+ .filter(|present| *present)
37
+ .count();
38
+ if supplied > 1 {
39
+ return Err(CliError::Usage(
40
+ "TARGET, --targets, --to-name, and --to-leader are mutually exclusive".to_string(),
41
+ ));
42
+ }
43
+ let logical_to = if args.to_leader.is_some() {
44
+ host_leader_to.unwrap_or_default().to_string()
45
+ } else if let Some(name) = args.to_name.as_deref() {
46
+ name.to_string()
47
+ } else if let Some(targets) = args.targets.as_deref() {
48
+ targets.to_string()
49
+ } else {
50
+ args.target.clone().unwrap_or_default()
51
+ };
52
+ if args.target.is_none() && supplied > 0 && args.message.is_empty() {
53
+ if args.to_name.is_some() {
54
+ return Err(CliError::Usage(
55
+ "--to-name requires a non-empty message".to_string(),
56
+ ));
57
+ }
58
+ return Err(CliError::Usage(
59
+ "send requires a non-empty message after logical TO".to_string(),
60
+ ));
61
+ }
62
+ Ok(logical_to)
63
+ }
64
+
65
+ pub(super) fn resolve_host_leader_alias(
66
+ name: &str,
67
+ ) -> Result<(String, crate::leader::registry::LeaderRegistryEntry), Value> {
68
+ let classified = crate::leader::registry::list_validated_no_gc();
69
+ let candidates = classified
70
+ .iter()
71
+ .filter(|(entry, _, _)| {
72
+ entry.delivery_name == name
73
+ || entry.qualified_name == name
74
+ || entry.stable_qualified_name == name
75
+ || entry.aliases.iter().any(|alias| alias == name)
76
+ })
77
+ .map(|(entry, _, _)| entry.clone())
78
+ .collect::<Vec<_>>();
79
+ if candidates.is_empty() {
80
+ return Err(json!({
81
+ "ok": false,
82
+ "status": "refused",
83
+ "reason": "leader_name_not_found",
84
+ "requested_name": name,
85
+ "resolved_via": "host_leader_registry",
86
+ "candidates": Vec::<Value>::new(),
87
+ "workspace_hash": null,
88
+ "stable_qualified_name": null,
89
+ "channel": "leader_mailbox",
90
+ "delivered": false,
91
+ "message_status": "queued_until_leader_attach",
92
+ "action": "run `team-agent leaders` to see registered leaders; inspect queued leader messages with `team-agent inbox`; retry with a qualified name",
93
+ "registry_stale": false,
94
+ }));
95
+ }
96
+ if candidates.len() > 1 {
97
+ let candidates = candidates
98
+ .iter()
99
+ .map(|entry| {
100
+ json!({
101
+ "name": entry.qualified_name,
102
+ "workspace": entry.workspace.display().to_string(),
103
+ "team_key": entry.team_key,
104
+ "workspace_hash": entry.workspace_hash,
105
+ "stable_qualified_name": entry.stable_qualified_name,
106
+ })
107
+ })
108
+ .collect::<Vec<_>>();
109
+ return Err(json!({
110
+ "ok": false,
111
+ "status": "refused",
112
+ "reason": "name_ambiguous",
113
+ "requested_name": name,
114
+ "resolved_via": "host_leader_registry",
115
+ "candidates": candidates,
116
+ "channel": "leader_mailbox",
117
+ "delivered": false,
118
+ "action": "run `team-agent leaders` and retry with the qualified name",
119
+ }));
120
+ }
121
+ let entry = candidates[0].clone();
122
+ let (status, reason) = crate::leader::registry::classify(&entry);
123
+ if status == "STALE" {
124
+ let team_alive = crate::state::persist::load_runtime_state(&entry.workspace)
125
+ .ok()
126
+ .and_then(|state| {
127
+ state
128
+ .get("teams")
129
+ .and_then(Value::as_object)
130
+ .and_then(|teams| teams.get(&entry.team_key))
131
+ .and_then(|team| team.get("status"))
132
+ .and_then(Value::as_str)
133
+ .map(|status| status == "alive" || status.is_empty())
134
+ })
135
+ .unwrap_or(false);
136
+ if !team_alive {
137
+ return Err(json!({
138
+ "ok": false,
139
+ "status": "refused",
140
+ "reason": "registry_stale",
141
+ "requested_name": name,
142
+ "resolved_via": "host_leader_registry",
143
+ "stale_reason": reason,
144
+ "workspace_hash": entry.workspace_hash,
145
+ "stable_qualified_name": entry.stable_qualified_name,
146
+ "channel": "leader_mailbox",
147
+ "delivered": false,
148
+ "action": "target team is not alive; run `team-agent leaders` for current state",
149
+ }));
150
+ }
151
+ }
152
+ let logical_to = format!("{}::{}/leader", entry.workspace.display(), entry.team_key);
153
+ Ok((logical_to, entry))
154
+ }
155
+
156
+ pub(super) fn decorate_host_leader_alias(
157
+ value: &mut Value,
158
+ entry: &crate::leader::registry::LeaderRegistryEntry,
159
+ ) {
160
+ let Some(object) = value.as_object_mut() else {
161
+ return;
162
+ };
163
+ object.insert("resolved_via".to_string(), json!("host_leader_registry"));
164
+ object.insert("to_leader".to_string(), json!(entry.qualified_name));
165
+ object.insert("requested_name".to_string(), json!(entry.delivery_name));
166
+ object.insert("workspace_hash".to_string(), json!(entry.workspace_hash));
167
+ object.insert(
168
+ "stable_qualified_name".to_string(),
169
+ json!(entry.stable_qualified_name),
170
+ );
171
+ }
172
+
173
+ pub(super) fn send_to_logical_to(
174
+ args: &SendArgs,
175
+ logical_to: &str,
176
+ content: &str,
177
+ ) -> Result<Value, CliError> {
178
+ let names = logical_to
179
+ .split(',')
180
+ .map(str::trim)
181
+ .filter(|name| !name.is_empty())
182
+ .collect::<Vec<_>>();
183
+ if names.is_empty() || names.len() != logical_to.split(',').count() {
184
+ return Err(CliError::Usage(
185
+ "logical TO comma-list contains an empty recipient".to_string(),
186
+ ));
187
+ }
188
+
189
+ let mut resolved = Vec::with_capacity(names.len());
190
+ for name in names {
191
+ match crate::cli::named_address::resolve_name_for_cli(
192
+ &args.workspace,
193
+ name,
194
+ args.team.as_deref(),
195
+ ) {
196
+ Ok((recipient, _transport)) => resolved.push(recipient),
197
+ Err(mut error) => {
198
+ adapt_positional_bare_error(args, name, &mut error);
199
+ if matches!(
200
+ error.kind,
201
+ crate::cli::named_address::NamedAddressErrorKind::StateNotFound
202
+ ) {
203
+ return Ok(resolution_refusal_json(&error, logical_to, content, args));
204
+ }
205
+ if resolved.is_empty() && !logical_to.contains(',') {
206
+ if let Some(value) = maybe_enqueue_offline_leader_mailbox(
207
+ &args.workspace,
208
+ name,
209
+ content,
210
+ args.sender.as_str(),
211
+ args.task.as_deref(),
212
+ &error,
213
+ )? {
214
+ return Ok(value);
215
+ }
216
+ }
217
+ if args.json {
218
+ return Ok(error.to_json());
219
+ }
220
+ return Err(CliError::Usage(error.n38_message()));
221
+ }
222
+ }
223
+ }
224
+
225
+ if resolved.len() == 1 {
226
+ return send_to_resolved_name(args, &resolved[0], content);
227
+ }
228
+
229
+ let first = &resolved[0];
230
+ let one_scope = resolved.iter().all(|recipient| {
231
+ recipient.target_workspace == first.target_workspace && recipient.team_key == first.team_key
232
+ });
233
+ if one_scope {
234
+ let recipients = resolved
235
+ .iter()
236
+ .map(logical_recipient_id)
237
+ .collect::<Result<Vec<_>, _>>()?;
238
+ let target = MessageTarget::Fanout(recipients);
239
+ return persist_resolved_target(args, first, &target, content);
240
+ }
241
+
242
+ let mut results = Vec::with_capacity(resolved.len());
243
+ for recipient in &resolved {
244
+ results.push(send_to_resolved_name(args, recipient, content)?);
245
+ }
246
+ let ok = results
247
+ .iter()
248
+ .all(|value| value.get("ok").and_then(Value::as_bool) == Some(true));
249
+ let message_id = results
250
+ .iter()
251
+ .rev()
252
+ .find_map(|value| value.get("message_id").and_then(Value::as_str))
253
+ .map(str::to_string);
254
+ Ok(json!({
255
+ "ok": ok,
256
+ "status": if ok { "fanout_delivered" } else { "fanout_partial" },
257
+ "delivery_status": if ok { "pending" } else { "fanout_partial" },
258
+ "delivered": false,
259
+ "target": logical_to.split(',').map(str::trim).collect::<Vec<_>>(),
260
+ "content_length_bytes": content.len(),
261
+ "sender": args.sender,
262
+ "message_id": message_id,
263
+ "results": results,
264
+ }))
265
+ }
266
+
267
+ pub(super) fn resolution_refusal_json(
268
+ error: &crate::cli::named_address::NamedAddressError,
269
+ logical_to: &str,
270
+ content: &str,
271
+ args: &SendArgs,
272
+ ) -> Value {
273
+ let mut value = error.to_json();
274
+ if let Some(object) = value.as_object_mut() {
275
+ object.insert("delivery_status".to_string(), json!("refused"));
276
+ object.insert("delivered".to_string(), json!(false));
277
+ object.insert("target".to_string(), json!(logical_to));
278
+ object.insert("agent_id".to_string(), json!(logical_to));
279
+ object.insert("content_length_bytes".to_string(), json!(content.len()));
280
+ object.insert("sender".to_string(), json!(args.sender));
281
+ object.insert("message_id".to_string(), Value::Null);
282
+ object.insert("message_status".to_string(), json!("refused"));
283
+ object.insert("verification".to_string(), Value::Null);
284
+ object.insert("stage".to_string(), Value::Null);
285
+ object.insert("channel".to_string(), Value::Null);
286
+ }
287
+ value
288
+ }
289
+
290
+ pub(super) fn adapt_positional_bare_error(
291
+ args: &SendArgs,
292
+ name: &str,
293
+ error: &mut crate::cli::named_address::NamedAddressError,
294
+ ) {
295
+ if args.target.as_deref() != Some(name)
296
+ || args.team.is_none()
297
+ || name.contains('/')
298
+ || name.contains(':')
299
+ || name.contains(',')
300
+ {
301
+ return;
302
+ }
303
+ error.requested_name = Some(name.to_string());
304
+ for candidate in &mut error.candidates {
305
+ let agent_id = candidate
306
+ .get("agent_id")
307
+ .and_then(Value::as_str)
308
+ .map(str::to_string);
309
+ if let (Some(object), Some(agent_id)) = (candidate.as_object_mut(), agent_id) {
310
+ object.insert("name".to_string(), json!(agent_id));
311
+ }
312
+ }
313
+ error.suggested_name = error
314
+ .suggested_name
315
+ .as_deref()
316
+ .and_then(|suggested| suggested.rsplit('/').next())
317
+ .map(str::to_string);
318
+ if let Some(suggested) = error.suggested_name.as_deref() {
319
+ error.action = format!("Did you mean `{suggested}`? Retry with `{suggested}` as TO.");
320
+ }
321
+ }
322
+
323
+ pub(super) fn logical_recipient_id(
324
+ resolved: &crate::cli::named_address::ResolvedNamedAddress,
325
+ ) -> Result<String, CliError> {
326
+ match resolved.target_kind {
327
+ crate::cli::named_address::NamedTargetKind::Worker => resolved
328
+ .agent_id
329
+ .clone()
330
+ .ok_or_else(|| CliError::Runtime("resolved worker is missing agent id".to_string())),
331
+ crate::cli::named_address::NamedTargetKind::Leader => Ok("leader".to_string()),
332
+ crate::cli::named_address::NamedTargetKind::SessionWindow => Err(CliError::Usage(
333
+ "named session/window delivery is sunset; use a logical agent or leader name"
334
+ .to_string(),
335
+ )),
336
+ }
337
+ }
338
+
339
+ pub(super) fn send_to_resolved_name(
340
+ args: &SendArgs,
341
+ resolved: &crate::cli::named_address::ResolvedNamedAddress,
342
+ content: &str,
343
+ ) -> Result<Value, CliError> {
344
+ let recipient = logical_recipient_id(resolved)?;
345
+ if let Some(warning) = &resolved.warning {
346
+ eprintln!("warning: {warning}");
347
+ }
348
+ let target = MessageTarget::Single(recipient);
349
+ let mut value = persist_resolved_target(args, resolved, &target, content)?;
350
+ if args.to_name.is_some() || args.to_leader.is_some() {
351
+ if let Some(obj) = value.as_object_mut() {
352
+ obj.insert("to_name".to_string(), json!(resolved.raw_name));
353
+ obj.insert(
354
+ "target_workspace".to_string(),
355
+ json!(resolved.target_workspace.display().to_string()),
356
+ );
357
+ obj.insert("team_key".to_string(), json!(resolved.team_key));
358
+ }
359
+ }
360
+ Ok(value)
361
+ }