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