@team-agent/installer 0.5.49 → 0.5.50

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,133 +7,63 @@ 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()
10
+ // F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
11
+ // Mutually exclusive with target / --to (agent-name routing).
12
+ if let Some(ref pane_id) = args.pane {
13
+ warn_send_alias("--pane");
14
+ if args.target.is_some()
21
15
  || args.targets.is_some()
16
+ || args.to_name.is_some()
17
+ || args.to_leader.is_some()
22
18
  {
19
+ let message = if args.to_name.is_some() {
20
+ "--to-name and --pane/TARGET/--to are mutually exclusive"
21
+ } else {
22
+ "--pane and TARGET/--to are mutually exclusive; --pane also conflicts with --to-leader"
23
+ };
23
24
  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(),
25
+ message.to_string(),
27
26
  ));
28
27
  }
29
28
  let content = args.message.join(" ");
30
29
  if content.is_empty() {
31
30
  return Err(CliError::Usage(
32
- "--to-leader requires a non-empty message".to_string(),
31
+ "--pane requires a non-empty message".to_string(),
33
32
  ));
34
33
  }
35
- let value = send_to_canonical_leader_target(
36
- &args.workspace,
37
- to_leader,
38
- &content,
39
- &args.sender,
40
- args.task.as_deref(),
41
- )?;
42
- return Ok(cmd_send_result(value, args.json));
34
+ return Err(CliError::Usage(format!(
35
+ "--pane {pane_id} is deprecated and sunset; use a logical TARGET so the message is persisted before delivery"
36
+ )));
43
37
  }
44
- if let Some(ref to_name) = args.to_name {
45
- if args.pane.is_some() || args.target.is_some() || args.targets.is_some() {
46
- return Err(CliError::Usage(
47
- "--to-name and --pane/TARGET/--to are mutually exclusive: \
48
- --to-name resolves a stable workspace/team/name to a live pane"
49
- .to_string(),
50
- ));
51
- }
52
- let content = args.message.join(" ");
53
- if content.is_empty() {
54
- return Err(CliError::Usage(
55
- "--to-name requires a non-empty message".to_string(),
56
- ));
57
- }
58
- // 0.5.45 naming-addressing (design §3.1, RED-1): thread
59
- // `--team` down to resolver so bare `--to-name agent --team T`
60
- // scopes to `T` BEFORE workspace scanning. Qualified addresses
61
- // (`team/agent`, `workspace::team/agent`) ignore the scope
62
- // per §1 priority ladder.
63
- let (resolved, transport) =
64
- match crate::cli::named_address::resolve_name_for_cli(
65
- &args.workspace,
66
- to_name,
67
- args.team.as_deref(),
68
- ) {
69
- Ok(resolved) => resolved,
70
- Err(error) => {
71
- // E6 (0.5.9 offline-mailbox-toname-design §3.1/§6.2): when
72
- // the resolver refuses with `leader_not_attached`, the team
73
- // itself may still be alive (worker + coordinator running
74
- // without a bound leader). Third-party senders in that
75
- // shape must land in the offline mailbox — same
76
- // canonical team.db row + queued_until_leader_attach
77
- // status the coordinator/attach hook replay through the
78
- // existing pipeline exactly once. Owner-scope refusals
79
- // (same workspace as target) keep the actionable attach
80
- // hint — E6 owner copy is documented as
81
- // `run team-agent attach-leader`.
82
- if let Some(mut value) = maybe_enqueue_offline_leader_mailbox(
83
- &args.workspace,
84
- to_name,
85
- &content,
86
- &args.sender,
87
- args.task.as_deref(),
88
- &error,
89
- )? {
90
- add_send_reminder_if_ok(&mut value);
91
- return Ok(cmd_send_result(value, args.json));
92
- }
93
- if args.json {
94
- return Ok(CmdResult::from_json(error.to_json(), args.json));
95
- }
96
- return Err(CliError::Usage(error.n38_message()));
97
- }
98
- };
99
- let mut value = send_to_named_pane_direct(
100
- &args.workspace,
101
- transport.as_ref(),
102
- &resolved,
103
- &content,
104
- &args.sender,
105
- args.task.as_deref(),
106
- args.json,
107
- )?;
108
- add_send_reminder_if_ok(&mut value);
109
- return Ok(cmd_send_result(value, args.json));
38
+ if args.targets.is_some() {
39
+ warn_send_alias("--targets");
110
40
  }
111
- // F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
112
- // Mutually exclusive with target / --to (agent-name routing).
113
- if let Some(ref pane_id) = args.pane {
114
- if args.target.is_some() || args.targets.is_some() {
115
- return Err(CliError::Usage(
116
- "--pane and TARGET/--to are mutually exclusive: \
117
- --pane bypasses agent-name routing and injects directly into the \
118
- specified tmux pane (cross-team capable)"
119
- .to_string(),
120
- ));
41
+ if args.to_name.is_some() {
42
+ warn_send_alias("--to-name");
43
+ }
44
+ if args.to_leader.is_some() {
45
+ warn_send_alias("--to-leader");
46
+ }
47
+ let host_leader_alias = if let Some(name) = args.to_leader.as_deref() {
48
+ match resolve_host_leader_alias(name) {
49
+ Ok(resolved) => Some(resolved),
50
+ Err(value) => return Ok(cmd_send_result(value, args.json)),
121
51
  }
122
- let content = args.message.join(" ");
123
- if content.is_empty() {
124
- return Err(CliError::Usage(
125
- "--pane requires a non-empty message".to_string(),
126
- ));
52
+ } else {
53
+ None
54
+ };
55
+ let logical_to = logical_to_from_args(
56
+ args,
57
+ host_leader_alias
58
+ .as_ref()
59
+ .map(|(logical_to, _)| logical_to.as_str()),
60
+ )?;
61
+ let content = args.message.join(" ");
62
+ if !logical_to.is_empty() && logical_to != "*" && !content.is_empty() {
63
+ let mut value = send_to_logical_to(args, &logical_to, &content)?;
64
+ if let Some((_, entry)) = host_leader_alias.as_ref() {
65
+ decorate_host_leader_alias(&mut value, entry);
127
66
  }
128
- let mut value = send_to_pane_direct(
129
- &args.workspace,
130
- pane_id,
131
- &content,
132
- &args.sender,
133
- args.task.as_deref(),
134
- args.team.as_deref(),
135
- args.json,
136
- )?;
137
67
  add_send_reminder_if_ok(&mut value);
138
68
  return Ok(cmd_send_result(value, args.json));
139
69
  }
@@ -142,13 +72,12 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
142
72
  args.team.as_deref(),
143
73
  crate::state::selector::SelectorMode::RuntimeOnly,
144
74
  )?;
145
- let target = send_target(args.targets.as_deref(), args.target.as_deref());
75
+ let target = send_target(None, Some(logical_to.as_str()));
146
76
  let mut opts = send_options_from_args(args);
147
77
  // `args.team` is a selector and may be a legacy session/team-dir alias.
148
78
  // All downstream membership and DB scope must use the canonical key that
149
79
  // resolve_active_team returned, never the original selector spelling.
150
80
  opts.team = Some(TeamKey::new(selected.team_key.clone()));
151
- let content = args.message.join(" ");
152
81
  // CR-061/N27 routing-ambiguous: a single positional with no `--to`/`--targets` and an
153
82
  // empty message body is a prompt-only invocation (`team-agent send "fix the build"`).
154
83
  // The lone positional is CONTENT, not a target — reject with `routing_ambiguous`
@@ -161,11 +90,12 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
161
90
  if let Some(value) = dirty_topology_refusal_value(&selected, args.team.as_deref()) {
162
91
  return Ok(cmd_send_result(value, args.json));
163
92
  }
164
- let coordinator_ensure = if target_has_known_worker(&selected.state, &target, &opts.sender) {
165
- loud_ensure_coordinator(&selected)?
166
- } else {
167
- None
168
- };
93
+ let coordinator_ensure =
94
+ if target_has_known_worker(&selected.state, &target, opts.sender.as_str()) {
95
+ loud_ensure_coordinator(&selected)?
96
+ } else {
97
+ None
98
+ };
169
99
  if let Some(value) =
170
100
  coordinator_ensure_unavailable_value(coordinator_ensure.as_ref(), &target, &content, &opts)
171
101
  {
@@ -391,253 +321,393 @@ fn coordinator_health_status_wire(
391
321
  }
392
322
  }
393
323
 
394
- /// F1 (0.3.26): direct pane-id send — bypasses agent-name routing + team
395
- /// membership check. Constructs `Target::Pane`, renders the message with
396
- /// the standard protocol block (Team Agent message from sender + token),
397
- /// injects via the selected team's endpoint-local tmux transport, and
398
- /// surfaces the inject report as a JSON result.
399
- ///
400
- /// 0.5.43 debt-sweep (§6.2): the pre-0.5.43 comment overstated the
401
- /// scope. pane_id is inherently endpoint-local —
402
- /// `lifecycle_worker_tmux_backend_for_selected_state` below builds the
403
- /// transport from the SELECTED team's persisted endpoint. For cross-
404
- /// workspace delivery, use `--to-name` / `--to-leader` instead; there
405
- /// is intentionally no `--socket` flag.
406
- fn send_to_pane_direct(
407
- workspace: &Path,
408
- pane_id: &str,
409
- content: &str,
410
- sender: &str,
411
- task_id: Option<&str>,
412
- team: Option<&str>,
413
- json: bool,
414
- ) -> Result<serde_json::Value, CliError> {
415
- use crate::messaging::delivery::render_message;
416
- use crate::transport::{InjectPayload, Key, PaneId, Target};
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
+ }
417
332
 
418
- let message_id = format!("pane_send_{}", chrono::Utc::now().timestamp_millis());
419
- let rendered = render_message(sender, task_id, content, &message_id);
420
- let target = Target::Pane(PaneId::new(pane_id));
421
- let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
422
- .unwrap_or_else(|_| workspace.to_path_buf());
423
- let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
424
- &run_workspace,
425
- team,
426
- )
427
- .unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(&run_workspace));
428
- let event_log = crate::event_log::EventLog::new(&run_workspace);
429
- // Warn if the pane is not in the team's known agents (cross-team usage).
430
- let state = crate::state::persist::load_runtime_state(&run_workspace).ok();
431
- let in_team = state
432
- .as_ref()
433
- .and_then(|s| s.get("agents"))
434
- .and_then(serde_json::Value::as_object)
435
- .is_some_and(|agents| {
436
- agents.values().any(|agent| {
437
- agent
438
- .get("pane_id")
439
- .and_then(serde_json::Value::as_str)
440
- .is_some_and(|p| p == pane_id)
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
+ })
441
419
  })
442
- });
443
- if !in_team {
444
- eprintln!(
445
- "warning: pane {pane_id} is not in the team's known agents — \
446
- cross-team delivery (F1)"
447
- );
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
+ }));
448
432
  }
449
- let transport: &dyn crate::transport::Transport = &transport;
450
- let report = transport
451
- .inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
452
- .map_err(|e| CliError::Runtime(format!("inject to pane {pane_id} failed: {e}")))?;
453
- let _ = event_log.write(
454
- "send.pane_direct",
455
- serde_json::json!({
456
- "pane_id": pane_id,
457
- "sender": sender,
458
- "message_id": message_id,
459
- "submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
460
- "inject_verification": format!("{:?}", report.inject_verification),
461
- "in_team": in_team,
462
- }),
463
- );
464
- let ok = matches!(
465
- report.submit_verification,
466
- crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck
467
- | crate::transport::SubmitVerification::PastedContentPromptAbsentAfterSubmit
468
- | crate::transport::SubmitVerification::KeySentAfterVisibleToken { .. }
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),
469
482
  );
470
- Ok(serde_json::json!({
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!({
471
568
  "ok": ok,
472
- "pane_id": pane_id,
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,
473
575
  "message_id": message_id,
474
- "submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
475
- "inject_verification": format!("{:?}", report.inject_verification),
476
- "in_team": in_team,
576
+ "results": results,
477
577
  }))
478
578
  }
479
579
 
480
- fn send_to_named_pane_direct(
481
- sender_workspace: &Path,
482
- transport: &dyn crate::transport::Transport,
483
- resolved: &crate::cli::named_address::ResolvedNamedAddress,
580
+ fn resolution_refusal_json(
581
+ error: &crate::cli::named_address::NamedAddressError,
582
+ logical_to: &str,
484
583
  content: &str,
485
- sender: &str,
486
- task_id: Option<&str>,
487
- _json: bool,
488
- ) -> Result<serde_json::Value, CliError> {
489
- use crate::messaging::delivery::render_message;
490
- use crate::transport::{InjectPayload, Key, PaneId, Target};
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
+ }
491
602
 
492
- let message_id = format!("named_send_{}", chrono::Utc::now().timestamp_millis());
493
- let rendered = render_message(sender, task_id, content, &message_id);
494
- let sender_run_workspace = crate::model::paths::canonical_run_workspace(sender_workspace)
495
- .unwrap_or_else(|_| sender_workspace.to_path_buf());
496
- let event_log = crate::event_log::EventLog::new(&sender_run_workspace);
497
- if let Some(warning) = &resolved.warning {
498
- eprintln!("warning: {warning}");
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;
499
615
  }
500
- if resolved.transport_kind.as_deref() == Some("codex_app_server") {
501
- return send_to_named_app_server_leader(
502
- &event_log,
503
- resolved,
504
- &message_id,
505
- &rendered,
506
- sender,
507
- );
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
+ }
508
625
  }
509
- let target = Target::Pane(PaneId::new(&resolved.pane_id));
510
- let report = transport
511
- .inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
512
- .map_err(|e| {
513
- CliError::Runtime(format!(
514
- "inject to named target {} pane {} failed: {e}",
515
- resolved.raw_name, resolved.pane_id
516
- ))
517
- })?;
518
- let target_kind = named_target_kind_wire(resolved.target_kind);
519
- let event = serde_json::json!({
520
- "to_name": resolved.raw_name,
521
- "target_kind": target_kind,
522
- "sender": sender,
523
- "sender_workspace": sender_run_workspace.display().to_string(),
524
- "target_workspace": resolved.target_workspace.display().to_string(),
525
- "team_key": resolved.team_key,
526
- "agent_id": resolved.agent_id,
527
- "pane_id": resolved.pane_id,
528
- "session_name": resolved.session_name,
529
- "window_name": resolved.window_name,
530
- "tmux_endpoint": resolved.tmux_endpoint,
531
- "state_pane_id": resolved.state_pane_id,
532
- "state_pane_stale": resolved.state_pane_stale,
533
- "agent_status": resolved.agent_status,
534
- "warning": resolved.warning,
535
- "message_id": message_id,
536
- "submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
537
- "inject_verification": format!("{:?}", report.inject_verification),
538
- });
539
- let _ = event_log.write("send.name_direct", event.clone());
540
- let ok = matches!(
541
- report.submit_verification,
542
- crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck
543
- | crate::transport::SubmitVerification::PastedContentPromptAbsentAfterSubmit
544
- | crate::transport::SubmitVerification::KeySentAfterVisibleToken { .. }
545
- );
546
- let mut value = event;
547
- if let Some(obj) = value.as_object_mut() {
548
- obj.insert("ok".to_string(), serde_json::json!(ok));
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.");
549
633
  }
550
- Ok(value)
551
634
  }
552
635
 
553
- fn send_to_named_app_server_leader(
554
- event_log: &crate::event_log::EventLog,
636
+ fn logical_recipient_id(
555
637
  resolved: &crate::cli::named_address::ResolvedNamedAddress,
556
- message_id: &str,
557
- rendered: &str,
558
- sender: &str,
559
- ) -> Result<serde_json::Value, CliError> {
560
- let receiver = serde_json::json!({
561
- "mode": "codex_app_server",
562
- "transport_kind": "codex_app_server",
563
- "app_server": resolved.app_server.clone().unwrap_or(serde_json::Value::Null),
564
- });
565
- let binding = crate::codex_app_server::binding_from_receiver(&receiver)
566
- .map_err(|error| CliError::Runtime(format!("invalid app-server named leader: {error}")))?;
567
- let target_kind = named_target_kind_wire(resolved.target_kind);
568
- let base_event = serde_json::json!({
569
- "to_name": resolved.raw_name,
570
- "target_kind": target_kind,
571
- "sender": sender,
572
- "sender_workspace": resolved.sender_workspace.display().to_string(),
573
- "target_workspace": resolved.target_workspace.display().to_string(),
574
- "team_key": resolved.team_key,
575
- "agent_id": resolved.agent_id,
576
- "transport_kind": "codex_app_server",
577
- "socket": binding.socket,
578
- "thread_id": binding.thread_id,
579
- "message_id": message_id,
580
- });
581
- match crate::codex_app_server::submit_to_bound_thread(&binding, message_id, rendered) {
582
- Ok(submit) => {
583
- let event = merge_json(
584
- base_event.clone(),
585
- serde_json::json!({
586
- "ok": true,
587
- "turn_id": submit.turn_id,
588
- "turn_status": submit.turn_status,
589
- }),
590
- );
591
- let _ = event_log.write("send.name_app_server", event.clone());
592
- Ok(event)
593
- }
594
- Err(crate::codex_app_server::AppServerError::LeaderBusy(message)) => {
595
- let event = merge_json(
596
- base_event.clone(),
597
- serde_json::json!({
598
- "ok": false,
599
- "status": "retry_scheduled",
600
- "reason": "leader_busy",
601
- "channel": "leader_busy",
602
- "error": message,
603
- }),
604
- );
605
- let _ = event_log.write("send.name_app_server", event.clone());
606
- Ok(event)
607
- }
608
- Err(error) => {
609
- let event = merge_json(
610
- base_event.clone(),
611
- serde_json::json!({
612
- "ok": false,
613
- "status": "refused",
614
- "reason": error.code(),
615
- "channel": "rebind_required",
616
- "error": error.to_string(),
617
- "action": "run team-agent attach-app-server-leader for the target team",
618
- }),
619
- );
620
- let _ = event_log.write("send.name_app_server", event.clone());
621
- Ok(event)
622
- }
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
+ )),
623
649
  }
624
650
  }
625
651
 
626
- fn merge_json(mut left: serde_json::Value, right: serde_json::Value) -> serde_json::Value {
627
- if let (Some(left), Some(right)) = (left.as_object_mut(), right.as_object()) {
628
- for (key, value) in right {
629
- left.insert(key.clone(), value.clone());
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));
630
671
  }
631
672
  }
632
- left
673
+ Ok(value)
633
674
  }
634
675
 
635
- fn named_target_kind_wire(kind: crate::cli::named_address::NamedTargetKind) -> &'static str {
636
- match kind {
637
- crate::cli::named_address::NamedTargetKind::Worker => "worker",
638
- crate::cli::named_address::NamedTargetKind::Leader => "leader",
639
- crate::cli::named_address::NamedTargetKind::SessionWindow => "session_window",
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);
640
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)
641
711
  }
642
712
 
643
713
  fn routing_ambiguous_value(
@@ -1220,183 +1290,33 @@ pub fn send_to_canonical_leader_target(
1220
1290
  sender_workspace: &std::path::Path,
1221
1291
  name: &str,
1222
1292
  content: &str,
1223
- sender: &str,
1293
+ sender: &TrustedSender,
1224
1294
  task_id: Option<&str>,
1225
1295
  ) -> Result<serde_json::Value, CliError> {
1226
- // Resolve NAME through the registry. Ambiguity is decided *before*
1227
- // canonical validation so an ambiguous short name never picks a
1228
- // winner even when only one candidate happens to be live. Send
1229
- // uses the no-GC listing so stale entries can still refuse with
1230
- // `registry_stale` the leaders CLI is the one that prunes.
1231
- let classified = crate::leader::registry::list_validated_no_gc();
1232
- let mut candidates_all: Vec<crate::leader::registry::LeaderRegistryEntry> = Vec::new();
1233
- for (entry, _status, _reason) in &classified {
1234
- let matches = entry.delivery_name == name
1235
- || entry.qualified_name == name
1236
- || entry.stable_qualified_name == name
1237
- || entry.aliases.iter().any(|a| a == name);
1238
- if matches {
1239
- candidates_all.push(entry.clone());
1240
- }
1241
- }
1242
- if candidates_all.is_empty() {
1243
- return Ok(serde_json::json!({
1244
- "ok": false,
1245
- "status": "refused",
1246
- "reason": "leader_name_not_found",
1247
- "requested_name": name,
1248
- "resolved_via": "host_leader_registry",
1249
- "candidates": Vec::<serde_json::Value>::new(),
1250
- "workspace_hash": null,
1251
- "stable_qualified_name": null,
1252
- "channel": "leader_mailbox",
1253
- "delivered": false,
1254
- "message_status": "queued_until_leader_attach",
1255
- "action": "run `team-agent leaders` to see registered leaders; inspect queued leader messages with `team-agent inbox`; retry with a qualified name",
1256
- "registry_stale": false,
1257
- }));
1258
- }
1259
- if candidates_all.len() > 1 {
1260
- let cand_json: Vec<serde_json::Value> = candidates_all
1261
- .iter()
1262
- .map(|e| {
1263
- serde_json::json!({
1264
- "name": e.qualified_name,
1265
- "workspace": e.workspace.display().to_string(),
1266
- "team_key": e.team_key,
1267
- "workspace_hash": e.workspace_hash,
1268
- "stable_qualified_name": e.stable_qualified_name,
1269
- })
1270
- })
1271
- .collect();
1272
- return Ok(serde_json::json!({
1273
- "ok": false,
1274
- "status": "refused",
1275
- "reason": "name_ambiguous",
1276
- "requested_name": name,
1277
- "resolved_via": "host_leader_registry",
1278
- "candidates": cand_json,
1279
- "channel": "leader_mailbox",
1280
- "delivered": false,
1281
- "action": "run `team-agent leaders` and retry with the qualified name",
1282
- }));
1283
- }
1284
- let entry = candidates_all.into_iter().next().ok_or_else(|| {
1285
- CliError::Runtime("internal: candidate list must have at least one entry".to_string())
1286
- })?;
1287
- // Canonical-validate the entry against target workspace state. Send
1288
- // through the same E6 --to-name path so live inject and mailbox both
1289
- // funnel through one code path.
1290
- let (status, reason) = crate::leader::registry::classify(&entry);
1291
- if status == "STALE" {
1292
- // Check whether the underlying team is still alive — if so we
1293
- // may still queue via the E6 mailbox path (leader-not-attached
1294
- // shape). If the workspace/team is gone we refuse `registry_stale`.
1295
- let state = crate::state::persist::load_runtime_state(&entry.workspace).ok();
1296
- let team_alive = state
1297
- .as_ref()
1298
- .and_then(|s| s.get("teams"))
1299
- .and_then(|v| v.as_object())
1300
- .and_then(|teams| teams.get(&entry.team_key))
1301
- .and_then(|t| t.get("status"))
1302
- .and_then(serde_json::Value::as_str)
1303
- .map(|s| s == "alive" || s.is_empty())
1304
- .unwrap_or(false);
1305
- if !team_alive {
1306
- return Ok(serde_json::json!({
1307
- "ok": false,
1308
- "status": "refused",
1309
- "reason": "registry_stale",
1310
- "requested_name": name,
1311
- "resolved_via": "host_leader_registry",
1312
- "stale_reason": reason,
1313
- "workspace_hash": entry.workspace_hash,
1314
- "stable_qualified_name": entry.stable_qualified_name,
1315
- "channel": "leader_mailbox",
1316
- "delivered": false,
1317
- "action": "target team is not alive; run `team-agent leaders` for current state",
1318
- }));
1319
- }
1320
- // Team alive but leader unattached → E6 mailbox.
1321
- let event_log = crate::event_log::EventLog::new(&entry.workspace);
1322
- let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
1323
- let outcome = crate::messaging::enqueue_leader_mailbox_until_attach(
1324
- &entry.workspace,
1325
- &entry.team_key,
1326
- content,
1327
- task.as_ref(),
1328
- sender,
1329
- &event_log,
1330
- )
1331
- .map_err(|e| CliError::Runtime(e.to_string()))?;
1332
- return Ok(serde_json::json!({
1333
- "ok": true,
1334
- "status": "queued_until_leader_attach",
1335
- "message_status": "queued_until_leader_attach",
1336
- "channel": "leader_mailbox",
1337
- "delivered": false,
1338
- "resolved_via": "host_leader_registry",
1339
- "requested_name": name,
1340
- "to_leader": entry.qualified_name,
1341
- "target_workspace": entry.workspace.display().to_string(),
1342
- "workspace_hash": entry.workspace_hash,
1343
- "stable_qualified_name": entry.stable_qualified_name,
1344
- "team_key": entry.team_key,
1345
- "message_id": outcome.message_id,
1346
- }));
1347
- }
1348
- // LIVE: canonical-validated. Delegate to the E6 --to-name path via a
1349
- // synthesized `<workspace>::<team_key>/leader` name so live inject +
1350
- // mailbox both go through one code path.
1351
- let to_name = format!("{}::{}/leader", entry.workspace.display(), entry.team_key);
1352
- // 0.5.45 naming-addressing (design §3.1 / §4.1): internal
1353
- // registry-to-E6 delegation MUST pass None for bare_team_scope.
1354
- // The synthesized name above is a full `workspace::team/leader`
1355
- // form, and the caller's `--team` flag (if any) must not
1356
- // override the registry's authoritative team_key.
1357
- let (resolved, transport) =
1358
- match crate::cli::named_address::resolve_name_for_cli(sender_workspace, &to_name, None) {
1359
- Ok(r) => r,
1360
- Err(err) => {
1361
- // Named-address refusal — surface it verbatim but tag as
1362
- // registry-resolved so callers can trace the origin.
1363
- let mut body = err.to_json();
1364
- if let Some(obj) = body.as_object_mut() {
1365
- obj.insert(
1366
- "resolved_via".to_string(),
1367
- serde_json::Value::String("host_leader_registry".to_string()),
1368
- );
1369
- obj.insert("delivered".to_string(), serde_json::Value::Bool(false));
1370
- }
1371
- return Ok(body);
1372
- }
1373
- };
1374
- let mut value = send_to_named_pane_direct(
1375
- sender_workspace,
1376
- transport.as_ref(),
1377
- &resolved,
1378
- content,
1379
- sender,
1380
- task_id,
1381
- true,
1382
- )?;
1383
- if let Some(obj) = value.as_object_mut() {
1384
- obj.insert(
1385
- "resolved_via".to_string(),
1386
- serde_json::Value::String("host_leader_registry".to_string()),
1387
- );
1388
- obj.insert(
1389
- "to_leader".to_string(),
1390
- serde_json::Value::String(entry.qualified_name.clone()),
1391
- );
1392
- // Honest delivered marker. `send_to_named_pane_direct` sets `ok`
1393
- // to whether physical inject verified — mirror that as
1394
- // `delivered`.
1395
- let ok = obj
1396
- .get("ok")
1397
- .and_then(serde_json::Value::as_bool)
1398
- .unwrap_or(false);
1399
- obj.insert("delivered".to_string(), serde_json::Value::Bool(ok));
1400
- }
1296
+ let (logical_to, entry) = match resolve_host_leader_alias(name) {
1297
+ Ok(resolved) => resolved,
1298
+ Err(value) => return Ok(value),
1299
+ };
1300
+ let args = SendArgs {
1301
+ target: Some(logical_to.clone()),
1302
+ message: vec![content.to_string()],
1303
+ targets: None,
1304
+ workspace: sender_workspace.to_path_buf(),
1305
+ team: None,
1306
+ task: task_id.map(str::to_string),
1307
+ sender: sender.clone(),
1308
+ no_ack: false,
1309
+ no_wait: true,
1310
+ watch_result: false,
1311
+ timeout: 0.0,
1312
+ confirm_human: false,
1313
+ json: true,
1314
+ message_id: None,
1315
+ pane: None,
1316
+ to_name: None,
1317
+ to_leader: None,
1318
+ };
1319
+ let mut value = send_to_logical_to(&args, &logical_to, content)?;
1320
+ decorate_host_leader_alias(&mut value, &entry);
1401
1321
  Ok(value)
1402
1322
  }