@team-agent/installer 0.5.49 → 0.5.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +6 -4
  4. package/crates/team-agent/src/cli/emit.rs +91 -39
  5. package/crates/team-agent/src/cli/mod.rs +33 -15
  6. package/crates/team-agent/src/cli/named_address.rs +82 -53
  7. package/crates/team-agent/src/cli/send/coordinator.rs +163 -0
  8. package/crates/team-agent/src/cli/send/mailbox.rs +99 -0
  9. package/crates/team-agent/src/cli/send/persist.rs +154 -0
  10. package/crates/team-agent/src/cli/send/presentation.rs +333 -0
  11. package/crates/team-agent/src/cli/send/resolve.rs +361 -0
  12. package/crates/team-agent/src/cli/send.rs +103 -1308
  13. package/crates/team-agent/src/cli/spec.rs +2 -2
  14. package/crates/team-agent/src/cli/status_port/agents.rs +358 -0
  15. package/crates/team-agent/src/cli/status_port/approvals.rs +79 -0
  16. package/crates/team-agent/src/cli/status_port/compact.rs +207 -0
  17. package/crates/team-agent/src/cli/status_port/format.rs +145 -0
  18. package/crates/team-agent/src/cli/status_port/inbox.rs +36 -0
  19. package/crates/team-agent/src/cli/status_port/runtime.rs +195 -0
  20. package/crates/team-agent/src/cli/status_port/snapshot.rs +181 -0
  21. package/crates/team-agent/src/cli/status_port/store.rs +412 -0
  22. package/crates/team-agent/src/cli/status_port/tests.rs +54 -0
  23. package/crates/team-agent/src/cli/status_port.rs +47 -1548
  24. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -1
  25. package/crates/team-agent/src/cli/tests/named_address.rs +9 -7
  26. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -3
  27. package/crates/team-agent/src/cli/tests/status_send.rs +17 -33
  28. package/crates/team-agent/src/cli/types.rs +5 -8
  29. package/crates/team-agent/src/coordinator/conpty_shim.rs +34 -30
  30. package/crates/team-agent/src/coordinator/steps/abnormal.rs +135 -13
  31. package/crates/team-agent/src/coordinator/tick.rs +37 -0
  32. package/crates/team-agent/src/db/agent_health_capture.rs +18 -13
  33. package/crates/team-agent/src/db/message_store.rs +154 -44
  34. package/crates/team-agent/src/event_log.rs +73 -0
  35. package/crates/team-agent/src/leader/start.rs +28 -4
  36. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +424 -0
  37. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +297 -0
  38. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +160 -0
  39. package/crates/team-agent/src/lifecycle/launch/approval.rs +134 -0
  40. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +492 -0
  41. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +297 -0
  42. package/crates/team-agent/src/lifecycle/launch/identity.rs +372 -0
  43. package/crates/team-agent/src/lifecycle/launch/layout.rs +313 -0
  44. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +478 -0
  45. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +201 -0
  46. package/crates/team-agent/src/lifecycle/launch/ownership.rs +66 -0
  47. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +477 -0
  48. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +278 -0
  49. package/crates/team-agent/src/lifecycle/launch/readiness.rs +123 -0
  50. package/crates/team-agent/src/lifecycle/launch/spawn.rs +377 -0
  51. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +434 -0
  52. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +499 -0
  53. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +438 -0
  54. package/crates/team-agent/src/lifecycle/launch.rs +119 -5351
  55. package/crates/team-agent/src/lifecycle/restart/agent.rs +44 -26
  56. package/crates/team-agent/src/lifecycle/restart/common.rs +53 -27
  57. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +67 -26
  58. package/crates/team-agent/src/lifecycle/restart/remove.rs +435 -72
  59. package/crates/team-agent/src/lifecycle/restart.rs +1 -1
  60. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +575 -17
  61. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +55 -2
  62. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +24 -1
  63. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +1 -1
  64. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +30 -7
  65. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +8 -4
  66. package/crates/team-agent/src/mcp_server/mod.rs +2 -2
  67. package/crates/team-agent/src/mcp_server/tests/send.rs +22 -15
  68. package/crates/team-agent/src/mcp_server/tests/wire.rs +6 -0
  69. package/crates/team-agent/src/mcp_server/tools.rs +26 -15
  70. package/crates/team-agent/src/mcp_server/wire.rs +2 -18
  71. package/crates/team-agent/src/messaging/activity.rs +4 -2
  72. package/crates/team-agent/src/messaging/address.rs +86 -0
  73. package/crates/team-agent/src/messaging/delivery.rs +165 -39
  74. package/crates/team-agent/src/messaging/helpers.rs +17 -13
  75. package/crates/team-agent/src/messaging/leader_receiver.rs +60 -35
  76. package/crates/team-agent/src/messaging/mod.rs +11 -2
  77. package/crates/team-agent/src/messaging/persist.rs +309 -0
  78. package/crates/team-agent/src/messaging/results.rs +16 -24
  79. package/crates/team-agent/src/messaging/scheduler.rs +4 -2
  80. package/crates/team-agent/src/messaging/selftest.rs +19 -12
  81. package/crates/team-agent/src/messaging/send.rs +133 -58
  82. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +305 -0
  83. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  84. package/crates/team-agent/src/messaging/tests/runtime.rs +38 -17
  85. package/crates/team-agent/src/messaging/watchers.rs +13 -3
  86. package/crates/team-agent/src/redaction.rs +72 -2
  87. package/crates/team-agent/src/state/persist.rs +2 -1
  88. package/crates/team-agent/src/state/repository/tests.rs +47 -0
  89. package/crates/team-agent/src/state/repository.rs +59 -16
  90. package/package.json +4 -4
@@ -4,136 +4,87 @@
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> {
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()
33
+ // F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
34
+ // Mutually exclusive with target / --to (agent-name routing).
35
+ if let Some(ref pane_id) = args.pane {
36
+ warn_send_alias("--pane");
37
+ if args.target.is_some()
21
38
  || args.targets.is_some()
39
+ || args.to_name.is_some()
40
+ || args.to_leader.is_some()
22
41
  {
23
- return Err(CliError::Usage(
24
- "--to-leader and --to-name/--pane/TARGET/--to are mutually exclusive: \
25
- --to-leader resolves a host leader delivery name via the leader registry"
26
- .to_string(),
27
- ));
42
+ let message = if args.to_name.is_some() {
43
+ "--to-name and --pane/TARGET/--to are mutually exclusive"
44
+ } else {
45
+ "--pane and TARGET/--to are mutually exclusive; --pane also conflicts with --to-leader"
46
+ };
47
+ return Err(CliError::Usage(message.to_string()));
28
48
  }
29
49
  let content = args.message.join(" ");
30
50
  if content.is_empty() {
31
51
  return Err(CliError::Usage(
32
- "--to-leader requires a non-empty message".to_string(),
52
+ "--pane requires a non-empty message".to_string(),
33
53
  ));
34
54
  }
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));
55
+ return Err(CliError::Usage(format!(
56
+ "--pane {pane_id} is deprecated and sunset; use a logical TARGET so the message is persisted before delivery"
57
+ )));
43
58
  }
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));
59
+ if args.targets.is_some() {
60
+ warn_send_alias("--targets");
110
61
  }
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
- ));
62
+ if args.to_name.is_some() {
63
+ warn_send_alias("--to-name");
64
+ }
65
+ if args.to_leader.is_some() {
66
+ warn_send_alias("--to-leader");
67
+ }
68
+ let host_leader_alias = if let Some(name) = args.to_leader.as_deref() {
69
+ match resolve_host_leader_alias(name) {
70
+ Ok(resolved) => Some(resolved),
71
+ Err(value) => return Ok(cmd_send_result(value, args.json)),
121
72
  }
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
- ));
73
+ } else {
74
+ None
75
+ };
76
+ let logical_to = logical_to_from_args(
77
+ args,
78
+ host_leader_alias
79
+ .as_ref()
80
+ .map(|(logical_to, _)| logical_to.as_str()),
81
+ )?;
82
+ let content = args.message.join(" ");
83
+ if !logical_to.is_empty() && logical_to != "*" && !content.is_empty() {
84
+ let mut value = send_to_logical_to(args, &logical_to, &content)?;
85
+ if let Some((_, entry)) = host_leader_alias.as_ref() {
86
+ decorate_host_leader_alias(&mut value, entry);
127
87
  }
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
88
  add_send_reminder_if_ok(&mut value);
138
89
  return Ok(cmd_send_result(value, args.json));
139
90
  }
@@ -142,13 +93,12 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
142
93
  args.team.as_deref(),
143
94
  crate::state::selector::SelectorMode::RuntimeOnly,
144
95
  )?;
145
- let target = send_target(args.targets.as_deref(), args.target.as_deref());
96
+ let target = send_target(None, Some(logical_to.as_str()));
146
97
  let mut opts = send_options_from_args(args);
147
98
  // `args.team` is a selector and may be a legacy session/team-dir alias.
148
99
  // All downstream membership and DB scope must use the canonical key that
149
100
  // resolve_active_team returned, never the original selector spelling.
150
101
  opts.team = Some(TeamKey::new(selected.team_key.clone()));
151
- let content = args.message.join(" ");
152
102
  // CR-061/N27 routing-ambiguous: a single positional with no `--to`/`--targets` and an
153
103
  // empty message body is a prompt-only invocation (`team-agent send "fix the build"`).
154
104
  // The lone positional is CONTENT, not a target — reject with `routing_ambiguous`
@@ -161,16 +111,12 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
161
111
  if let Some(value) = dirty_topology_refusal_value(&selected, args.team.as_deref()) {
162
112
  return Ok(cmd_send_result(value, args.json));
163
113
  }
164
- let coordinator_ensure = if target_has_known_worker(&selected.state, &target, &opts.sender) {
165
- loud_ensure_coordinator(&selected)?
166
- } else {
167
- None
168
- };
169
- if let Some(value) =
170
- coordinator_ensure_unavailable_value(coordinator_ensure.as_ref(), &target, &content, &opts)
171
- {
172
- return Ok(cmd_send_result(value, args.json));
173
- }
114
+ let coordinator_ensure =
115
+ if target_has_known_worker(&selected.state, &target, opts.sender.as_str()) {
116
+ loud_ensure_coordinator(&selected)?
117
+ } else {
118
+ None
119
+ };
174
120
  let mut outcome = messaging::send_message(&selected.run_workspace, &target, &content, &opts)?;
175
121
  if opts.watch_result {
176
122
  outcome = observe_initial_delivery_for_watch(&selected, &target, &outcome, &opts)?;
@@ -195,545 +141,41 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
195
141
  Ok(cmd_send_result(value, args.json))
196
142
  }
197
143
 
198
- fn dirty_topology_refusal_value(
199
- selected: &crate::state::selector::SelectedTeam,
200
- requested_team: Option<&str>,
201
- ) -> Option<Value> {
202
- let issue_ids = crate::topology::restart_dirty_topology_issue_ids(&selected.state);
203
- if issue_ids.is_empty() {
204
- return None;
205
- }
206
- let session_name = selected
207
- .state
208
- .get("session_name")
209
- .and_then(Value::as_str)
210
- .unwrap_or_default()
211
- .to_string();
212
- let reason = issue_ids
213
- .first()
214
- .cloned()
215
- .unwrap_or_else(|| "dirty_topology".to_string());
216
- let repair_team = requested_team
217
- .filter(|team| !team.is_empty())
218
- .unwrap_or(selected.team_key.as_str());
219
- Some(json!({
220
- "ok": false,
221
- "status": "refused_dirty_topology",
222
- "reason": reason,
223
- "session_name": session_name,
224
- "error": "send refused: tmux endpoint/socket topology is inconsistent; run diagnose from the intended leader socket before sending",
225
- "issues": issue_ids
226
- .iter()
227
- .map(|id| json!({"id": id}))
228
- .collect::<Vec<_>>(),
229
- "next_actions": [
230
- "team-agent diagnose --json",
231
- format!("team-agent claim-leader --team {repair_team} --confirm --json"),
232
- format!("team-agent takeover --team {repair_team} --confirm --json")
233
- ],
234
- }))
235
- }
236
-
237
- fn target_has_known_worker(state: &Value, target: &MessageTarget, sender: &str) -> bool {
238
- let Some(agents) = state.get("agents").and_then(Value::as_object) else {
239
- return false;
240
- };
241
- match target {
242
- MessageTarget::Single(target) => agents.contains_key(target),
243
- MessageTarget::Broadcast => agents.keys().any(|agent| agent != sender),
244
- MessageTarget::Fanout(recipients) => recipients
245
- .iter()
246
- .any(|recipient| agents.contains_key(recipient)),
247
- }
248
- }
249
-
250
- #[derive(Debug, Clone)]
251
- struct LoudEnsureResult {
252
- previous_status: String,
253
- start: crate::coordinator::StartReport,
254
- }
255
-
256
- fn loud_ensure_coordinator(
257
- selected: &crate::state::selector::SelectedTeam,
258
- ) -> Result<Option<LoudEnsureResult>, CliError> {
259
- if in_process_unit_test() {
260
- return Ok(None);
261
- }
262
- let workspace = crate::coordinator::WorkspacePath::new(selected.run_workspace.clone());
263
- let previous = crate::coordinator::coordinator_health(&workspace);
264
- if previous.ok {
265
- return Ok(None);
266
- }
267
- if previous.service_available
268
- && matches!(
269
- previous.binary_identity_relation,
270
- crate::coordinator::CoordinatorBinaryIdentityRelation::DaemonNewerThanCaller
271
- )
272
- {
273
- return Ok(None);
274
- }
275
- let previous_status = coordinator_health_status_wire(previous.status).to_string();
276
- let start = crate::coordinator::start_coordinator_with_team(
277
- &workspace,
278
- Some(selected.team_key.as_str()),
279
- )
280
- .map_err(|error| CliError::Runtime(error.to_string()))?;
281
- if !start.ok {
282
- return Ok(Some(LoudEnsureResult {
283
- previous_status,
284
- start,
285
- }));
286
- }
287
- if matches!(
288
- start.status,
289
- crate::coordinator::StartOutcome::Started
290
- | crate::coordinator::StartOutcome::StartedAfterRotation
291
- ) {
292
- crate::event_log::EventLog::new(&selected.run_workspace)
293
- .write(
294
- "coordinator.ensure_restarted",
295
- json!({
296
- "coordinator_previous_status": previous_status,
297
- "status": start.status,
298
- "pid": start.pid.map(|pid| pid.get()),
299
- "previous_pid": start.previous_pid.map(|pid| pid.get()),
300
- "binary_path": start.binary_path,
301
- "binary_version": start.binary_version,
302
- "rotation_reason": start.rotation_reason,
303
- }),
304
- )
305
- .map_err(|error| CliError::Runtime(error.to_string()))?;
306
- return Ok(Some(LoudEnsureResult {
307
- previous_status,
308
- start,
309
- }));
310
- }
311
- Ok(None)
312
- }
313
-
314
- #[cfg(test)]
315
- fn in_process_unit_test() -> bool {
316
- true
317
- }
318
-
319
- #[cfg(not(test))]
320
- fn in_process_unit_test() -> bool {
321
- false
322
- }
323
-
324
- fn coordinator_ensure_unavailable_value(
325
- ensure: Option<&LoudEnsureResult>,
326
- target: &MessageTarget,
327
- content: &str,
328
- opts: &SendOptions,
329
- ) -> Option<Value> {
330
- let ensure = ensure?;
331
- if ensure.start.ok {
332
- return None;
333
- }
334
- let warning = format!(
335
- "coordinator is not running; message was not queued for {}. Run `team-agent diagnose` or restart the team before sending again.",
336
- first_target(target)
337
- );
338
- let mut value = json!({
339
- "ok": false,
340
- "status": "degraded",
341
- "delivery_status": "degraded",
342
- "delivered": false,
343
- "target": target_json(target),
344
- "agent_id": first_target(target),
345
- "content_length_bytes": content.len(),
346
- "sender": opts.sender,
347
- "message_id": Value::Null,
348
- "message_status": "degraded",
349
- "verification": warning,
350
- "stage": Value::Null,
351
- "reason": "coordinator_unavailable",
352
- "channel": "coordinator_unavailable",
353
- });
354
- append_loud_ensure_fields(&mut value, Some(ensure));
355
- Some(value)
356
- }
357
-
358
- fn append_loud_ensure_fields(value: &mut Value, ensure: Option<&LoudEnsureResult>) {
359
- let Some(ensure) = ensure else {
360
- return;
361
- };
362
- if !ensure.start.ok {
363
- return;
364
- }
365
- if let Some(obj) = value.as_object_mut() {
366
- obj.insert("coordinator_auto_restarted".to_string(), json!(true));
367
- obj.insert(
368
- "coordinator_previous_status".to_string(),
369
- json!(ensure.previous_status),
370
- );
371
- obj.insert(
372
- "coordinator".to_string(),
373
- coordinator_start_json(&ensure.start),
374
- );
375
- }
376
- }
377
-
378
- fn coordinator_start_json(report: &crate::coordinator::StartReport) -> Value {
379
- let summary = crate::lifecycle::CoordinatorStartSummary::from_start_report(report);
380
- crate::lifecycle::coordinator_start_summary_value(&summary)
381
- }
382
-
383
- fn coordinator_health_status_wire(
384
- status: crate::coordinator::CoordinatorHealthStatus,
385
- ) -> &'static str {
386
- match status {
387
- crate::coordinator::CoordinatorHealthStatus::Missing => "missing",
388
- crate::coordinator::CoordinatorHealthStatus::InvalidPid => "invalid_pid",
389
- crate::coordinator::CoordinatorHealthStatus::Running => "running",
390
- crate::coordinator::CoordinatorHealthStatus::Stale => "stale",
391
- }
392
- }
393
-
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};
417
-
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)
441
- })
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
- );
448
- }
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 { .. }
469
- );
470
- Ok(serde_json::json!({
471
- "ok": ok,
472
- "pane_id": pane_id,
473
- "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,
477
- }))
478
- }
479
-
480
- fn send_to_named_pane_direct(
481
- sender_workspace: &Path,
482
- transport: &dyn crate::transport::Transport,
483
- resolved: &crate::cli::named_address::ResolvedNamedAddress,
144
+ /// Resolve a host-leader alias, send through the canonical persisted funnel,
145
+ /// then decorate the already-produced result for the host-leader surface.
146
+ pub fn send_to_canonical_leader_target(
147
+ sender_workspace: &std::path::Path,
148
+ name: &str,
484
149
  content: &str,
485
- sender: &str,
150
+ sender: &TrustedSender,
486
151
  task_id: Option<&str>,
487
- _json: bool,
488
152
  ) -> Result<serde_json::Value, CliError> {
489
- use crate::messaging::delivery::render_message;
490
- use crate::transport::{InjectPayload, Key, PaneId, Target};
491
-
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}");
499
- }
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
- );
508
- }
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));
549
- }
550
- Ok(value)
551
- }
552
-
553
- fn send_to_named_app_server_leader(
554
- event_log: &crate::event_log::EventLog,
555
- 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
- }
623
- }
624
- }
625
-
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());
630
- }
631
- }
632
- left
633
- }
634
-
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",
640
- }
641
- }
642
-
643
- fn routing_ambiguous_value(
644
- workspace: &Path,
645
- args: &SendArgs,
646
- target: &MessageTarget,
647
- content: &str,
648
- opts: &SendOptions,
649
- ) -> Option<Value> {
650
- if args.targets.is_some() || !content.is_empty() {
651
- return None;
652
- }
653
- let MessageTarget::Single(name) = target else {
654
- return None;
153
+ let (logical_to, entry) = match resolve_host_leader_alias(name) {
154
+ Ok(resolved) => resolved,
155
+ Err(value) => return Ok(value),
655
156
  };
656
- if name.is_empty() {
657
- return None;
658
- }
659
- let state = crate::state::persist::load_runtime_state(workspace).ok()?;
660
- let in_team = state
661
- .get("agents")
662
- .and_then(|v| v.as_object())
663
- .is_some_and(|a| a.contains_key(name));
664
- if in_team {
665
- return None;
666
- }
667
- // aeab1c7 follow-up: `content` is no longer emitted anywhere from `send`
668
- // responses (including this refusal). Replace with `content_length_bytes`
669
- // to keep the size-sanity field consistent with the normal-send shape.
670
- Some(json!({
671
- "ok": false,
672
- "status": "refused",
673
- "target": null,
674
- "agent_id": null,
675
- "content_length_bytes": name.len(),
676
- "sender": opts.sender,
677
- "message_id": null,
678
- "message_status": "refused",
679
- "verification": null,
680
- "stage": null,
681
- "reason": "routing_ambiguous",
682
- "channel": null,
683
- }))
684
- }
685
-
686
- fn selected_state_with_active_key(selected: &crate::state::selector::SelectedTeam) -> Value {
687
- let mut state = selected.state.clone();
688
- if let Some(obj) = state.as_object_mut() {
689
- obj.insert(
690
- "active_team_key".to_string(),
691
- Value::String(selected.team_key.clone()),
692
- );
693
- }
694
- state
695
- }
696
-
697
- fn initial_delivery_allows_watch(status: DeliveryStatus) -> bool {
698
- matches!(
699
- status,
700
- DeliveryStatus::Delivered | DeliveryStatus::AlreadyDelivered
701
- )
702
- }
703
-
704
- fn observe_initial_delivery_for_watch(
705
- selected: &crate::state::selector::SelectedTeam,
706
- target: &MessageTarget,
707
- outcome: &DeliveryOutcome,
708
- opts: &SendOptions,
709
- ) -> Result<DeliveryOutcome, CliError> {
710
- if !matches!(target, MessageTarget::Single(agent) if !agent.is_empty()) {
711
- return Ok(outcome.clone());
712
- }
713
- if !matches!(outcome.status, DeliveryStatus::Queued) {
714
- return Ok(outcome.clone());
715
- }
716
- let Some(message_id) = outcome.message_id.as_deref() else {
717
- return Ok(outcome.clone());
157
+ let args = SendArgs {
158
+ target: Some(logical_to.clone()),
159
+ message: vec![content.to_string()],
160
+ targets: None,
161
+ workspace: sender_workspace.to_path_buf(),
162
+ team: None,
163
+ task: task_id.map(str::to_string),
164
+ sender: sender.clone(),
165
+ no_ack: false,
166
+ no_wait: true,
167
+ watch_result: false,
168
+ timeout: 0.0,
169
+ confirm_human: false,
170
+ json: true,
171
+ message_id: None,
172
+ pane: None,
173
+ to_name: None,
174
+ to_leader: None,
718
175
  };
719
- let store = crate::message_store::MessageStore::open(&selected.run_workspace)
720
- .map_err(|e| CliError::Runtime(e.to_string()))?;
721
- let transport = crate::lifecycle::restart::lifecycle_worker_tmux_backend_for_selected_state(
722
- &selected.run_workspace,
723
- opts.team.as_ref().map(TeamKey::as_str),
724
- )
725
- .map_err(|e| CliError::Runtime(e.to_string()))?;
726
- let event_log = crate::event_log::EventLog::new(&selected.run_workspace);
727
- let state = selected_state_with_active_key(selected);
728
- crate::messaging::delivery::deliver_pending_message(
729
- &selected.run_workspace,
730
- &store,
731
- &transport,
732
- message_id,
733
- &event_log,
734
- &state,
735
- )
736
- .map_err(CliError::from)
176
+ let mut value = send_to_logical_to(&args, &logical_to, content)?;
177
+ decorate_host_leader_alias(&mut value, &entry);
178
+ Ok(value)
737
179
  }
738
180
 
739
181
  /// `_send_target`(`commands.py:181-184`):`--to` comma-split fanout / `target` 单值 / None。
@@ -753,650 +195,3 @@ pub fn send_target(targets: Option<&str>, target: Option<&str>) -> MessageTarget
753
195
  None => MessageTarget::Single(String::new()),
754
196
  }
755
197
  }
756
-
757
- /// `cmd_send` 的 [`SendArgs`]→[`SendOptions`] 翻译(`commands.py:170-177`)。CLI **独占**的
758
- /// 旗标取反语义(经典 off-by-inversion bug 面):`no_ack→!requires_ack`、`no_wait→!wait_visible`、
759
- /// `watch_result` 直传、`task_id`/`sender`/`confirm_human`/`timeout`/`team` 透传。
760
- /// (其余 `lock_timeout`/`block_until_delivered` 用 [`SendOptions::default`]。)
761
- pub fn send_options_from_args(args: &SendArgs) -> SendOptions {
762
- SendOptions {
763
- task_id: args.task.as_ref().map(|s| TaskId::new(s.clone())),
764
- route_task_id: true,
765
- sender: args.sender.clone(),
766
- requires_ack: !args.no_ack,
767
- confirm_human: args.confirm_human,
768
- wait_visible: !args.no_wait,
769
- timeout: args.timeout,
770
- watch_result: args.watch_result,
771
- team: args.team.as_ref().map(|s| TeamKey::new(s.clone())),
772
- message_id: args.message_id.clone(),
773
- ..SendOptions::default()
774
- }
775
- }
776
-
777
- fn watch_notice_json(target: &MessageTarget, opts: &SendOptions) -> Value {
778
- let agent_id = match target {
779
- MessageTarget::Single(agent) => agent.clone(),
780
- MessageTarget::Broadcast => "*".to_string(),
781
- MessageTarget::Fanout(recipients) => recipients
782
- .first()
783
- .cloned()
784
- .unwrap_or_else(|| "-".to_string()),
785
- };
786
- json!({
787
- "status": "registered",
788
- "watcher_id": format!("watch-{agent_id}"),
789
- "task_id": opts.task_id.as_ref().map(|t| t.as_str().to_string()),
790
- "agent_id": agent_id,
791
- "notice": "Team Agent will collect the result and notify the leader when this task reports completion."
792
- })
793
- }
794
-
795
- /// 0.5.45 naming-addressing (design §3.5, RED-2/RED-3 positional):
796
- /// after `messaging::send_message` refuses with `target_not_in_team`
797
- /// for a Single non-special short id, attach scope-safe advisory
798
- /// suggestions to the outbound JSON envelope. Candidate source =
799
- /// selected team's projected `agents` map (never the raw workspace
800
- /// `teams`) so sibling teams cannot leak. Zero DB write, zero inject
801
- /// — the refusal exit code is unchanged.
802
- fn attach_positional_typo_suggestions(
803
- value: &mut Value,
804
- target: &MessageTarget,
805
- selected_state: &Value,
806
- ) {
807
- use crate::model::name_similarity::{rank, Candidate};
808
- let requested = match target {
809
- MessageTarget::Single(id) if id != "*" && id != "leader" => id.clone(),
810
- _ => return,
811
- };
812
- let Some(obj) = value.as_object_mut() else {
813
- return;
814
- };
815
- if obj.get("reason").and_then(Value::as_str) != Some("target_not_in_team") {
816
- return;
817
- }
818
- let team_key = selected_state
819
- .get("active_team_key")
820
- .or_else(|| selected_state.get("team_key"))
821
- .and_then(Value::as_str)
822
- .unwrap_or("");
823
- let candidates: Vec<Candidate<String>> = selected_state
824
- .get("agents")
825
- .and_then(Value::as_object)
826
- .map(|agents| {
827
- agents
828
- .keys()
829
- .map(|agent_id| Candidate {
830
- match_key: agent_id.clone(),
831
- stable_key: agent_id.clone(),
832
- payload: agent_id.clone(),
833
- })
834
- .collect()
835
- })
836
- .unwrap_or_default();
837
- let ranked = rank(&requested, &candidates);
838
- let candidate_values: Vec<Value> = ranked
839
- .iter()
840
- .map(|agent_id| {
841
- json!({
842
- "name": agent_id,
843
- "team_key": team_key,
844
- "agent_id": agent_id,
845
- "advisory": true,
846
- })
847
- })
848
- .collect();
849
- obj.insert("requested_name".to_string(), json!(requested));
850
- if let Some(best) = ranked.first() {
851
- obj.insert("suggested_name".to_string(), json!(best));
852
- }
853
- obj.insert("candidates".to_string(), Value::Array(candidate_values));
854
- }
855
-
856
- fn delivery_outcome_json(
857
- outcome: &DeliveryOutcome,
858
- target: &MessageTarget,
859
- content: &str,
860
- opts: &SendOptions,
861
- ) -> Value {
862
- // Pre-release 0.4.0 user directive: send result MUST NOT carry the
863
- // message body — neither in human form (cli/emit.rs) NOR in --json.
864
- // External consumers who need the message content read it via `inbox`,
865
- // not from the send response. We surface `content_length_bytes` as a
866
- // size sanity field so callers can verify the body size they intended
867
- // to send arrived intact without exposing the body itself.
868
- let target_wire = target_json(target);
869
- json!({
870
- "ok": outcome.ok,
871
- "status": delivery_status_wire(outcome.status),
872
- "delivery_status": api_delivery_status(outcome),
873
- "delivered": delivery_proven(outcome.status),
874
- "target": target_wire,
875
- "agent_id": first_target(target),
876
- "content_length_bytes": content.len(),
877
- "sender": opts.sender,
878
- "message_id": outcome.message_id,
879
- "message_status": outcome.message_status.0,
880
- "verification": outcome.verification,
881
- "stage": outcome.stage.map(delivery_stage_wire),
882
- "reason": outcome.reason.map(delivery_refusal_wire),
883
- "channel": outcome.channel,
884
- })
885
- }
886
-
887
- fn api_delivery_status(outcome: &DeliveryOutcome) -> &'static str {
888
- if delivery_proven(outcome.status) {
889
- return "delivered";
890
- }
891
- if matches!(outcome.status, DeliveryStatus::Queued) && outcome.message_status.0 == "accepted" {
892
- return "pending";
893
- }
894
- delivery_status_wire(outcome.status)
895
- }
896
-
897
- fn delivery_proven(status: DeliveryStatus) -> bool {
898
- matches!(
899
- status,
900
- DeliveryStatus::Delivered
901
- | DeliveryStatus::AlreadyDelivered
902
- | DeliveryStatus::BroadcastDelivered
903
- | DeliveryStatus::FanoutDelivered
904
- )
905
- }
906
-
907
- fn add_send_reminder_if_ok(value: &mut Value) {
908
- if value.get("ok").and_then(Value::as_bool) != Some(true) {
909
- return;
910
- }
911
- let reminder = send_reminder_for_value(value);
912
- if let Some(obj) = value.as_object_mut() {
913
- obj.insert("reminder".to_string(), json!(reminder));
914
- }
915
- }
916
-
917
- /// E6 (0.5.9 offline-mailbox-toname-design §§3.1/6.2/8, real-machine
918
- /// escape evidence
919
- /// `.team/artifacts/0.5.9-subscription-gate.md` +
920
- /// `.team/evidence/0.5.9-subscription-gate-20260707T143241Z-4645/`):
921
- /// when the `--to-name <ws>::<team>/leader` resolver refused with
922
- /// `leader_not_attached`, decide whether the target team is still alive
923
- /// (worker + coordinator running without a bound leader) and, if so,
924
- /// enqueue the mailbox row so `attach-leader` replays it exactly once.
925
- ///
926
- /// Only queues for third-party senders (sender workspace ≠ target
927
- /// workspace). Owner-scope refusals stay refused so status/diagnose can
928
- /// keep pushing the operator toward `attach-leader`.
929
- fn maybe_enqueue_offline_leader_mailbox(
930
- sender_workspace: &Path,
931
- to_name: &str,
932
- content: &str,
933
- sender: &str,
934
- task_id: Option<&str>,
935
- error: &crate::cli::named_address::NamedAddressError,
936
- ) -> Result<Option<Value>, CliError> {
937
- if error.kind != crate::cli::named_address::NamedAddressErrorKind::LeaderNotAttached {
938
- return Ok(None);
939
- }
940
- let parsed = match crate::cli::named_address::parse_leader_target_workspace_and_team(
941
- sender_workspace,
942
- to_name,
943
- ) {
944
- Ok(Some(v)) => v,
945
- Ok(None) => return Ok(None),
946
- Err(_) => return Ok(None),
947
- };
948
- let (target_workspace, team_key) = parsed;
949
- // Owner-scope refusal: sender workspace == target workspace. Keep
950
- // the actionable attach hint (owner sees status/diagnose copy that
951
- // points at `attach-leader`).
952
- let sender_canonical =
953
- std::fs::canonicalize(sender_workspace).unwrap_or_else(|_| sender_workspace.to_path_buf());
954
- let target_canonical =
955
- std::fs::canonicalize(&target_workspace).unwrap_or_else(|_| target_workspace.clone());
956
- if sender_canonical == target_canonical {
957
- return Ok(None);
958
- }
959
- // Verify the target team is actually alive on this host — mailbox
960
- // is only for `team live + leader unattached`. Fail-closed otherwise
961
- // so we never leave a message in a permanently-dead workspace's DB.
962
- let state = match crate::state::persist::load_runtime_state(&target_workspace) {
963
- Ok(s) => s,
964
- Err(_) => return Ok(None),
965
- };
966
- let team_alive = target_team_is_alive_for_mailbox(&state, &team_key);
967
- if !team_alive {
968
- return Ok(None);
969
- }
970
- let event_log = crate::event_log::EventLog::new(&target_workspace);
971
- let task = task_id.map(|s| crate::model::ids::TaskId::new(s.to_string()));
972
- let outcome = messaging::enqueue_leader_mailbox_until_attach(
973
- &target_workspace,
974
- &team_key,
975
- content,
976
- task.as_ref(),
977
- sender,
978
- &event_log,
979
- )
980
- .map_err(|e| CliError::Runtime(e.to_string()))?;
981
- let message_id = outcome.message_id.clone().unwrap_or_else(|| "".to_string());
982
- Ok(Some(json!({
983
- "ok": true,
984
- "status": "queued_until_leader_attach",
985
- "message_status": "queued_until_leader_attach",
986
- "channel": "leader_mailbox",
987
- "delivered": false,
988
- "to_name": to_name,
989
- "target_workspace": target_workspace.display().to_string(),
990
- "team_key": team_key,
991
- "recipient": "leader",
992
- "leader_attached": false,
993
- "message_id": message_id,
994
- })))
995
- }
996
-
997
- /// Positive-source liveness heuristic per offline-mailbox-toname-design.md §4:
998
- /// - target workspace has state and the team key is present + not archived/down;
999
- /// - AND at least one live tmux fact — a persisted `session_name` OR any
1000
- /// agent with a recorded pane on the recorded socket.
1001
- ///
1002
- /// We deliberately do NOT poll coordinator health here — enqueuing is
1003
- /// safe even when the coordinator is transiently down; attach-leader
1004
- /// itself replays via `requeue_blocked_leader_messages` regardless.
1005
- fn target_team_is_alive_for_mailbox(state: &Value, team_key: &str) -> bool {
1006
- let team = state
1007
- .get("teams")
1008
- .and_then(|v| v.as_object())
1009
- .and_then(|teams| teams.get(team_key));
1010
- let Some(team) = team else {
1011
- return false;
1012
- };
1013
- let status = team
1014
- .get("status")
1015
- .and_then(|v| v.as_str())
1016
- .unwrap_or("alive");
1017
- if matches!(status, "archived" | "down" | "stopped") {
1018
- return false;
1019
- }
1020
- // A recorded session_name is enough — target's coordinator/attach
1021
- // path will re-verify tmux presence when the replay fires.
1022
- team.get("session_name")
1023
- .and_then(|v| v.as_str())
1024
- .is_some_and(|s| !s.is_empty())
1025
- }
1026
-
1027
- fn cmd_send_result(value: Value, as_json: bool) -> CmdResult {
1028
- let exit = if value.get("ok").and_then(Value::as_bool) == Some(false) {
1029
- ExitCode::Error
1030
- } else {
1031
- ExitCode::Ok
1032
- };
1033
- if as_json {
1034
- CmdResult::from_json(value, true)
1035
- } else {
1036
- CmdResult {
1037
- output: CmdOutput::Human(send_human_output(&value)),
1038
- exit,
1039
- as_json: false,
1040
- }
1041
- }
1042
- }
1043
-
1044
- fn send_human_output(value: &Value) -> String {
1045
- let mut parts = vec![
1046
- send_human_field(value, "ok"),
1047
- format!("status: {}", send_human_status(value)),
1048
- send_human_field(value, "message_id"),
1049
- format!("target: {}", send_human_target(value)),
1050
- ];
1051
- for key in ["verification", "stage", "reason", "channel"] {
1052
- if !value.get(key).is_none_or(Value::is_null) {
1053
- parts.push(send_human_field(value, key));
1054
- }
1055
- }
1056
- // 0.5.45 naming-addressing (design §3.4/§3.5, RED-3 positional):
1057
- // when the refusal envelope carries a scope-safe suggestion,
1058
- // surface it verbatim in human output so users can copy the
1059
- // right short id. `requested_name` echoes the typo, `suggested_
1060
- // name` is the copyable canonical.
1061
- if let Some(requested) = value
1062
- .get("requested_name")
1063
- .and_then(Value::as_str)
1064
- .filter(|s| !s.is_empty())
1065
- {
1066
- parts.push(format!("requested_name: {requested}"));
1067
- }
1068
- if let Some(suggested) = value
1069
- .get("suggested_name")
1070
- .and_then(Value::as_str)
1071
- .filter(|s| !s.is_empty())
1072
- {
1073
- parts.push(format!("Did you mean `{suggested}`? suggested_name: {suggested}"));
1074
- }
1075
- parts.join(" ")
1076
- }
1077
-
1078
- fn send_human_field(value: &Value, key: &str) -> String {
1079
- let rendered = value
1080
- .get(key)
1081
- .map(send_human_value)
1082
- .unwrap_or_else(|| "None".to_string());
1083
- format!("{key}: {rendered}")
1084
- }
1085
-
1086
- fn send_human_target(value: &Value) -> String {
1087
- ["target", "agent_id", "pane_id", "to_name"]
1088
- .iter()
1089
- .find_map(|key| value.get(*key).filter(|v| !v.is_null()))
1090
- .map(send_human_value)
1091
- .unwrap_or_else(|| "None".to_string())
1092
- }
1093
-
1094
- fn send_human_status(value: &Value) -> String {
1095
- value
1096
- .get("status")
1097
- .map(send_human_value)
1098
- .unwrap_or_else(|| {
1099
- if value.get("ok").and_then(Value::as_bool) == Some(true) {
1100
- "delivered".to_string()
1101
- } else {
1102
- "failed".to_string()
1103
- }
1104
- })
1105
- }
1106
-
1107
- fn send_human_value(value: &Value) -> String {
1108
- let text = match value {
1109
- Value::Null => "None".to_string(),
1110
- Value::Bool(true) => "True".to_string(),
1111
- Value::Bool(false) => "False".to_string(),
1112
- Value::Number(n) => n.to_string(),
1113
- Value::String(s) => s.clone(),
1114
- Value::Array(_) | Value::Object(_) => {
1115
- serde_json::to_string(value).unwrap_or_else(|_| "None".to_string())
1116
- }
1117
- };
1118
- text.replace(['\r', '\n'], " ")
1119
- }
1120
-
1121
- fn send_reminder_for_value(value: &Value) -> &'static str {
1122
- let delivered = value.get("delivered").and_then(Value::as_bool);
1123
- let status = value.get("status").and_then(Value::as_str);
1124
- let delivery_status = value.get("delivery_status").and_then(Value::as_str);
1125
- if delivered == Some(false)
1126
- || matches!(status, Some("queued"))
1127
- || matches!(delivery_status, Some("pending"))
1128
- {
1129
- "Message queued; coordinator will notify when the worker receives it. Do not poll the worker terminal with capture-pane."
1130
- } else {
1131
- crate::cli::SEND_REMINDER
1132
- }
1133
- }
1134
-
1135
- fn target_json(target: &MessageTarget) -> Value {
1136
- match target {
1137
- MessageTarget::Single(agent) => json!(agent),
1138
- MessageTarget::Broadcast => json!("*"),
1139
- MessageTarget::Fanout(recipients) => json!(recipients),
1140
- }
1141
- }
1142
-
1143
- fn first_target(target: &MessageTarget) -> String {
1144
- match target {
1145
- MessageTarget::Single(agent) => agent.clone(),
1146
- MessageTarget::Broadcast => "*".to_string(),
1147
- MessageTarget::Fanout(recipients) => recipients.first().cloned().unwrap_or_default(),
1148
- }
1149
- }
1150
-
1151
- fn delivery_status_wire(status: DeliveryStatus) -> &'static str {
1152
- match status {
1153
- DeliveryStatus::Delivered => "delivered",
1154
- DeliveryStatus::Failed => "failed",
1155
- DeliveryStatus::Queued => "queued",
1156
- DeliveryStatus::Blocked => "blocked",
1157
- DeliveryStatus::Refused => "refused",
1158
- DeliveryStatus::Degraded => "degraded",
1159
- DeliveryStatus::RetryScheduled => "retry_scheduled",
1160
- DeliveryStatus::TrustAutoAnswerExhausted => "trust_auto_answer_exhausted",
1161
- DeliveryStatus::AlreadyDelivered => "already_delivered",
1162
- DeliveryStatus::FallbackLog => "fallback_log",
1163
- DeliveryStatus::BroadcastDelivered => "broadcast_delivered",
1164
- DeliveryStatus::BroadcastPartial => "broadcast_partial",
1165
- DeliveryStatus::FanoutDelivered => "fanout_delivered",
1166
- DeliveryStatus::FanoutPartial => "fanout_partial",
1167
- }
1168
- }
1169
-
1170
- fn delivery_refusal_wire(reason: DeliveryRefusal) -> &'static str {
1171
- match reason {
1172
- DeliveryRefusal::TargetNotInTeam => "target_not_in_team",
1173
- DeliveryRefusal::HumanConfirmationRequired => "human_confirmation_required",
1174
- DeliveryRefusal::MissingPermissions => "missing_permissions",
1175
- DeliveryRefusal::RecipientBusy => "recipient_busy",
1176
- DeliveryRefusal::UnknownRecipient => "unknown_recipient",
1177
- DeliveryRefusal::TmuxTargetMissing => "tmux_target_missing",
1178
- DeliveryRefusal::MessageAlreadyClaimed => "message_already_claimed",
1179
- DeliveryRefusal::LeaderNotAttached => "leader_not_attached",
1180
- DeliveryRefusal::CoordinatorUnavailable => "coordinator_unavailable",
1181
- DeliveryRefusal::NoCallerPane => "no_caller_pane",
1182
- DeliveryRefusal::TeamOwnerMismatch => "team_owner_mismatch",
1183
- DeliveryRefusal::Ambiguous => "ambiguous",
1184
- DeliveryRefusal::RecipientPaneInNonInputMode => "recipient_pane_in_non_input_mode",
1185
- DeliveryRefusal::SessionDrift => "session_drift",
1186
- DeliveryRefusal::Duplicate => "duplicate",
1187
- DeliveryRefusal::RoutingAmbiguous => "routing_ambiguous",
1188
- DeliveryRefusal::EmptyTargetList => "empty_target_list",
1189
- }
1190
- }
1191
-
1192
- fn delivery_stage_wire(stage: DeliveryStage) -> &'static str {
1193
- match stage {
1194
- DeliveryStage::TrustAutoAnswerDismissalWait => "trust_auto_answer_dismissal_wait",
1195
- DeliveryStage::Inject => "inject",
1196
- DeliveryStage::Submit => "submit",
1197
- DeliveryStage::VisibleCheck => "visible_check",
1198
- }
1199
- }
1200
-
1201
- /// E7 (0.5.9 host-leader-registry-design §8.3): resolve `NAME` through
1202
- /// `~/.team-agent/leaders`, then delegate to the E6 leader delivery path
1203
- /// so a resolved live target physically injects and a leader-not-attached
1204
- /// target queues via `enqueue_leader_mailbox_until_attach`. Ambiguous
1205
- /// short names refuse with `name_ambiguous` and expose `candidates` —
1206
- /// no priority heuristic ever picks a winner (host-leader-registry-design §5.2).
1207
- ///
1208
- /// Return shape reserves the following markers for downstream consumers:
1209
- /// - `resolved_via = "host_leader_registry"` when a registry entry
1210
- /// selected the canonical target (E7 test 2).
1211
- /// - `reason = "leader_name_not_found"` for missing entries; `reason =
1212
- /// "registry_stale"` when canonical validation refuses; `reason =
1213
- /// "name_ambiguous"` for collisions with a candidate list including
1214
- /// `workspace_hash` and `stable_qualified_name`.
1215
- ///
1216
- /// The first slice ships the marker/return-shape surface so E6 wiring is
1217
- /// available at the CLI; the full canonical-validate loop follows in a
1218
- /// later commit alongside the registry read implementation.
1219
- pub fn send_to_canonical_leader_target(
1220
- sender_workspace: &std::path::Path,
1221
- name: &str,
1222
- content: &str,
1223
- sender: &str,
1224
- task_id: Option<&str>,
1225
- ) -> 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
- }
1401
- Ok(value)
1402
- }