@team-agent/installer 0.4.11 → 0.5.1

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 (80) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +1 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +2 -15
  5. package/crates/team-agent/src/cli/emit.rs +98 -5
  6. package/crates/team-agent/src/cli/mod.rs +2 -0
  7. package/crates/team-agent/src/cli/named_address.rs +864 -0
  8. package/crates/team-agent/src/cli/send.rs +104 -0
  9. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/named_address.rs +455 -0
  12. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  13. package/crates/team-agent/src/cli/types.rs +4 -0
  14. package/crates/team-agent/src/compiler.rs +11 -20
  15. package/crates/team-agent/src/coordinator/orphan.rs +2 -2
  16. package/crates/team-agent/src/coordinator/runtime_detectors.rs +5 -4
  17. package/crates/team-agent/src/coordinator/steps/abnormal.rs +1776 -2
  18. package/crates/team-agent/src/coordinator/steps/mod.rs +19 -0
  19. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +78 -7
  20. package/crates/team-agent/src/coordinator/tests/abnormal.rs +195 -0
  21. package/crates/team-agent/src/coordinator/tick.rs +31 -932
  22. package/crates/team-agent/src/diagnose/orphans.rs +66 -8
  23. package/crates/team-agent/src/layout/worker_window_helpers.rs +5 -7
  24. package/crates/team-agent/src/leader/provider_attribution.rs +7 -5
  25. package/crates/team-agent/src/leader/rediscover.rs +1 -11
  26. package/crates/team-agent/src/lifecycle/launch/plan.rs +19 -8
  27. package/crates/team-agent/src/lifecycle/launch.rs +135 -58
  28. package/crates/team-agent/src/lifecycle/lock.rs +302 -0
  29. package/crates/team-agent/src/lifecycle/mod.rs +1 -0
  30. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -11
  31. package/crates/team-agent/src/lifecycle/restart/agent.rs +169 -113
  32. package/crates/team-agent/src/lifecycle/restart/common.rs +108 -17
  33. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +165 -32
  34. package/crates/team-agent/src/lifecycle/restart/remove.rs +16 -2
  35. package/crates/team-agent/src/lifecycle/restart/selection.rs +2 -1
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +6 -0
  37. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +3 -3
  38. package/crates/team-agent/src/lifecycle/tests/lifecycle_lock.rs +321 -0
  39. package/crates/team-agent/src/lifecycle/tests/main_preserved.rs +35 -5
  40. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +802 -0
  41. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +675 -0
  42. package/crates/team-agent/src/lifecycle/tests.rs +3 -0
  43. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  44. package/crates/team-agent/src/mcp_server/lifecycle_tools/state_status.rs +24 -9
  45. package/crates/team-agent/src/mcp_server/tools.rs +157 -68
  46. package/crates/team-agent/src/messaging/activity.rs +43 -18
  47. package/crates/team-agent/src/messaging/delivery.rs +161 -97
  48. package/crates/team-agent/src/messaging/helpers.rs +1 -0
  49. package/crates/team-agent/src/messaging/leader_receiver.rs +22 -1
  50. package/crates/team-agent/src/messaging/results.rs +34 -9
  51. package/crates/team-agent/src/messaging/scheduler.rs +52 -9
  52. package/crates/team-agent/src/model/spec.rs +10 -6
  53. package/crates/team-agent/src/provider/adapter.rs +10 -1038
  54. package/crates/team-agent/src/provider/adapters/claude.rs +3 -8
  55. package/crates/team-agent/src/provider/adapters/copilot.rs +2 -5
  56. package/crates/team-agent/src/provider/classify.rs +23 -48
  57. package/crates/team-agent/src/provider/command.rs +16 -7
  58. package/crates/team-agent/src/provider/faults.rs +93 -53
  59. package/crates/team-agent/src/provider/session/capture.rs +4 -15
  60. package/crates/team-agent/src/provider/session_scan/claude.rs +309 -0
  61. package/crates/team-agent/src/provider/session_scan/codex.rs +40 -0
  62. package/crates/team-agent/src/provider/session_scan/common.rs +350 -0
  63. package/crates/team-agent/src/provider/session_scan/copilot.rs +202 -0
  64. package/crates/team-agent/src/provider/session_scan.rs +65 -27
  65. package/crates/team-agent/src/provider/tests/faults.rs +80 -0
  66. package/crates/team-agent/src/provider/types.rs +33 -1
  67. package/crates/team-agent/src/provider/wire.rs +171 -1
  68. package/crates/team-agent/src/state/identity.rs +242 -57
  69. package/crates/team-agent/src/state/identity_keys.rs +9 -12
  70. package/crates/team-agent/src/state/mod.rs +5 -1
  71. package/crates/team-agent/src/state/owner_gate.rs +59 -15
  72. package/crates/team-agent/src/state/ownership.rs +12 -11
  73. package/crates/team-agent/src/state/paths.rs +13 -6
  74. package/crates/team-agent/src/state/persist.rs +671 -128
  75. package/crates/team-agent/src/state/projection.rs +240 -49
  76. package/crates/team-agent/src/state/selector.rs +11 -5
  77. package/crates/team-agent/src/tmux_backend/tests.rs +51 -2
  78. package/crates/team-agent/src/tmux_backend.rs +42 -5
  79. package/crates/team-agent/src/transport/test_support.rs +55 -6
  80. package/package.json +4 -4
@@ -7,6 +7,38 @@ 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_name) = args.to_name {
11
+ if args.pane.is_some() || args.target.is_some() || args.targets.is_some() {
12
+ return Err(CliError::Usage(
13
+ "--to-name and --pane/TARGET/--to are mutually exclusive: \
14
+ --to-name resolves a stable workspace/team/name to a live pane"
15
+ .to_string(),
16
+ ));
17
+ }
18
+ let content = args.message.join(" ");
19
+ if content.is_empty() {
20
+ return Err(CliError::Usage("--to-name requires a non-empty message".to_string()));
21
+ }
22
+ let (resolved, transport) =
23
+ match crate::cli::named_address::resolve_name_for_cli(&args.workspace, to_name) {
24
+ Ok(resolved) => resolved,
25
+ Err(error) if args.json => {
26
+ return Ok(CmdResult::from_json(error.to_json(), args.json));
27
+ }
28
+ Err(error) => return Err(CliError::Usage(error.n38_message())),
29
+ };
30
+ let mut value = send_to_named_pane_direct(
31
+ &args.workspace,
32
+ transport.as_ref(),
33
+ &resolved,
34
+ &content,
35
+ &args.sender,
36
+ args.task.as_deref(),
37
+ args.json,
38
+ )?;
39
+ add_send_reminder_if_ok(&mut value);
40
+ return Ok(CmdResult::from_json(value, args.json));
41
+ }
10
42
  // F1 (0.3.26, cross-team send): --pane <pane_id> direct targeting.
11
43
  // Mutually exclusive with target / --to (agent-name routing).
12
44
  if let Some(ref pane_id) = args.pane {
@@ -145,6 +177,78 @@ fn send_to_pane_direct(
145
177
  }))
146
178
  }
147
179
 
180
+ fn send_to_named_pane_direct(
181
+ sender_workspace: &Path,
182
+ transport: &dyn crate::transport::Transport,
183
+ resolved: &crate::cli::named_address::ResolvedNamedAddress,
184
+ content: &str,
185
+ sender: &str,
186
+ task_id: Option<&str>,
187
+ _json: bool,
188
+ ) -> Result<serde_json::Value, CliError> {
189
+ use crate::messaging::delivery::render_message;
190
+ use crate::transport::{InjectPayload, Key, PaneId, Target};
191
+
192
+ let message_id = format!("named_send_{}", chrono::Utc::now().timestamp_millis());
193
+ let rendered = render_message(sender, task_id, content, &message_id);
194
+ let target = Target::Pane(PaneId::new(&resolved.pane_id));
195
+ let sender_run_workspace = crate::model::paths::canonical_run_workspace(sender_workspace)
196
+ .unwrap_or_else(|_| sender_workspace.to_path_buf());
197
+ let event_log = crate::event_log::EventLog::new(&sender_run_workspace);
198
+ if let Some(warning) = &resolved.warning {
199
+ eprintln!("warning: {warning}");
200
+ }
201
+ let report = transport
202
+ .inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
203
+ .map_err(|e| {
204
+ CliError::Runtime(format!(
205
+ "inject to named target {} pane {} failed: {e}",
206
+ resolved.raw_name, resolved.pane_id
207
+ ))
208
+ })?;
209
+ let target_kind = named_target_kind_wire(resolved.target_kind);
210
+ let event = serde_json::json!({
211
+ "to_name": resolved.raw_name,
212
+ "target_kind": target_kind,
213
+ "sender": sender,
214
+ "sender_workspace": sender_run_workspace.display().to_string(),
215
+ "target_workspace": resolved.target_workspace.display().to_string(),
216
+ "team_key": resolved.team_key,
217
+ "agent_id": resolved.agent_id,
218
+ "pane_id": resolved.pane_id,
219
+ "session_name": resolved.session_name,
220
+ "window_name": resolved.window_name,
221
+ "tmux_endpoint": resolved.tmux_endpoint,
222
+ "state_pane_id": resolved.state_pane_id,
223
+ "state_pane_stale": resolved.state_pane_stale,
224
+ "agent_status": resolved.agent_status,
225
+ "warning": resolved.warning,
226
+ "message_id": message_id,
227
+ "submit_verification": crate::transport::submit_verification_wire(report.submit_verification),
228
+ "inject_verification": format!("{:?}", report.inject_verification),
229
+ });
230
+ let _ = event_log.write("send.name_direct", event.clone());
231
+ let ok = matches!(
232
+ report.submit_verification,
233
+ crate::transport::SubmitVerification::EnterSentWithoutPlaceholderCheck
234
+ | crate::transport::SubmitVerification::PastedContentPromptAbsentAfterSubmit
235
+ | crate::transport::SubmitVerification::KeySentAfterVisibleToken { .. }
236
+ );
237
+ let mut value = event;
238
+ if let Some(obj) = value.as_object_mut() {
239
+ obj.insert("ok".to_string(), serde_json::json!(ok));
240
+ }
241
+ Ok(value)
242
+ }
243
+
244
+ fn named_target_kind_wire(kind: crate::cli::named_address::NamedTargetKind) -> &'static str {
245
+ match kind {
246
+ crate::cli::named_address::NamedTargetKind::Worker => "worker",
247
+ crate::cli::named_address::NamedTargetKind::Leader => "leader",
248
+ crate::cli::named_address::NamedTargetKind::SessionWindow => "session_window",
249
+ }
250
+ }
251
+
148
252
  pub fn cmd_fallback_send_leader(args: &FallbackSendLeaderArgs) -> Result<CmdResult, CliError> {
149
253
  if let Some(value) = fallback_business_refusal(&args.primary_error, args.json) {
150
254
  return Ok(value);
@@ -50,6 +50,7 @@ use super::*;
50
50
  json: true,
51
51
  message_id: None,
52
52
  pane: None,
53
+ to_name: None,
53
54
  };
54
55
  let _ = cmd_send(&args);
55
56
 
@@ -87,6 +87,7 @@ mod divergence;
87
87
  mod lane_c;
88
88
  mod leader_watch;
89
89
  mod missing_subcommands;
90
+ mod named_address;
90
91
  mod repair_state_byte_lock;
91
92
  mod peer_allow;
92
93
  mod run_delegation;
@@ -0,0 +1,455 @@
1
+ use super::*;
2
+ use crate::transport::test_support::OfflineTransport;
3
+ use crate::transport::{PaneId, PaneInfo, SessionName, WindowName};
4
+ use serde_json::json;
5
+ use std::collections::BTreeMap;
6
+ use std::path::{Path, PathBuf};
7
+
8
+ fn named_ws(tag: &str) -> PathBuf {
9
+ use std::sync::atomic::{AtomicU64, Ordering};
10
+ static N: AtomicU64 = AtomicU64::new(0);
11
+ let dir = std::env::temp_dir().join(format!(
12
+ "ta-named-{tag}-{}-{}",
13
+ std::process::id(),
14
+ N.fetch_add(1, Ordering::Relaxed)
15
+ ));
16
+ std::fs::create_dir_all(dir.join(".team").join("runtime")).unwrap();
17
+ dir
18
+ }
19
+
20
+ fn seed_state(ws: &Path, state: serde_json::Value) {
21
+ std::fs::create_dir_all(ws.join(".team").join("runtime")).unwrap();
22
+ std::fs::write(
23
+ ws.join(".team").join("runtime").join("state.json"),
24
+ serde_json::to_vec_pretty(&state).unwrap(),
25
+ )
26
+ .unwrap();
27
+ }
28
+
29
+ fn state_with_teams(teams: serde_json::Value) -> serde_json::Value {
30
+ json!({
31
+ "session_name": "projected",
32
+ "tmux_endpoint": "workspace-socket",
33
+ "teams": teams,
34
+ })
35
+ }
36
+
37
+ fn worker_team(session: &str, agent: &str, pane: &str, window: &str) -> serde_json::Value {
38
+ json!({
39
+ "session_name": session,
40
+ "tmux_endpoint": format!("{session}-socket"),
41
+ "agents": {
42
+ agent: {
43
+ "status": "running",
44
+ "pane_id": pane,
45
+ "window": window,
46
+ }
47
+ }
48
+ })
49
+ }
50
+
51
+ fn worker_team_with_status(
52
+ session: &str,
53
+ agent: &str,
54
+ pane: &str,
55
+ window: &str,
56
+ status: &str,
57
+ ) -> serde_json::Value {
58
+ let mut team = worker_team(session, agent, pane, window);
59
+ team["agents"][agent]["status"] = json!(status);
60
+ team
61
+ }
62
+
63
+ fn leader_team(session: &str, pane: &str, socket: &str) -> serde_json::Value {
64
+ json!({
65
+ "session_name": session,
66
+ "tmux_endpoint": format!("{session}-worker-socket"),
67
+ "agents": {},
68
+ "leader_receiver": {
69
+ "pane_id": pane,
70
+ "session_name": format!("{session}-leader"),
71
+ "window_name": "codex",
72
+ "tmux_socket": socket,
73
+ "status": "attached"
74
+ }
75
+ })
76
+ }
77
+
78
+ fn pane(session: &str, window: &str, pane_id: &str) -> PaneInfo {
79
+ PaneInfo {
80
+ pane_id: PaneId::new(pane_id),
81
+ session: SessionName::new(session),
82
+ window_index: None,
83
+ window_name: Some(WindowName::new(window)),
84
+ pane_index: None,
85
+ tty: None,
86
+ current_command: None,
87
+ current_path: None,
88
+ active: false,
89
+ pane_pid: None,
90
+ leader_env: BTreeMap::new(),
91
+ }
92
+ }
93
+
94
+ fn assert_kind(
95
+ result: Result<ResolvedNamedAddress, NamedAddressError>,
96
+ kind: NamedAddressErrorKind,
97
+ ) {
98
+ let err = result.unwrap_err();
99
+ assert_eq!(err.kind, kind, "{err}");
100
+ let n38 = err.n38_message();
101
+ assert!(n38.contains("Error: "), "{n38}");
102
+ assert!(n38.contains("Action: "), "{n38}");
103
+ assert!(n38.contains("Log: "), "{n38}");
104
+ }
105
+
106
+ fn named_send_args(
107
+ ws: &Path,
108
+ to_name: Option<&str>,
109
+ pane: Option<&str>,
110
+ targets: Option<&str>,
111
+ message: &[&str],
112
+ ) -> SendArgs {
113
+ SendArgs {
114
+ target: None,
115
+ message: message.iter().map(|part| (*part).to_string()).collect(),
116
+ targets: targets.map(str::to_string),
117
+ workspace: ws.to_path_buf(),
118
+ team: None,
119
+ task: None,
120
+ sender: "leader".to_string(),
121
+ no_ack: false,
122
+ no_wait: false,
123
+ watch_result: false,
124
+ timeout: 0.0,
125
+ confirm_human: false,
126
+ json: true,
127
+ message_id: None,
128
+ pane: pane.map(str::to_string),
129
+ to_name: to_name.map(str::to_string),
130
+ }
131
+ }
132
+
133
+ #[test]
134
+ fn resolve_name_current_team_unique() {
135
+ let ws = named_ws("unique");
136
+ seed_state(
137
+ &ws,
138
+ state_with_teams(json!({
139
+ "team-a": worker_team("team-a", "qa", "%old", "qa")
140
+ })),
141
+ );
142
+ let transport = OfflineTransport::new().with_targets(vec![pane("team-a", "qa", "%1")]);
143
+
144
+ let resolved = resolve_name_with_transport(&ws, "qa", &transport).unwrap();
145
+ assert_eq!(resolved.pane_id, "%1");
146
+ assert_eq!(resolved.team_key.as_deref(), Some("team-a"));
147
+ assert_eq!(resolved.agent_id.as_deref(), Some("qa"));
148
+ assert_eq!(resolved.target_kind, NamedTargetKind::Worker);
149
+ let _ = std::fs::remove_dir_all(&ws);
150
+ }
151
+
152
+ #[test]
153
+ fn resolve_name_ambiguous_same_role() {
154
+ let ws = named_ws("ambiguous-role");
155
+ seed_state(
156
+ &ws,
157
+ state_with_teams(json!({
158
+ "team-a": worker_team("team-a", "qa", "%1", "qa"),
159
+ "team-b": worker_team("team-b", "qa", "%2", "qa")
160
+ })),
161
+ );
162
+ let transport = OfflineTransport::new()
163
+ .with_targets(vec![pane("team-a", "qa", "%1"), pane("team-b", "qa", "%2")]);
164
+
165
+ let err = resolve_name_with_transport(&ws, "qa", &transport).unwrap_err();
166
+ assert_eq!(err.kind, NamedAddressErrorKind::NameAmbiguous);
167
+ let candidates = serde_json::to_string(&err.candidates).unwrap();
168
+ assert!(candidates.contains("team-a/qa"), "{candidates}");
169
+ assert!(candidates.contains("team-b/qa"), "{candidates}");
170
+ let _ = std::fs::remove_dir_all(&ws);
171
+ }
172
+
173
+ #[test]
174
+ fn resolve_name_team_disambiguated() {
175
+ let ws = named_ws("team-disambiguated");
176
+ seed_state(
177
+ &ws,
178
+ state_with_teams(json!({
179
+ "team-a": worker_team("team-a", "qa", "%1", "qa"),
180
+ "team-b": worker_team("team-b", "qa", "%2", "qa")
181
+ })),
182
+ );
183
+ let transport = OfflineTransport::new()
184
+ .with_targets(vec![pane("team-a", "qa", "%1"), pane("team-b", "qa", "%2")]);
185
+
186
+ let resolved = resolve_name_with_transport(&ws, "team-b/qa", &transport).unwrap();
187
+ assert_eq!(resolved.pane_id, "%2");
188
+ assert_eq!(resolved.team_key.as_deref(), Some("team-b"));
189
+ let _ = std::fs::remove_dir_all(&ws);
190
+ }
191
+
192
+ #[test]
193
+ fn resolve_name_stale_pane() {
194
+ let ws = named_ws("stale-pane");
195
+ seed_state(
196
+ &ws,
197
+ state_with_teams(json!({
198
+ "team-a": worker_team("team-a", "qa", "%old", "qa")
199
+ })),
200
+ );
201
+ let transport = OfflineTransport::new().with_targets(vec![pane("team-a", "qa", "%new")]);
202
+
203
+ let resolved = resolve_name_with_transport(&ws, "team-a/qa", &transport).unwrap();
204
+ assert_eq!(resolved.pane_id, "%new");
205
+ assert_eq!(resolved.state_pane_id.as_deref(), Some("%old"));
206
+ assert!(resolved.state_pane_stale);
207
+ let _ = std::fs::remove_dir_all(&ws);
208
+ }
209
+
210
+ #[test]
211
+ fn resolve_name_cross_workspace_socket() {
212
+ let sender = named_ws("sender");
213
+ let target = named_ws("target");
214
+ seed_state(&sender, state_with_teams(json!({})));
215
+ seed_state(
216
+ &target,
217
+ state_with_teams(json!({
218
+ "alpha": worker_team("alpha-session", "qa", "%target-old", "qa")
219
+ })),
220
+ );
221
+ let transport = OfflineTransport::new()
222
+ .with_tmux_endpoint("target-socket")
223
+ .with_targets(vec![pane("alpha-session", "qa", "%target-live")]);
224
+
225
+ let resolved = resolve_name_with_transport(
226
+ &sender,
227
+ &format!("{}::alpha/qa", target.display()),
228
+ &transport,
229
+ )
230
+ .unwrap();
231
+ assert_eq!(resolved.sender_workspace, sender);
232
+ assert_eq!(resolved.target_workspace, target);
233
+ assert_eq!(resolved.tmux_endpoint.as_deref(), Some("target-socket"));
234
+ assert_eq!(resolved.pane_id, "%target-live");
235
+ let _ = std::fs::remove_dir_all(&sender);
236
+ let _ = std::fs::remove_dir_all(&target);
237
+ }
238
+
239
+ #[test]
240
+ fn send_to_name_mutual_exclusion() {
241
+ let cwd = named_ws("mutual");
242
+ let args = named_send_args(&cwd, Some("team-a/qa"), Some("%1"), None, &["hello"]);
243
+ let err = cmd_send(&args).unwrap_err();
244
+ assert!(
245
+ matches!(err, CliError::Usage(ref message) if message.contains("--to-name and --pane/TARGET/--to are mutually exclusive")),
246
+ "expected --to-name mutual exclusion, got {err:?}"
247
+ );
248
+
249
+ let args = named_send_args(&cwd, Some("team-a/qa"), None, Some("worker"), &["hello"]);
250
+ let err = cmd_send(&args).unwrap_err();
251
+ assert!(
252
+ matches!(err, CliError::Usage(ref message) if message.contains("--to-name and --pane/TARGET/--to are mutually exclusive")),
253
+ "expected --to-name/--to mutual exclusion, got {err:?}"
254
+ );
255
+
256
+ let args = named_send_args(&cwd, Some("team-a/qa"), None, None, &[]);
257
+ let err = cmd_send(&args).unwrap_err();
258
+ assert!(
259
+ matches!(err, CliError::Usage(ref message) if message == "--to-name requires a non-empty message"),
260
+ "expected empty-message usage error, got {err:?}"
261
+ );
262
+ let _ = std::fs::remove_dir_all(&cwd);
263
+ }
264
+
265
+ #[test]
266
+ fn resolve_leader_name_uses_receiver_socket() {
267
+ let ws = named_ws("leader-socket");
268
+ seed_state(
269
+ &ws,
270
+ state_with_teams(json!({
271
+ "alpha": leader_team("alpha-session", "%leader", "/tmp/leader.sock")
272
+ })),
273
+ );
274
+ let transport = OfflineTransport::new()
275
+ .with_tmux_endpoint("/tmp/leader.sock")
276
+ .with_targets(vec![pane("alpha-session-leader", "codex", "%leader")]);
277
+
278
+ let resolved = resolve_name_with_transport(&ws, "alpha/leader", &transport).unwrap();
279
+ assert_eq!(resolved.target_kind, NamedTargetKind::Leader);
280
+ assert_eq!(resolved.pane_id, "%leader");
281
+ assert_eq!(resolved.tmux_endpoint.as_deref(), Some("/tmp/leader.sock"));
282
+ let _ = std::fs::remove_dir_all(&ws);
283
+ }
284
+
285
+ #[test]
286
+ fn resolve_leader_name_not_live() {
287
+ let ws = named_ws("leader-not-live");
288
+ seed_state(
289
+ &ws,
290
+ state_with_teams(json!({
291
+ "alpha": leader_team("alpha-session", "%missing", "/tmp/leader.sock")
292
+ })),
293
+ );
294
+ let transport = OfflineTransport::new()
295
+ .with_tmux_endpoint("/tmp/leader.sock")
296
+ .with_targets(vec![pane("other", "codex", "%other")]);
297
+
298
+ let err = resolve_name_with_transport(&ws, "alpha/leader", &transport).unwrap_err();
299
+ assert_eq!(err.kind, NamedAddressErrorKind::NameNotLive);
300
+ let n38 = err.n38_message();
301
+ assert!(n38.contains("claim-leader"), "{n38}");
302
+ assert!(n38.contains("attach-leader"), "{n38}");
303
+ assert!(n38.contains("takeover"), "{n38}");
304
+ let _ = std::fs::remove_dir_all(&ws);
305
+ }
306
+
307
+ #[test]
308
+ fn resolve_leader_name_after_rebind() {
309
+ let ws = named_ws("leader-rebind");
310
+ seed_state(
311
+ &ws,
312
+ state_with_teams(json!({
313
+ "alpha": leader_team("alpha-session", "%new", "/tmp/leader.sock")
314
+ })),
315
+ );
316
+ let transport = OfflineTransport::new()
317
+ .with_tmux_endpoint("/tmp/leader.sock")
318
+ .with_targets(vec![pane("alpha-session-leader", "codex", "%new")]);
319
+
320
+ let resolved = resolve_name_with_transport(&ws, "alpha/leader", &transport).unwrap();
321
+ assert_eq!(resolved.pane_id, "%new");
322
+ assert_eq!(resolved.target_kind, NamedTargetKind::Leader);
323
+ let _ = std::fs::remove_dir_all(&ws);
324
+ }
325
+
326
+ #[test]
327
+ fn resolve_name_invalid_syntax() {
328
+ let ws = named_ws("invalid");
329
+ let transport = OfflineTransport::new();
330
+ assert_kind(
331
+ resolve_name_with_transport(&ws, "", &transport),
332
+ NamedAddressErrorKind::NameInvalid,
333
+ );
334
+ assert_kind(
335
+ resolve_name_with_transport(&ws, "team//qa", &transport),
336
+ NamedAddressErrorKind::NameInvalid,
337
+ );
338
+ let _ = std::fs::remove_dir_all(&ws);
339
+ }
340
+
341
+ #[test]
342
+ fn resolve_name_workspace_not_found() {
343
+ let ws = named_ws("sender-missing-target");
344
+ let missing = std::env::temp_dir().join(format!(
345
+ "ta-named-missing-{}-{}",
346
+ std::process::id(),
347
+ chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
348
+ ));
349
+ let transport = OfflineTransport::new();
350
+ assert_kind(
351
+ resolve_name_with_transport(&ws, &format!("{}::alpha/qa", missing.display()), &transport),
352
+ NamedAddressErrorKind::WorkspaceNotFound,
353
+ );
354
+ let _ = std::fs::remove_dir_all(&ws);
355
+ }
356
+
357
+ #[test]
358
+ fn resolve_name_state_missing() {
359
+ let ws = named_ws("state-missing");
360
+ let state_path = ws.join(".team").join("runtime").join("state.json");
361
+ let _ = std::fs::remove_file(state_path);
362
+ let transport = OfflineTransport::new();
363
+ assert_kind(
364
+ resolve_name_with_transport(&ws, "alpha/qa", &transport),
365
+ NamedAddressErrorKind::StateNotFound,
366
+ );
367
+ let _ = std::fs::remove_dir_all(&ws);
368
+ }
369
+
370
+ #[test]
371
+ fn resolve_name_same_team_key_across_workspaces() {
372
+ let sender = named_ws("same-team-sender");
373
+ let other = named_ws("same-team-other");
374
+ seed_state(
375
+ &sender,
376
+ state_with_teams(json!({
377
+ "alpha": worker_team("alpha-a", "qa", "%a", "qa")
378
+ })),
379
+ );
380
+ seed_state(
381
+ &other,
382
+ state_with_teams(json!({
383
+ "alpha": worker_team("alpha-b", "qa", "%b", "qa")
384
+ })),
385
+ );
386
+ let sender_transport = OfflineTransport::new().with_targets(vec![pane("alpha-a", "qa", "%a")]);
387
+ let other_transport = OfflineTransport::new().with_targets(vec![pane("alpha-b", "qa", "%b")]);
388
+
389
+ let err = resolve_name_with_workspace_transports(
390
+ &sender,
391
+ "alpha/qa",
392
+ &[
393
+ NamedWorkspaceTransport {
394
+ workspace: &sender,
395
+ transport: &sender_transport,
396
+ },
397
+ NamedWorkspaceTransport {
398
+ workspace: &other,
399
+ transport: &other_transport,
400
+ },
401
+ ],
402
+ )
403
+ .unwrap_err();
404
+ assert_eq!(err.kind, NamedAddressErrorKind::NameAmbiguous);
405
+ assert!(err.n38_message().contains("<workspace>::alpha/qa"));
406
+ let _ = std::fs::remove_dir_all(&sender);
407
+ let _ = std::fs::remove_dir_all(&other);
408
+ }
409
+
410
+ #[test]
411
+ fn resolve_paused_agent_with_live_pane_succeeds_with_warning() {
412
+ let ws = named_ws("paused-live");
413
+ seed_state(
414
+ &ws,
415
+ state_with_teams(json!({
416
+ "alpha": worker_team_with_status("alpha-session", "qa", "%paused", "qa", "paused")
417
+ })),
418
+ );
419
+ let transport =
420
+ OfflineTransport::new().with_targets(vec![pane("alpha-session", "qa", "%paused")]);
421
+
422
+ let resolved = resolve_name_with_transport(&ws, "alpha/qa", &transport).unwrap();
423
+ assert_eq!(resolved.pane_id, "%paused");
424
+ assert_eq!(resolved.agent_status.as_deref(), Some("paused"));
425
+ assert!(resolved.warning.as_deref().unwrap_or("").contains("paused"));
426
+ let _ = std::fs::remove_dir_all(&ws);
427
+ }
428
+
429
+ #[test]
430
+ fn named_address_resolver_does_not_write_leader_receiver_or_owner_fields() {
431
+ let src = std::fs::read_to_string(concat!(
432
+ env!("CARGO_MANIFEST_DIR"),
433
+ "/src/cli/named_address.rs"
434
+ ))
435
+ .unwrap();
436
+ for forbidden in ["leader_receiver.*=", "team_owner.*=", "owner_epoch.*="] {
437
+ assert!(
438
+ !src.contains(forbidden),
439
+ "resolver must not contain binding write pattern {forbidden}"
440
+ );
441
+ }
442
+ for line in src.lines() {
443
+ let trimmed = line.trim();
444
+ for field in ["leader_receiver", "team_owner", "owner_epoch"] {
445
+ assert!(
446
+ !(trimmed.starts_with(field) && trimmed.contains('=')),
447
+ "resolver must not assign binding field `{field}`: {trimmed}"
448
+ );
449
+ assert!(
450
+ !(trimmed.starts_with(&format!(".{field}")) && trimmed.contains('=')),
451
+ "resolver must not assign binding field `{field}`: {trimmed}"
452
+ );
453
+ }
454
+ }
455
+ }
@@ -447,6 +447,7 @@ use super::*;
447
447
  json: false,
448
448
  message_id: None,
449
449
  pane: None,
450
+ to_name: None,
450
451
  }
451
452
  }
452
453
 
@@ -323,6 +323,10 @@ pub struct SendArgs {
323
323
  /// the **cross-team communication** primitive: the target pane does not
324
324
  /// need to be in the sender's team.
325
325
  pub pane: Option<String>,
326
+ /// `--to-name <name>` — stable named addressing. Mutually exclusive with
327
+ /// `target` / `targets` / `--pane`; resolves workspace/team/agent or leader
328
+ /// name to the current live pane before using direct pane injection.
329
+ pub to_name: Option<String>,
326
330
  }
327
331
 
328
332
  /// E23 worker-side emergency fallback for `team_orchestrator.send_message`
@@ -29,6 +29,10 @@ use std::path::Path;
29
29
  use crate::model::enums::{Provider, ProviderEffort};
30
30
  use crate::model::yaml::Value;
31
31
  use crate::model::{paths, spec, yaml, ModelError};
32
+ use crate::provider::wire::{
33
+ builtin_provider_model as wire_builtin_provider_model, is_claude_family,
34
+ parse_canonical_provider, provider_model_keys,
35
+ };
32
36
 
33
37
  pub const IGNORED_OWNER_TEAM_ID_FIELD: &str = "owner_team_id";
34
38
 
@@ -417,16 +421,8 @@ pub fn compile_role_agent(
417
421
  _ => None,
418
422
  })
419
423
  .unwrap_or("");
420
- let provider_enum = match provider_str {
421
- "claude" => Provider::Claude,
422
- "claude_code" => Provider::ClaudeCode,
423
- "codex" => Provider::Codex,
424
- "copilot" => Provider::Copilot,
425
- "gemini_cli" => Provider::GeminiCli,
426
- "fake" => Provider::Fake,
427
- _ => Provider::Codex, // unknown providers caught elsewhere
428
- };
429
- if effort.is_claude_only() && !matches!(provider_enum, Provider::Claude | Provider::ClaudeCode) {
424
+ let provider_enum = parse_canonical_provider(provider_str).unwrap_or(Provider::Codex);
425
+ if effort.is_claude_only() && !is_claude_family(provider_enum) {
430
426
  return Err(ModelError::Validation(format!(
431
427
  "{}: effort '{}' is only supported by claude/claude_code (provider: {provider_str})",
432
428
  role_path.display(),
@@ -543,19 +539,14 @@ fn resolve_model(role_meta: &Value, team_meta: &Value, provider: &str) -> Value
543
539
 
544
540
  fn provider_model(team_meta: &Value, provider: &str) -> Option<String> {
545
541
  let models = team_meta.get("provider_models")?;
546
- string_field(models, provider).or_else(|| match provider {
547
- "claude_code" => string_field(models, "claude"),
548
- "claude" => string_field(models, "claude_code"),
549
- _ => None,
550
- })
542
+ let provider = parse_canonical_provider(provider)?;
543
+ provider_model_keys(provider)
544
+ .iter()
545
+ .find_map(|key| string_field(models, key))
551
546
  }
552
547
 
553
548
  fn builtin_provider_model(provider: &str) -> Option<&'static str> {
554
- match provider {
555
- "claude" | "claude_code" => Some("claude-sonnet-4-6"),
556
- "codex" => Some("gpt-5.5"),
557
- _ => None,
558
- }
549
+ parse_canonical_provider(provider).and_then(wire_builtin_provider_model)
559
550
  }
560
551
 
561
552
  fn non_empty_trimmed(text: &str) -> Option<String> {
@@ -31,8 +31,8 @@ pub fn should_orphan_self_terminate(
31
31
  /// #236 dedicated `worker.abnormal_exit` surface.
32
32
  ///
33
33
  /// This is intentionally separate from `process_abnormal_records`: the generic
34
- /// abnormal path is transcript-only, while `worker.abnormal_exit` requires the
35
- /// provider-process-dead AND latest-explicit-error gate before leader notification.
34
+ /// abnormal path is transcript-only, while `worker.abnormal_exit` requires a fresh
35
+ /// latest explicit provider error before leader notification.
36
36
  #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
37
37
  #[serde(tag = "event")]
38
38
  pub enum WorkerAbnormalExitSurface {