@team-agent/installer 0.5.50 → 0.5.52

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 (93) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +5 -3
  4. package/crates/team-agent/src/cli/emit.rs +2 -1
  5. package/crates/team-agent/src/cli/mod.rs +32 -14
  6. package/crates/team-agent/src/cli/named_address.rs +46 -115
  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 +44 -1169
  13. package/crates/team-agent/src/cli/spec.rs +1 -1
  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/main_preserved.rs +1 -0
  25. package/crates/team-agent/src/cli/tests/run_delegation.rs +1 -0
  26. package/crates/team-agent/src/cli/types.rs +1 -0
  27. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  29. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  30. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  31. package/crates/team-agent/src/db/message_store.rs +154 -44
  32. package/crates/team-agent/src/event_log.rs +73 -0
  33. package/crates/team-agent/src/leader/lease.rs +202 -16
  34. package/crates/team-agent/src/leader/start.rs +28 -4
  35. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  36. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +303 -0
  37. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  38. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  39. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  41. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  42. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  43. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  44. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  45. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  46. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  48. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  49. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  50. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  51. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  52. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  53. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  54. package/crates/team-agent/src/lifecycle/restart/agent.rs +63 -29
  55. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  56. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +167 -27
  57. package/crates/team-agent/src/lifecycle/restart/remove.rs +503 -77
  58. package/crates/team-agent/src/lifecycle/restart.rs +26 -2
  59. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +906 -17
  60. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +67 -2
  61. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  62. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  63. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  64. package/crates/team-agent/src/mcp_server/mod.rs +1 -1
  65. package/crates/team-agent/src/mcp_server/tests/send.rs +6 -10
  66. package/crates/team-agent/src/mcp_server/tests/tools.rs +18 -1
  67. package/crates/team-agent/src/mcp_server/tests.rs +11 -4
  68. package/crates/team-agent/src/mcp_server/tools.rs +14 -5
  69. package/crates/team-agent/src/messaging/activity.rs +4 -2
  70. package/crates/team-agent/src/messaging/address.rs +86 -0
  71. package/crates/team-agent/src/messaging/delivery.rs +288 -183
  72. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  73. package/crates/team-agent/src/messaging/leader_channel.rs +171 -0
  74. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  75. package/crates/team-agent/src/messaging/mod.rs +15 -2
  76. package/crates/team-agent/src/messaging/persist.rs +309 -0
  77. package/crates/team-agent/src/messaging/results.rs +16 -24
  78. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  79. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  80. package/crates/team-agent/src/messaging/send.rs +101 -49
  81. package/crates/team-agent/src/messaging/tests/leader_channel.rs +169 -0
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +2 -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 +284 -21
  88. package/crates/team-agent/src/state/projection.rs +245 -30
  89. package/crates/team-agent/src/state/repository/intent.rs +13 -0
  90. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  91. package/crates/team-agent/src/state/repository.rs +94 -27
  92. package/crates/team-agent/src/state/selector.rs +9 -18
  93. package/package.json +4 -4
@@ -4,6 +4,29 @@
4
4
  use super::*;
5
5
  use crate::messaging::{DeliveryOutcome, DeliveryRefusal, DeliveryStage, DeliveryStatus};
6
6
 
7
+ mod coordinator;
8
+ mod mailbox;
9
+ mod persist;
10
+ mod presentation;
11
+ mod resolve;
12
+
13
+ use coordinator::{
14
+ append_loud_ensure_fields, dirty_topology_refusal_value, loud_ensure_coordinator,
15
+ target_has_known_worker,
16
+ };
17
+ pub use persist::send_options_from_args;
18
+ use persist::{
19
+ initial_delivery_allows_watch, observe_initial_delivery_for_watch, routing_ambiguous_value,
20
+ };
21
+ use presentation::{
22
+ add_send_reminder_if_ok, attach_positional_typo_suggestions, cmd_send_result,
23
+ delivery_outcome_json, watch_notice_json,
24
+ };
25
+ use resolve::{
26
+ decorate_host_leader_alias, logical_to_from_args, resolve_host_leader_alias,
27
+ send_to_logical_to, warn_send_alias,
28
+ };
29
+
7
30
  /// `cmd_send`(`commands.py:164`)。解析 target(`--to` fanout / 单 target / `*`)→ [`MessageTarget`],
8
31
  /// 拼 [`SendOptions`](no_ack→requires_ack 取反、no_wait→wait_visible 取反等)→ `messaging::send_message`。
9
32
  pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
@@ -21,9 +44,7 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
21
44
  } else {
22
45
  "--pane and TARGET/--to are mutually exclusive; --pane also conflicts with --to-leader"
23
46
  };
24
- return Err(CliError::Usage(
25
- message.to_string(),
26
- ));
47
+ return Err(CliError::Usage(message.to_string()));
27
48
  }
28
49
  let content = args.message.join(" ");
29
50
  if content.is_empty() {
@@ -96,11 +117,6 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
96
117
  } else {
97
118
  None
98
119
  };
99
- if let Some(value) =
100
- coordinator_ensure_unavailable_value(coordinator_ensure.as_ref(), &target, &content, &opts)
101
- {
102
- return Ok(cmd_send_result(value, args.json));
103
- }
104
120
  let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
105
121
  if opts.watch_result {
106
122
  outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
@@ -125,1167 +141,8 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
125
141
  Ok(cmd_send_result(value, args.json))
126
142
  }
127
143
 
128
- fn dirty_topology_refusal_value(
129
- selected: &crate::state::selector::SelectedTeam,
130
- requested_team: Option<&str>,
131
- ) -> Option<Value> {
132
- let issue_ids = crate::topology::restart_dirty_topology_issue_ids(&selected.state);
133
- if issue_ids.is_empty() {
134
- return None;
135
- }
136
- let session_name = selected
137
- .state
138
- .get("session_name")
139
- .and_then(Value::as_str)
140
- .unwrap_or_default()
141
- .to_string();
142
- let reason = issue_ids
143
- .first()
144
- .cloned()
145
- .unwrap_or_else(|| "dirty_topology".to_string());
146
- let repair_team = requested_team
147
- .filter(|team| !team.is_empty())
148
- .unwrap_or(selected.team_key.as_str());
149
- Some(json!({
150
- "ok": false,
151
- "status": "refused_dirty_topology",
152
- "reason": reason,
153
- "session_name": session_name,
154
- "error": "send refused: tmux endpoint/socket topology is inconsistent; run diagnose from the intended leader socket before sending",
155
- "issues": issue_ids
156
- .iter()
157
- .map(|id| json!({"id": id}))
158
- .collect::<Vec<_>>(),
159
- "next_actions": [
160
- "team-agent diagnose --json",
161
- format!("team-agent claim-leader --team {repair_team} --confirm --json"),
162
- format!("team-agent takeover --team {repair_team} --confirm --json")
163
- ],
164
- }))
165
- }
166
-
167
- fn target_has_known_worker(state: &Value, target: &MessageTarget, sender: &str) -> bool {
168
- let Some(agents) = state.get("agents").and_then(Value::as_object) else {
169
- return false;
170
- };
171
- match target {
172
- MessageTarget::Single(target) => agents.contains_key(target),
173
- MessageTarget::Broadcast => agents.keys().any(|agent| agent != sender),
174
- MessageTarget::Fanout(recipients) => recipients
175
- .iter()
176
- .any(|recipient| agents.contains_key(recipient)),
177
- }
178
- }
179
-
180
- #[derive(Debug, Clone)]
181
- struct LoudEnsureResult {
182
- previous_status: String,
183
- start: crate::coordinator::StartReport,
184
- }
185
-
186
- fn loud_ensure_coordinator(
187
- selected: &crate::state::selector::SelectedTeam,
188
- ) -> Result<Option<LoudEnsureResult>, CliError> {
189
- if in_process_unit_test() {
190
- return Ok(None);
191
- }
192
- let workspace = crate::coordinator::WorkspacePath::new(selected.run_workspace.clone());
193
- let previous = crate::coordinator::coordinator_health(&workspace);
194
- if previous.ok {
195
- return Ok(None);
196
- }
197
- if previous.service_available
198
- && matches!(
199
- previous.binary_identity_relation,
200
- crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
201
- )
202
- {
203
- return Ok(None);
204
- }
205
- let previous_status = coordinator_health_status_wire(previous.status).to_string();
206
- let start = crate::coordinator::start_coordinator_with_team(
207
- &workspace,
208
- Some(selected.team_key.as_str()),
209
- )
210
- .map_err(|error| CliError::Runtime(error.to_string()))?;
211
- if !start.ok {
212
- return Ok(Some(LoudEnsureResult {
213
- previous_status,
214
- start,
215
- }));
216
- }
217
- if matches!(
218
- start.status,
219
- crate::coordinator::StartOutcome::Started
220
- | crate::coordinator::StartOutcome::StartedAfterRotation
221
- ) {
222
- crate::event_log::EventLog::new(&selected.run_workspace)
223
- .write(
224
- "coordinator.ensure_restarted",
225
- json!({
226
- "coordinator_previous_status": previous_status,
227
- "status": start.status,
228
- "pid": start.pid.map(|pid| pid.get()),
229
- "previous_pid": start.previous_pid.map(|pid| pid.get()),
230
- "binary_path": start.binary_path,
231
- "binary_version": start.binary_version,
232
- "rotation_reason": start.rotation_reason,
233
- }),
234
- )
235
- .map_err(|error| CliError::Runtime(error.to_string()))?;
236
- return Ok(Some(LoudEnsureResult {
237
- previous_status,
238
- start,
239
- }));
240
- }
241
- Ok(None)
242
- }
243
-
244
- #[cfg(test)]
245
- fn in_process_unit_test() -> bool {
246
- true
247
- }
248
-
249
- #[cfg(not(test))]
250
- fn in_process_unit_test() -> bool {
251
- false
252
- }
253
-
254
- fn coordinator_ensure_unavailable_value(
255
- ensure: Option<&LoudEnsureResult>,
256
- target: &MessageTarget,
257
- content: &str,
258
- opts: &SendOptions,
259
- ) -> Option<Value> {
260
- let ensure = ensure?;
261
- if ensure.start.ok {
262
- return None;
263
- }
264
- let warning = format!(
265
- "coordinator is not running; message was not queued for {}. Run `team-agent diagnose` or restart the team before sending again.",
266
- first_target(target)
267
- );
268
- let mut value = json!({
269
- "ok": false,
270
- "status": "degraded",
271
- "delivery_status": "degraded",
272
- "delivered": false,
273
- "target": target_json(target),
274
- "agent_id": first_target(target),
275
- "content_length_bytes": content.len(),
276
- "sender": opts.sender,
277
- "message_id": Value::Null,
278
- "message_status": "degraded",
279
- "verification": warning,
280
- "stage": Value::Null,
281
- "reason": "coordinator_unavailable",
282
- "channel": "coordinator_unavailable",
283
- });
284
- append_loud_ensure_fields(&mut value, Some(ensure));
285
- Some(value)
286
- }
287
-
288
- fn append_loud_ensure_fields(value: &mut Value, ensure: Option<&LoudEnsureResult>) {
289
- let Some(ensure) = ensure else {
290
- return;
291
- };
292
- if !ensure.start.ok {
293
- return;
294
- }
295
- if let Some(obj) = value.as_object_mut() {
296
- obj.insert("coordinator_auto_restarted".to_string(), json!(true));
297
- obj.insert(
298
- "coordinator_previous_status".to_string(),
299
- json!(ensure.previous_status),
300
- );
301
- obj.insert(
302
- "coordinator".to_string(),
303
- coordinator_start_json(&ensure.start),
304
- );
305
- }
306
- }
307
-
308
- fn coordinator_start_json(report: &crate::coordinator::StartReport) -> Value {
309
- let summary = crate::lifecycle::CoordinatorStartSummary::from_start_report(report);
310
- crate::lifecycle::coordinator_start_summary_value(&summary)
311
- }
312
-
313
- fn coordinator_health_status_wire(
314
- status: crate::coordinator::CoordinatorHealthStatus,
315
- ) -> &'static str {
316
- match status {
317
- crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
318
- crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
319
- crate::coordinator::CoordinatorHealthStatus::Running => "running",
320
- crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
321
- }
322
- }
323
-
324
- fn warn_send_alias(flag: &str) {
325
- let spec = crate::cli::spec::command_spec("send");
326
- let sunset = spec.and_then(|spec| spec.sunset).unwrap_or("next compatibility release");
327
- let action = spec
328
- .and_then(|spec| spec.action)
329
- .unwrap_or("use positional logical TARGET addressing");
330
- eprintln!("warning: {flag} is deprecated; sunset: {sunset}; action: {action}");
331
- }
332
-
333
- fn logical_to_from_args(args: &SendArgs, host_leader_to: Option<&str>) -> Result<String, CliError> {
334
- if args.to_name.is_some()
335
- && (args.target.is_some() || args.targets.is_some() || args.to_leader.is_some())
336
- {
337
- return Err(CliError::Usage(
338
- "--to-name and --pane/TARGET/--to are mutually exclusive".to_string(),
339
- ));
340
- }
341
- let supplied = [
342
- args.target.is_some(),
343
- args.targets.is_some(),
344
- args.to_name.is_some(),
345
- args.to_leader.is_some(),
346
- ]
347
- .into_iter()
348
- .filter(|present| *present)
349
- .count();
350
- if supplied > 1 {
351
- return Err(CliError::Usage(
352
- "TARGET, --targets, --to-name, and --to-leader are mutually exclusive".to_string(),
353
- ));
354
- }
355
- let logical_to = if args.to_leader.is_some() {
356
- host_leader_to.unwrap_or_default().to_string()
357
- } else if let Some(name) = args.to_name.as_deref() {
358
- name.to_string()
359
- } else if let Some(targets) = args.targets.as_deref() {
360
- targets.to_string()
361
- } else {
362
- args.target.clone().unwrap_or_default()
363
- };
364
- if args.target.is_none() && supplied > 0 && args.message.is_empty() {
365
- if args.to_name.is_some() {
366
- return Err(CliError::Usage(
367
- "--to-name requires a non-empty message".to_string(),
368
- ));
369
- }
370
- return Err(CliError::Usage(
371
- "send requires a non-empty message after logical TO".to_string(),
372
- ));
373
- }
374
- Ok(logical_to)
375
- }
376
-
377
- fn resolve_host_leader_alias(
378
- name: &str,
379
- ) -> Result<(String, crate::leader::registry::LeaderRegistryEntry), Value> {
380
- let classified = crate::leader::registry::list_validated_no_gc();
381
- let candidates = classified
382
- .iter()
383
- .filter(|(entry, _, _)| {
384
- entry.delivery_name == name
385
- || entry.qualified_name == name
386
- || entry.stable_qualified_name == name
387
- || entry.aliases.iter().any(|alias| alias == name)
388
- })
389
- .map(|(entry, _, _)| entry.clone())
390
- .collect::<Vec<_>>();
391
- if candidates.is_empty() {
392
- return Err(json!({
393
- "ok": false,
394
- "status": "refused",
395
- "reason": "leader_name_not_found",
396
- "requested_name": name,
397
- "resolved_via": "host_leader_registry",
398
- "candidates": Vec::<Value>::new(),
399
- "workspace_hash": null,
400
- "stable_qualified_name": null,
401
- "channel": "leader_mailbox",
402
- "delivered": false,
403
- "message_status": "queued_until_leader_attach",
404
- "action": "run `team-agent leaders` to see registered leaders; inspect queued leader messages with `team-agent inbox`; retry with a qualified name",
405
- "registry_stale": false,
406
- }));
407
- }
408
- if candidates.len() > 1 {
409
- let candidates = candidates
410
- .iter()
411
- .map(|entry| {
412
- json!({
413
- "name": entry.qualified_name,
414
- "workspace": entry.workspace.display().to_string(),
415
- "team_key": entry.team_key,
416
- "workspace_hash": entry.workspace_hash,
417
- "stable_qualified_name": entry.stable_qualified_name,
418
- })
419
- })
420
- .collect::<Vec<_>>();
421
- return Err(json!({
422
- "ok": false,
423
- "status": "refused",
424
- "reason": "name_ambiguous",
425
- "requested_name": name,
426
- "resolved_via": "host_leader_registry",
427
- "candidates": candidates,
428
- "channel": "leader_mailbox",
429
- "delivered": false,
430
- "action": "run `team-agent leaders` and retry with the qualified name",
431
- }));
432
- }
433
- let entry = candidates[0].clone();
434
- let (status, reason) = crate::leader::registry::classify(&entry);
435
- if status == "STALE" {
436
- let team_alive = crate::state::persist::load_runtime_state(&entry.workspace)
437
- .ok()
438
- .and_then(|state| {
439
- state
440
- .get("teams")
441
- .and_then(Value::as_object)
442
- .and_then(|teams| teams.get(&entry.team_key))
443
- .and_then(|team| team.get("status"))
444
- .and_then(Value::as_str)
445
- .map(|status| status == "alive" || status.is_empty())
446
- })
447
- .unwrap_or(false);
448
- if !team_alive {
449
- return Err(json!({
450
- "ok": false,
451
- "status": "refused",
452
- "reason": "registry_stale",
453
- "requested_name": name,
454
- "resolved_via": "host_leader_registry",
455
- "stale_reason": reason,
456
- "workspace_hash": entry.workspace_hash,
457
- "stable_qualified_name": entry.stable_qualified_name,
458
- "channel": "leader_mailbox",
459
- "delivered": false,
460
- "action": "target team is not alive; run `team-agent leaders` for current state",
461
- }));
462
- }
463
- }
464
- let logical_to = format!("{}::{}/leader", entry.workspace.display(), entry.team_key);
465
- Ok((logical_to, entry))
466
- }
467
-
468
- fn decorate_host_leader_alias(
469
- value: &mut Value,
470
- entry: &crate::leader::registry::LeaderRegistryEntry,
471
- ) {
472
- let Some(object) = value.as_object_mut() else {
473
- return;
474
- };
475
- object.insert("resolved_via".to_string(), json!("host_leader_registry"));
476
- object.insert("to_leader".to_string(), json!(entry.qualified_name));
477
- object.insert("requested_name".to_string(), json!(entry.delivery_name));
478
- object.insert("workspace_hash".to_string(), json!(entry.workspace_hash));
479
- object.insert(
480
- "stable_qualified_name".to_string(),
481
- json!(entry.stable_qualified_name),
482
- );
483
- }
484
-
485
- fn send_to_logical_to(args: &SendArgs, logical_to: &str, content: &str) -> Result<Value, CliError> {
486
- let names = logical_to
487
- .split(',')
488
- .map(str::trim)
489
- .filter(|name| !name.is_empty())
490
- .collect::<Vec<_>>();
491
- if names.is_empty() || names.len() != logical_to.split(',').count() {
492
- return Err(CliError::Usage(
493
- "logical TO comma-list contains an empty recipient".to_string(),
494
- ));
495
- }
496
-
497
- let mut resolved = Vec::with_capacity(names.len());
498
- for name in names {
499
- match crate::cli::named_address::resolve_name_for_cli(
500
- &args.workspace,
501
- name,
502
- args.team.as_deref(),
503
- ) {
504
- Ok((recipient, _transport)) => resolved.push(recipient),
505
- Err(mut error) => {
506
- adapt_positional_bare_error(args, name, &mut error);
507
- if matches!(
508
- error.kind,
509
- crate::cli::named_address::NamedAddressErrorKind::StateNotFound
510
- ) {
511
- return Ok(resolution_refusal_json(
512
- &error,
513
- logical_to,
514
- content,
515
- args,
516
- ));
517
- }
518
- if resolved.is_empty() && !logical_to.contains(',') {
519
- if let Some(value) = maybe_enqueue_offline_leader_mailbox(
520
- &args.workspace,
521
- name,
522
- content,
523
- args.sender.as_str(),
524
- args.task.as_deref(),
525
- &error,
526
- )? {
527
- return Ok(value);
528
- }
529
- }
530
- if args.json {
531
- return Ok(error.to_json());
532
- }
533
- return Err(CliError::Usage(error.n38_message()));
534
- }
535
- }
536
- }
537
-
538
- if resolved.len() == 1 {
539
- return send_to_resolved_name(args, &resolved[0], content);
540
- }
541
-
542
- let first = &resolved[0];
543
- let one_scope = resolved.iter().all(|recipient| {
544
- recipient.target_workspace == first.target_workspace && recipient.team_key == first.team_key
545
- });
546
- if one_scope {
547
- let recipients = resolved
548
- .iter()
549
- .map(logical_recipient_id)
550
- .collect::<Result<Vec<_>, _>>()?;
551
- let target = MessageTarget::Fanout(recipients);
552
- return persist_resolved_target(args, first, &target, content);
553
- }
554
-
555
- let mut results = Vec::with_capacity(resolved.len());
556
- for recipient in &resolved {
557
- results.push(send_to_resolved_name(args, recipient, content)?);
558
- }
559
- let ok = results
560
- .iter()
561
- .all(|value| value.get("ok").and_then(Value::as_bool) == Some(true));
562
- let message_id = results
563
- .iter()
564
- .rev()
565
- .find_map(|value| value.get("message_id").and_then(Value::as_str))
566
- .map(str::to_string);
567
- Ok(json!({
568
- "ok": ok,
569
- "status": if ok { "fanout_delivered" } else { "fanout_partial" },
570
- "delivery_status": if ok { "pending" } else { "fanout_partial" },
571
- "delivered": false,
572
- "target": logical_to.split(',').map(str::trim).collect::<Vec<_>>(),
573
- "content_length_bytes": content.len(),
574
- "sender": args.sender,
575
- "message_id": message_id,
576
- "results": results,
577
- }))
578
- }
579
-
580
- fn resolution_refusal_json(
581
- error: &crate::cli::named_address::NamedAddressError,
582
- logical_to: &str,
583
- content: &str,
584
- args: &SendArgs,
585
- ) -> Value {
586
- let mut value = error.to_json();
587
- if let Some(object) = value.as_object_mut() {
588
- object.insert("delivery_status".to_string(), json!("refused"));
589
- object.insert("delivered".to_string(), json!(false));
590
- object.insert("target".to_string(), json!(logical_to));
591
- object.insert("agent_id".to_string(), json!(logical_to));
592
- object.insert("content_length_bytes".to_string(), json!(content.len()));
593
- object.insert("sender".to_string(), json!(args.sender));
594
- object.insert("message_id".to_string(), Value::Null);
595
- object.insert("message_status".to_string(), json!("refused"));
596
- object.insert("verification".to_string(), Value::Null);
597
- object.insert("stage".to_string(), Value::Null);
598
- object.insert("channel".to_string(), Value::Null);
599
- }
600
- value
601
- }
602
-
603
- fn adapt_positional_bare_error(
604
- args: &SendArgs,
605
- name: &str,
606
- error: &mut crate::cli::named_address::NamedAddressError,
607
- ) {
608
- if args.target.as_deref() != Some(name)
609
- || args.team.is_none()
610
- || name.contains('/')
611
- || name.contains(':')
612
- || name.contains(',')
613
- {
614
- return;
615
- }
616
- error.requested_name = Some(name.to_string());
617
- for candidate in &mut error.candidates {
618
- let agent_id = candidate
619
- .get("agent_id")
620
- .and_then(Value::as_str)
621
- .map(str::to_string);
622
- if let (Some(object), Some(agent_id)) = (candidate.as_object_mut(), agent_id) {
623
- object.insert("name".to_string(), json!(agent_id));
624
- }
625
- }
626
- error.suggested_name = error
627
- .suggested_name
628
- .as_deref()
629
- .and_then(|suggested| suggested.rsplit('/').next())
630
- .map(str::to_string);
631
- if let Some(suggested) = error.suggested_name.as_deref() {
632
- error.action = format!("Did you mean `{suggested}`? Retry with `{suggested}` as TO.");
633
- }
634
- }
635
-
636
- fn logical_recipient_id(
637
- resolved: &crate::cli::named_address::ResolvedNamedAddress,
638
- ) -> Result<String, CliError> {
639
- match resolved.target_kind {
640
- crate::cli::named_address::NamedTargetKind::Worker => resolved
641
- .agent_id
642
- .clone()
643
- .ok_or_else(|| CliError::Runtime("resolved worker is missing agent id".to_string())),
644
- crate::cli::named_address::NamedTargetKind::Leader => Ok("leader".to_string()),
645
- crate::cli::named_address::NamedTargetKind::SessionWindow => Err(CliError::Usage(
646
- "named session/window delivery is sunset; use a logical agent or leader name"
647
- .to_string(),
648
- )),
649
- }
650
- }
651
-
652
- fn send_to_resolved_name(
653
- args: &SendArgs,
654
- resolved: &crate::cli::named_address::ResolvedNamedAddress,
655
- content: &str,
656
- ) -> Result<Value, CliError> {
657
- let recipient = logical_recipient_id(resolved)?;
658
- if let Some(warning) = &resolved.warning {
659
- eprintln!("warning: {warning}");
660
- }
661
- let target = MessageTarget::Single(recipient);
662
- let mut value = persist_resolved_target(args, resolved, &target, content)?;
663
- if args.to_name.is_some() || args.to_leader.is_some() {
664
- if let Some(obj) = value.as_object_mut() {
665
- obj.insert("to_name".to_string(), json!(resolved.raw_name));
666
- obj.insert(
667
- "target_workspace".to_string(),
668
- json!(resolved.target_workspace.display().to_string()),
669
- );
670
- obj.insert("team_key".to_string(), json!(resolved.team_key));
671
- }
672
- }
673
- Ok(value)
674
- }
675
-
676
- fn persist_resolved_target(
677
- args: &SendArgs,
678
- resolved: &crate::cli::named_address::ResolvedNamedAddress,
679
- target: &MessageTarget,
680
- content: &str,
681
- ) -> Result<Value, CliError> {
682
- let selected = crate::state::selector::resolve_active_team(
683
- &resolved.target_workspace,
684
- resolved.team_key.as_deref(),
685
- crate::state::selector::SelectorMode::RuntimeOnly,
686
- )?;
687
- if let Some(value) = dirty_topology_refusal_value(&selected, resolved.team_key.as_deref()) {
688
- return Ok(value);
689
- }
690
- let mut opts = send_options_from_args(args);
691
- opts.route_task_id = args.to_name.is_none() && args.to_leader.is_none();
692
- opts.team = Some(TeamKey::new(selected.team_key.clone()));
693
- let coordinator_ensure = if target_has_known_worker(&selected.state, target, opts.sender.as_str())
694
- {
695
- loud_ensure_coordinator(&selected)?
696
- } else {
697
- None
698
- };
699
- if let Some(value) = coordinator_ensure_unavailable_value(
700
- coordinator_ensure.as_ref(),
701
- target,
702
- content,
703
- &opts,
704
- ) {
705
- return Ok(value);
706
- }
707
- let outcome = messaging::send_message(&selected.run_workspace, target, content, &opts)?;
708
- let mut value = delivery_outcome_json(&outcome, target, content, &opts);
709
- append_loud_ensure_fields(&mut value, coordinator_ensure.as_ref());
710
- Ok(value)
711
- }
712
-
713
- fn routing_ambiguous_value(
714
- workspace: &Path,
715
- args: &SendArgs,
716
- target: &MessageTarget,
717
- content: &str,
718
- opts: &SendOptions,
719
- ) -> Option<Value> {
720
- if args.targets.is_some() || !content.is_empty() {
721
- return None;
722
- }
723
- let MessageTarget::Single(name) = target else {
724
- return None;
725
- };
726
- if name.is_empty() {
727
- return None;
728
- }
729
- let state = crate::state::persist::load_runtime_state(workspace).ok()?;
730
- let in_team = state
731
- .get("agents")
732
- .and_then(|v| v.as_object())
733
- .is_some_and(|a| a.contains_key(name));
734
- if in_team {
735
- return None;
736
- }
737
- // aeab1c7 follow-up: `content` is no longer emitted anywhere from `send`
738
- // responses (including this refusal). Replace with `content_length_bytes`
739
- // to keep the size-sanity field consistent with the normal-send shape.
740
- Some(json!({
741
- "ok": false,
742
- "status": "refused",
743
- "target": null,
744
- "agent_id": null,
745
- "content_length_bytes": name.len(),
746
- "sender": opts.sender,
747
- "message_id": null,
748
- "message_status": "refused",
749
- "verification": null,
750
- "stage": null,
751
- "reason": "routing_ambiguous",
752
- "channel": null,
753
- }))
754
- }
755
-
756
- fn selected_state_with_active_key(selected: &crate::state::selector::SelectedTeam) -> Value {
757
- let mut state = selected.state.clone();
758
- if let Some(obj) = state.as_object_mut() {
759
- obj.insert(
760
- "active_team_key".to_string(),
761
- Value::String(selected.team_key.clone()),
762
- );
763
- }
764
- state
765
- }
766
-
767
- fn initial_delivery_allows_watch(status: DeliveryStatus) -> bool {
768
- matches!(
769
- status,
770
- DeliveryStatus::Delivered | DeliveryStatus::AlreadyDelivered
771
- )
772
- }
773
-
774
- fn observe_initial_delivery_for_watch(
775
- selected: &crate::state::selector::SelectedTeam,
776
- target: &MessageTarget,
777
- outcome: &DeliveryOutcome,
778
- opts: &SendOptions,
779
- ) -> Result<DeliveryOutcome, CliError> {
780
- if !matches!(target, MessageTarget::Single(agent) if !agent.is_empty()) {
781
- return Ok(outcome.clone());
782
- }
783
- if !matches!(outcome.status, DeliveryStatus::Queued) {
784
- return Ok(outcome.clone());
785
- }
786
- let Some(message_id) = outcome.message_id.as_deref() else {
787
- return Ok(outcome.clone());
788
- };
789
- let store = crate::message_store::MessageStore::open(&selected.run_workspace)
790
- .map_err(|e| CliError::Runtime(e.to_string()))?;
791
- let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
792
- &selected.run_workspace,
793
- opts.team.as_ref().map(TeamKey::as_str),
794
- )
795
- .map_err(|e| CliError::Runtime(e.to_string()))?;
796
- let event_log = crate::event_log::EventLog::new(&selected.run_workspace);
797
- let state = selected_state_with_active_key(selected);
798
- crate::messaging::delivery::deliver_pending_message(
799
- &selected.run_workspace,
800
- &store,
801
- &transport,
802
- message_id,
803
- &event_log,
804
- &state,
805
- )
806
- .map_err(CliError::from)
807
- }
808
-
809
- /// `_send_target`(`commands.py:181-184`):`--to` comma-split fanout / `target` 单值 / None。
810
- pub fn send_target(targets: Option<&str>, target: Option<&str>) -> MessageTarget {
811
- if let Some(targets) = targets.filter(|s| !s.is_empty()) {
812
- let recipients: Vec<String> = targets
813
- .split(',')
814
- .map(str::trim)
815
- .filter(|s| !s.is_empty())
816
- .map(ToString::to_string)
817
- .collect();
818
- return MessageTarget::Fanout(recipients);
819
- }
820
- match target {
821
- Some("*") => MessageTarget::Broadcast,
822
- Some(target) => MessageTarget::Single(target.to_string()),
823
- None => MessageTarget::Single(String::new()),
824
- }
825
- }
826
-
827
- /// `cmd_send` 的 [`SendArgs`]→[`SendOptions`] 翻译(`commands.py:170-177`)。CLI **独占**的
828
- /// 旗标取反语义(经典 off-by-inversion bug 面):`no_ack→!requires_ack`、`no_wait→!wait_visible`、
829
- /// `watch_result` 直传、`task_id`/`sender`/`confirm_human`/`timeout`/`team` 透传。
830
- /// (其余 `lock_timeout`/`block_until_delivered` 用 [`SendOptions::default`]。)
831
- pub fn send_options_from_args(args: &SendArgs) -> SendOptions {
832
- SendOptions {
833
- task_id: args.task.as_ref().map(|s| TaskId::new(s.clone())),
834
- route_task_id: true,
835
- sender: args.sender.clone(),
836
- requires_ack: !args.no_ack,
837
- confirm_human: args.confirm_human,
838
- wait_visible: !args.no_wait,
839
- timeout: args.timeout,
840
- watch_result: args.watch_result,
841
- team: args.team.as_ref().map(|s| TeamKey::new(s.clone())),
842
- message_id: args.message_id.clone(),
843
- ..SendOptions::default()
844
- }
845
- }
846
-
847
- fn watch_notice_json(target: &MessageTarget, opts: &SendOptions) -> Value {
848
- let agent_id = match target {
849
- MessageTarget::Single(agent) => agent.clone(),
850
- MessageTarget::Broadcast => "*".to_string(),
851
- MessageTarget::Fanout(recipients) => recipients
852
- .first()
853
- .cloned()
854
- .unwrap_or_else(|| "-".to_string()),
855
- };
856
- json!({
857
- "status": "registered",
858
- "watcher_id": format!("watch-{agent_id}"),
859
- "task_id": opts.task_id.as_ref().map(|t| t.as_str().to_string()),
860
- "agent_id": agent_id,
861
- "notice": "Team Agent will collect the result and notify the leader when this task reports completion."
862
- })
863
- }
864
-
865
- /// 0.5.45 naming-addressing (design §3.5, RED-2/RED-3 positional):
866
- /// after `messaging::send_message` refuses with `target_not_in_team`
867
- /// for a Single non-special short id, attach scope-safe advisory
868
- /// suggestions to the outbound JSON envelope. Candidate source =
869
- /// selected team's projected `agents` map (never the raw workspace
870
- /// `teams`) so sibling teams cannot leak. Zero DB write, zero inject
871
- /// — the refusal exit code is unchanged.
872
- fn attach_positional_typo_suggestions(
873
- value: &mut Value,
874
- target: &MessageTarget,
875
- selected_state: &Value,
876
- ) {
877
- use crate::model::name_similarity::{rank, Candidate};
878
- let requested = match target {
879
- MessageTarget::Single(id) if id != "*" && id != "leader" => id.clone(),
880
- _ => return,
881
- };
882
- let Some(obj) = value.as_object_mut() else {
883
- return;
884
- };
885
- if obj.get("reason").and_then(Value::as_str) != Some("target_not_in_team") {
886
- return;
887
- }
888
- let team_key = selected_state
889
- .get("active_team_key")
890
- .or_else(|| selected_state.get("team_key"))
891
- .and_then(Value::as_str)
892
- .unwrap_or("");
893
- let candidates: Vec<Candidate<String>> = selected_state
894
- .get("agents")
895
- .and_then(Value::as_object)
896
- .map(|agents| {
897
- agents
898
- .keys()
899
- .map(|agent_id| Candidate {
900
- match_key: agent_id.clone(),
901
- stable_key: agent_id.clone(),
902
- payload: agent_id.clone(),
903
- })
904
- .collect()
905
- })
906
- .unwrap_or_default();
907
- let ranked = rank(&requested, &candidates);
908
- let candidate_values: Vec<Value> = ranked
909
- .iter()
910
- .map(|agent_id| {
911
- json!({
912
- "name": agent_id,
913
- "team_key": team_key,
914
- "agent_id": agent_id,
915
- "advisory": true,
916
- })
917
- })
918
- .collect();
919
- obj.insert("requested_name".to_string(), json!(requested));
920
- if let Some(best) = ranked.first() {
921
- obj.insert("suggested_name".to_string(), json!(best));
922
- }
923
- obj.insert("candidates".to_string(), Value::Array(candidate_values));
924
- }
925
-
926
- fn delivery_outcome_json(
927
- outcome: &DeliveryOutcome,
928
- target: &MessageTarget,
929
- content: &str,
930
- opts: &SendOptions,
931
- ) -> Value {
932
- // Pre-release 0.4.0 user directive: send result MUST NOT carry the
933
- // message body — neither in human form (cli/emit.rs) NOR in --json.
934
- // External consumers who need the message content read it via `inbox`,
935
- // not from the send response. We surface `content_length_bytes` as a
936
- // size sanity field so callers can verify the body size they intended
937
- // to send arrived intact without exposing the body itself.
938
- let target_wire = target_json(target);
939
- json!({
940
- "ok": outcome.ok,
941
- "status": delivery_status_wire(outcome.status),
942
- "delivery_status": api_delivery_status(outcome),
943
- "delivered": delivery_proven(outcome.status),
944
- "target": target_wire,
945
- "agent_id": first_target(target),
946
- "content_length_bytes": content.len(),
947
- "sender": opts.sender,
948
- "message_id": outcome.message_id,
949
- "message_status": outcome.message_status.0,
950
- "verification": outcome.verification,
951
- "stage": outcome.stage.map(delivery_stage_wire),
952
- "reason": outcome.reason.map(delivery_refusal_wire),
953
- "channel": outcome.channel,
954
- })
955
- }
956
-
957
- fn api_delivery_status(outcome: &DeliveryOutcome) -> &'static str {
958
- if delivery_proven(outcome.status) {
959
- return "delivered";
960
- }
961
- if matches!(outcome.status, DeliveryStatus::Queued) && outcome.message_status.0 == "accepted" {
962
- return "pending";
963
- }
964
- delivery_status_wire(outcome.status)
965
- }
966
-
967
- fn delivery_proven(status: DeliveryStatus) -> bool {
968
- matches!(
969
- status,
970
- DeliveryStatus::Delivered
971
- | DeliveryStatus::AlreadyDelivered
972
- | DeliveryStatus::BroadcastDelivered
973
- | DeliveryStatus::FanoutDelivered
974
- )
975
- }
976
-
977
- fn add_send_reminder_if_ok(value: &mut Value) {
978
- if value.get("ok").and_then(Value::as_bool) != Some(true) {
979
- return;
980
- }
981
- let reminder = send_reminder_for_value(value);
982
- if let Some(obj) = value.as_object_mut() {
983
- obj.insert("reminder".to_string(), json!(reminder));
984
- }
985
- }
986
-
987
- /// E6 (0.5.9 offline-mailbox-toname-design §§3.1/6.2/8, real-machine
988
- /// escape evidence
989
- /// `.team/artifacts/0.5.9-subscription-gate.md` +
990
- /// `.team/evidence/0.5.9-subscription-gate-20260707T143241Z-4645/`):
991
- /// when the `--to-name <ws>::<team>/leader` resolver refused with
992
- /// `leader_not_attached`, decide whether the target team is still alive
993
- /// (worker + coordinator running without a bound leader) and, if so,
994
- /// enqueue the mailbox row so `attach-leader` replays it exactly once.
995
- ///
996
- /// Only queues for third-party senders (sender workspace ≠ target
997
- /// workspace). Owner-scope refusals stay refused so status/diagnose can
998
- /// keep pushing the operator toward `attach-leader`.
999
- fn maybe_enqueue_offline_leader_mailbox(
1000
- sender_workspace: &Path,
1001
- to_name: &str,
1002
- content: &str,
1003
- sender: &str,
1004
- task_id: Option<&str>,
1005
- error: &crate::cli::named_address::NamedAddressError,
1006
- ) -> Result<Option<Value>, CliError> {
1007
- if error.kind != crate::cli::named_address::NamedAddressErrorKind::LeaderNotAttached {
1008
- return Ok(None);
1009
- }
1010
- let parsed = match crate::cli::named_address::parse_leader_target_workspace_and_team(
1011
- sender_workspace,
1012
- to_name,
1013
- ) {
1014
- Ok(Some(v)) => v,
1015
- Ok(None) => return Ok(None),
1016
- Err(_) => return Ok(None),
1017
- };
1018
- let (target_workspace, team_key) = parsed;
1019
- // Owner-scope refusal: sender workspace == target workspace. Keep
1020
- // the actionable attach hint (owner sees status/diagnose copy that
1021
- // points at `attach-leader`).
1022
- let sender_canonical =
1023
- std::fs::canonicalize(sender_workspace).unwrap_or_else(|_| sender_workspace.to_path_buf());
1024
- let target_canonical =
1025
- std::fs::canonicalize(&target_workspace).unwrap_or_else(|_| target_workspace.clone());
1026
- if sender_canonical == target_canonical {
1027
- return Ok(None);
1028
- }
1029
- // Verify the target team is actually alive on this host — mailbox
1030
- // is only for `team live + leader unattached`. Fail-closed otherwise
1031
- // so we never leave a message in a permanently-dead workspace's DB.
1032
- let state = match crate::state::persist::load_runtime_state(&target_workspace) {
1033
- Ok(s) => s,
1034
- Err(_) => return Ok(None),
1035
- };
1036
- let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
1037
- if !team_alive {
1038
- return Ok(None);
1039
- }
1040
- let event_log = crate::event_log::EventLog::new(&target_workspace);
1041
- let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
1042
- let outcome = messaging::enqueue_leader_mailbox_until_attach(
1043
- &target_workspace,
1044
- &team_key,
1045
- content,
1046
- task.as_ref(),
1047
- sender,
1048
- &event_log,
1049
- )
1050
- .map_err(|e| CliError::Runtime(e.to_string()))?;
1051
- let message_id = outcome.message_id.clone().unwrap_or_else(|| "".to_string());
1052
- Ok(Some(json!({
1053
- "ok": true,
1054
- "status": "queued_until_leader_attach",
1055
- "message_status": "queued_until_leader_attach",
1056
- "channel": "leader_mailbox",
1057
- "delivered": false,
1058
- "to_name": to_name,
1059
- "target_workspace": target_workspace.display().to_string(),
1060
- "team_key": team_key,
1061
- "recipient": "leader",
1062
- "leader_attached": false,
1063
- "message_id": message_id,
1064
- })))
1065
- }
1066
-
1067
- /// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
1068
- /// - target workspace has state and the team key is present + not archived/down;
1069
- /// - AND at least one live tmux fact — a persisted `session_name` OR any
1070
- /// agent with a recorded pane on the recorded socket.
1071
- ///
1072
- /// We deliberately do NOT poll coordinator health here — enqueuing is
1073
- /// safe even when the coordinator is transiently down; attach-leader
1074
- /// itself replays via `requeue_blocked_leader_messages` regardless.
1075
- fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
1076
- let team = state
1077
- .get("teams")
1078
- .and_then(|v| v.as_object())
1079
- .and_then(|teams| teams.get(team_key));
1080
- let Some(team) = team else {
1081
- return false;
1082
- };
1083
- let status = team
1084
- .get("status")
1085
- .and_then(|v| v.as_str())
1086
- .unwrap_or("alive");
1087
- if matches!(status, "archived" | "down" | "stopped") {
1088
- return false;
1089
- }
1090
- // A recorded session_name is enough — target's coordinator/attach
1091
- // path will re-verify tmux presence when the replay fires.
1092
- team.get("session_name")
1093
- .and_then(|v| v.as_str())
1094
- .is_some_and(|s| !s.is_empty())
1095
- }
1096
-
1097
- fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
1098
- let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
1099
- ExitCode::Error
1100
- } else {
1101
- ExitCode::Ok
1102
- };
1103
- if as_json {
1104
- CmdResult::from_json(value, true)
1105
- } else {
1106
- CmdResult {
1107
- output: CmdOutput::Human(send_human_output(&value)),
1108
- exit,
1109
- as_json: false,
1110
- }
1111
- }
1112
- }
1113
-
1114
- fn send_human_output(value: &Value) -> String {
1115
- let mut parts = vec![
1116
- send_human_field(value, "ok"),
1117
- format!("status: {}", send_human_status(value)),
1118
- send_human_field(value, "message_id"),
1119
- format!("target: {}", send_human_target(value)),
1120
- ];
1121
- for key in ["verification", "stage", "reason", "channel"] {
1122
- if !value.get(key).is_none_or(Value::is_null) {
1123
- parts.push(send_human_field(value, key));
1124
- }
1125
- }
1126
- // 0.5.45 naming-addressing (design §3.4/§3.5, RED-3 positional):
1127
- // when the refusal envelope carries a scope-safe suggestion,
1128
- // surface it verbatim in human output so users can copy the
1129
- // right short id. `requested_name` echoes the typo, `suggested_
1130
- // name` is the copyable canonical.
1131
- if let Some(requested) = value
1132
- .get("requested_name")
1133
- .and_then(Value::as_str)
1134
- .filter(|s| !s.is_empty())
1135
- {
1136
- parts.push(format!("requested_name: {requested}"));
1137
- }
1138
- if let Some(suggested) = value
1139
- .get("suggested_name")
1140
- .and_then(Value::as_str)
1141
- .filter(|s| !s.is_empty())
1142
- {
1143
- parts.push(format!("Did you mean `{suggested}`? suggested_name: {suggested}"));
1144
- }
1145
- parts.join(" ")
1146
- }
1147
-
1148
- fn send_human_field(value: &Value, key: &str) -> String {
1149
- let rendered = value
1150
- .get(key)
1151
- .map(send_human_value)
1152
- .unwrap_or_else(|| "None".to_string());
1153
- format!("{key}: {rendered}")
1154
- }
1155
-
1156
- fn send_human_target(value: &Value) -> String {
1157
- ["target", "agent_id", "pane_id", "to_name"]
1158
- .iter()
1159
- .find_map(|key| value.get(*key).filter(|v| !v.is_null()))
1160
- .map(send_human_value)
1161
- .unwrap_or_else(|| "None".to_string())
1162
- }
1163
-
1164
- fn send_human_status(value: &Value) -> String {
1165
- value
1166
- .get("status")
1167
- .map(send_human_value)
1168
- .unwrap_or_else(|| {
1169
- if value.get("ok").and_then(Value::as_bool) == Some(true) {
1170
- "delivered".to_string()
1171
- } else {
1172
- "failed".to_string()
1173
- }
1174
- })
1175
- }
1176
-
1177
- fn send_human_value(value: &Value) -> String {
1178
- let text = match value {
1179
- Value::Null => "None".to_string(),
1180
- Value::Bool(true) => "True".to_string(),
1181
- Value::Bool(false) => "False".to_string(),
1182
- Value::Number(n) => n.to_string(),
1183
- Value::String(s) => s.clone(),
1184
- Value::Array(_) | Value::Object(_) => {
1185
- serde_json::to_string(value).unwrap_or_else(|_| "None".to_string())
1186
- }
1187
- };
1188
- text.replace(['\r', '\n'], " ")
1189
- }
1190
-
1191
- fn send_reminder_for_value(value: &Value) -> &'static str {
1192
- let delivered = value.get("delivered").and_then(Value::as_bool);
1193
- let status = value.get("status").and_then(Value::as_str);
1194
- let delivery_status = value.get("delivery_status").and_then(Value::as_str);
1195
- if delivered == Some(false)
1196
- || matches!(status, Some("queued"))
1197
- || matches!(delivery_status, Some("pending"))
1198
- {
1199
- "Message queued; coordinator will notify when the worker receives it. Do not poll the worker terminal with capture-pane."
1200
- } else {
1201
- crate::cli::SEND_REMINDER
1202
- }
1203
- }
1204
-
1205
- fn target_json(target: &MessageTarget) -> Value {
1206
- match target {
1207
- MessageTarget::Single(agent) => json!(agent),
1208
- MessageTarget::Broadcast => json!("*"),
1209
- MessageTarget::Fanout(recipients) => json!(recipients),
1210
- }
1211
- }
1212
-
1213
- fn first_target(target: &MessageTarget) -> String {
1214
- match target {
1215
- MessageTarget::Single(agent) => agent.clone(),
1216
- MessageTarget::Broadcast => "*".to_string(),
1217
- MessageTarget::Fanout(recipients) => recipients.first().cloned().unwrap_or_default(),
1218
- }
1219
- }
1220
-
1221
- fn delivery_status_wire(status: DeliveryStatus) -> &'static str {
1222
- match status {
1223
- DeliveryStatus::Delivered => "delivered",
1224
- DeliveryStatus::Failed => "failed",
1225
- DeliveryStatus::Queued => "queued",
1226
- DeliveryStatus::Blocked => "blocked",
1227
- DeliveryStatus::Refused => "refused",
1228
- DeliveryStatus::Degraded => "degraded",
1229
- DeliveryStatus::RetryScheduled => "retry_scheduled",
1230
- DeliveryStatus::TrustAutoAnswerExhausted => "trust_auto_answer_exhausted",
1231
- DeliveryStatus::AlreadyDelivered => "already_delivered",
1232
- DeliveryStatus::FallbackLog => "fallback_log",
1233
- DeliveryStatus::BroadcastDelivered => "broadcast_delivered",
1234
- DeliveryStatus::BroadcastPartial => "broadcast_partial",
1235
- DeliveryStatus::FanoutDelivered => "fanout_delivered",
1236
- DeliveryStatus::FanoutPartial => "fanout_partial",
1237
- }
1238
- }
1239
-
1240
- fn delivery_refusal_wire(reason: DeliveryRefusal) -> &'static str {
1241
- match reason {
1242
- DeliveryRefusal::TargetNotInTeam => "target_not_in_team",
1243
- DeliveryRefusal::HumanConfirmationRequired => "human_confirmation_required",
1244
- DeliveryRefusal::MissingPermissions => "missing_permissions",
1245
- DeliveryRefusal::RecipientBusy => "recipient_busy",
1246
- DeliveryRefusal::UnknownRecipient => "unknown_recipient",
1247
- DeliveryRefusal::TmuxTargetMissing => "tmux_target_missing",
1248
- DeliveryRefusal::MessageAlreadyClaimed => "message_already_claimed",
1249
- DeliveryRefusal::LeaderNotAttached => "leader_not_attached",
1250
- DeliveryRefusal::CoordinatorUnavailable => "coordinator_unavailable",
1251
- DeliveryRefusal::NoCallerPane => "no_caller_pane",
1252
- DeliveryRefusal::TeamOwnerMismatch => "team_owner_mismatch",
1253
- DeliveryRefusal::Ambiguous => "ambiguous",
1254
- DeliveryRefusal::RecipientPaneInNonInputMode => "recipient_pane_in_non_input_mode",
1255
- DeliveryRefusal::SessionDrift => "session_drift",
1256
- DeliveryRefusal::Duplicate => "duplicate",
1257
- DeliveryRefusal::RoutingAmbiguous => "routing_ambiguous",
1258
- DeliveryRefusal::EmptyTargetList => "empty_target_list",
1259
- }
1260
- }
1261
-
1262
- fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
1263
- match stage {
1264
- DeliveryStage::TrustAutoAnswerDismissalWait => "trust_auto_answer_dismissal_wait",
1265
- DeliveryStage::Inject => "inject",
1266
- DeliveryStage::Submit => "submit",
1267
- DeliveryStage::VisibleCheck => "visible_check",
1268
- }
1269
- }
1270
-
1271
- /// E7 (0.5.9 host-leader-registry-design §8.3): resolve `NAME` through
1272
- /// `~/.team-agent/leaders`, then delegate to the E6 leader delivery path
1273
- /// so a resolved live target physically injects and a leader-not-attached
1274
- /// target queues via `enqueue_leader_mailbox_until_attach`. Ambiguous
1275
- /// short names refuse with `name_ambiguous` and expose `candidates` —
1276
- /// no priority heuristic ever picks a winner (host-leader-registry-design §5.2).
1277
- ///
1278
- /// Return shape reserves the following markers for downstream consumers:
1279
- /// - `resolved_via = "host_leader_registry"` when a registry entry
1280
- /// selected the canonical target (E7 test 2).
1281
- /// - `reason = "leader_name_not_found"` for missing entries; `reason =
1282
- /// "registry_stale"` when canonical validation refuses; `reason =
1283
- /// "name_ambiguous"` for collisions with a candidate list including
1284
- /// `workspace_hash` and `stable_qualified_name`.
1285
- ///
1286
- /// The first slice ships the marker/return-shape surface so E6 wiring is
1287
- /// available at the CLI; the full canonical-validate loop follows in a
1288
- /// later commit alongside the registry read implementation.
144
+ /// Resolve a host-leader alias, send through the canonical persisted funnel,
145
+ /// then decorate the already-produced result for the host-leader surface.
1289
146
  pub fn send_to_canonical_leader_target(
1290
147
  sender_workspace: &std::path::Path,
1291
148
  name: &str,
@@ -1320,3 +177,21 @@ pub fn send_to_canonical_leader_target(
1320
177
  decorate_host_leader_alias(&mut value, &entry);
1321
178
  Ok(value)
1322
179
  }
180
+
181
+ /// `_send_target`(`commands.py:181-184`):`--to` comma-split fanout / `target` 单值 / None。
182
+ pub fn send_target(targets: Option<&str>, target: Option<&str>) -> MessageTarget {
183
+ if let Some(targets) = targets.filter(|s| !s.is_empty()) {
184
+ let recipients: Vec<String> = targets
185
+ .split(',')
186
+ .map(str::trim)
187
+ .filter(|s| !s.is_empty())
188
+ .map(ToString::to_string)
189
+ .collect();
190
+ return MessageTarget::Fanout(recipients);
191
+ }
192
+ match target {
193
+ Some("*") => MessageTarget::Broadcast,
194
+ Some(target) => MessageTarget::Single(target.to_string()),
195
+ None => MessageTarget::Single(String::new()),
196
+ }
197
+ }