@team-agent/installer 0.5.0 → 0.5.2

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 (40) hide show
  1. package/Cargo.lock +118 -9
  2. package/Cargo.toml +2 -2
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/app_server_test_support.rs +258 -0
  5. package/crates/team-agent/src/cli/adapters.rs +32 -3
  6. package/crates/team-agent/src/cli/attach_app_server_leader.rs +16 -0
  7. package/crates/team-agent/src/cli/diagnose.rs +14 -5
  8. package/crates/team-agent/src/cli/emit.rs +111 -3
  9. package/crates/team-agent/src/cli/mod.rs +72 -25
  10. package/crates/team-agent/src/cli/named_address.rs +975 -0
  11. package/crates/team-agent/src/cli/send.rs +200 -1
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/leader_watch.rs +1 -0
  14. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  15. package/crates/team-agent/src/cli/tests/named_address.rs +586 -0
  16. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  17. package/crates/team-agent/src/cli/tests/status_send.rs +1 -0
  18. package/crates/team-agent/src/cli/types.rs +21 -0
  19. package/crates/team-agent/src/codex_app_server.rs +488 -0
  20. package/crates/team-agent/src/conpty/backend.rs +710 -0
  21. package/crates/team-agent/src/conpty/mod.rs +32 -0
  22. package/crates/team-agent/src/coordinator/backoff.rs +45 -6
  23. package/crates/team-agent/src/diagnose/orphans.rs +11 -3
  24. package/crates/team-agent/src/leader/lease.rs +144 -7
  25. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  26. package/crates/team-agent/src/leader/start.rs +18 -5
  27. package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
  28. package/crates/team-agent/src/lib.rs +23 -0
  29. package/crates/team-agent/src/lifecycle/launch.rs +127 -3
  30. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  31. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  32. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +90 -0
  33. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  34. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  35. package/crates/team-agent/src/messaging/mod.rs +2 -2
  36. package/crates/team-agent/src/messaging/results.rs +78 -21
  37. package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
  38. package/crates/team-agent/src/tmux_backend.rs +31 -0
  39. package/crates/team-agent/src/transport_factory.rs +632 -0
  40. package/package.json +4 -4
@@ -0,0 +1,586 @@
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 app_server_leader_team(
79
+ ws: &Path,
80
+ endpoint: &str,
81
+ thread_id: &str,
82
+ session_id: &str,
83
+ ) -> serde_json::Value {
84
+ json!({
85
+ "session_name": "alpha-session",
86
+ "agents": {},
87
+ "leader_receiver": {
88
+ "mode": "codex_app_server",
89
+ "transport_kind": "codex_app_server",
90
+ "status": "attached",
91
+ "provider": "codex",
92
+ "owner_epoch": 3,
93
+ "app_server": {
94
+ "socket": endpoint,
95
+ "thread_id": thread_id,
96
+ "session_id": session_id,
97
+ "cwd": ws.to_string_lossy(),
98
+ "cli_version": "codex-appserver-team-agent-test/0.139.0",
99
+ "bound_at": "2026-07-05T00:00:00Z"
100
+ }
101
+ },
102
+ "team_owner": {
103
+ "provider": "codex",
104
+ "transport_kind": "codex_app_server",
105
+ "owner_epoch": 3
106
+ }
107
+ })
108
+ }
109
+
110
+ fn pane(session: &str, window: &str, pane_id: &str) -> PaneInfo {
111
+ PaneInfo {
112
+ pane_id: PaneId::new(pane_id),
113
+ session: SessionName::new(session),
114
+ window_index: None,
115
+ window_name: Some(WindowName::new(window)),
116
+ pane_index: None,
117
+ tty: None,
118
+ current_command: None,
119
+ current_path: None,
120
+ active: false,
121
+ pane_pid: None,
122
+ leader_env: BTreeMap::new(),
123
+ }
124
+ }
125
+
126
+ fn assert_kind(
127
+ result: Result<ResolvedNamedAddress, NamedAddressError>,
128
+ kind: NamedAddressErrorKind,
129
+ ) {
130
+ let err = result.unwrap_err();
131
+ assert_eq!(err.kind, kind, "{err}");
132
+ let n38 = err.n38_message();
133
+ assert!(n38.contains("Error: "), "{n38}");
134
+ assert!(n38.contains("Action: "), "{n38}");
135
+ assert!(n38.contains("Log: "), "{n38}");
136
+ }
137
+
138
+ fn named_send_args(
139
+ ws: &Path,
140
+ to_name: Option<&str>,
141
+ pane: Option<&str>,
142
+ targets: Option<&str>,
143
+ message: &[&str],
144
+ ) -> SendArgs {
145
+ SendArgs {
146
+ target: None,
147
+ message: message.iter().map(|part| (*part).to_string()).collect(),
148
+ targets: targets.map(str::to_string),
149
+ workspace: ws.to_path_buf(),
150
+ team: None,
151
+ task: None,
152
+ sender: "leader".to_string(),
153
+ no_ack: false,
154
+ no_wait: false,
155
+ watch_result: false,
156
+ timeout: 0.0,
157
+ confirm_human: false,
158
+ json: true,
159
+ message_id: None,
160
+ pane: pane.map(str::to_string),
161
+ to_name: to_name.map(str::to_string),
162
+ }
163
+ }
164
+
165
+ #[test]
166
+ fn resolve_name_current_team_unique() {
167
+ let ws = named_ws("unique");
168
+ seed_state(
169
+ &ws,
170
+ state_with_teams(json!({
171
+ "team-a": worker_team("team-a", "qa", "%old", "qa")
172
+ })),
173
+ );
174
+ let transport = OfflineTransport::new().with_targets(vec![pane("team-a", "qa", "%1")]);
175
+
176
+ let resolved = resolve_name_with_transport(&ws, "qa", &transport).unwrap();
177
+ assert_eq!(resolved.pane_id, "%1");
178
+ assert_eq!(resolved.team_key.as_deref(), Some("team-a"));
179
+ assert_eq!(resolved.agent_id.as_deref(), Some("qa"));
180
+ assert_eq!(resolved.target_kind, NamedTargetKind::Worker);
181
+ let _ = std::fs::remove_dir_all(&ws);
182
+ }
183
+
184
+ #[test]
185
+ fn resolve_name_ambiguous_same_role() {
186
+ let ws = named_ws("ambiguous-role");
187
+ seed_state(
188
+ &ws,
189
+ state_with_teams(json!({
190
+ "team-a": worker_team("team-a", "qa", "%1", "qa"),
191
+ "team-b": worker_team("team-b", "qa", "%2", "qa")
192
+ })),
193
+ );
194
+ let transport = OfflineTransport::new()
195
+ .with_targets(vec![pane("team-a", "qa", "%1"), pane("team-b", "qa", "%2")]);
196
+
197
+ let err = resolve_name_with_transport(&ws, "qa", &transport).unwrap_err();
198
+ assert_eq!(err.kind, NamedAddressErrorKind::NameAmbiguous);
199
+ let candidates = serde_json::to_string(&err.candidates).unwrap();
200
+ assert!(candidates.contains("team-a/qa"), "{candidates}");
201
+ assert!(candidates.contains("team-b/qa"), "{candidates}");
202
+ let _ = std::fs::remove_dir_all(&ws);
203
+ }
204
+
205
+ #[test]
206
+ fn resolve_name_team_disambiguated() {
207
+ let ws = named_ws("team-disambiguated");
208
+ seed_state(
209
+ &ws,
210
+ state_with_teams(json!({
211
+ "team-a": worker_team("team-a", "qa", "%1", "qa"),
212
+ "team-b": worker_team("team-b", "qa", "%2", "qa")
213
+ })),
214
+ );
215
+ let transport = OfflineTransport::new()
216
+ .with_targets(vec![pane("team-a", "qa", "%1"), pane("team-b", "qa", "%2")]);
217
+
218
+ let resolved = resolve_name_with_transport(&ws, "team-b/qa", &transport).unwrap();
219
+ assert_eq!(resolved.pane_id, "%2");
220
+ assert_eq!(resolved.team_key.as_deref(), Some("team-b"));
221
+ let _ = std::fs::remove_dir_all(&ws);
222
+ }
223
+
224
+ #[test]
225
+ fn resolve_name_stale_pane() {
226
+ let ws = named_ws("stale-pane");
227
+ seed_state(
228
+ &ws,
229
+ state_with_teams(json!({
230
+ "team-a": worker_team("team-a", "qa", "%old", "qa")
231
+ })),
232
+ );
233
+ let transport = OfflineTransport::new().with_targets(vec![pane("team-a", "qa", "%new")]);
234
+
235
+ let resolved = resolve_name_with_transport(&ws, "team-a/qa", &transport).unwrap();
236
+ assert_eq!(resolved.pane_id, "%new");
237
+ assert_eq!(resolved.state_pane_id.as_deref(), Some("%old"));
238
+ assert!(resolved.state_pane_stale);
239
+ let _ = std::fs::remove_dir_all(&ws);
240
+ }
241
+
242
+ #[test]
243
+ fn resolve_name_cross_workspace_socket() {
244
+ let sender = named_ws("sender");
245
+ let target = named_ws("target");
246
+ seed_state(&sender, state_with_teams(json!({})));
247
+ seed_state(
248
+ &target,
249
+ state_with_teams(json!({
250
+ "alpha": worker_team("alpha-session", "qa", "%target-old", "qa")
251
+ })),
252
+ );
253
+ let transport = OfflineTransport::new()
254
+ .with_tmux_endpoint("target-socket")
255
+ .with_targets(vec![pane("alpha-session", "qa", "%target-live")]);
256
+
257
+ let resolved = resolve_name_with_transport(
258
+ &sender,
259
+ &format!("{}::alpha/qa", target.display()),
260
+ &transport,
261
+ )
262
+ .unwrap();
263
+ assert_eq!(resolved.sender_workspace, sender);
264
+ assert_eq!(resolved.target_workspace, target);
265
+ assert_eq!(resolved.tmux_endpoint.as_deref(), Some("target-socket"));
266
+ assert_eq!(resolved.pane_id, "%target-live");
267
+ let _ = std::fs::remove_dir_all(&sender);
268
+ let _ = std::fs::remove_dir_all(&target);
269
+ }
270
+
271
+ #[test]
272
+ fn send_to_name_mutual_exclusion() {
273
+ let cwd = named_ws("mutual");
274
+ let args = named_send_args(&cwd, Some("team-a/qa"), Some("%1"), None, &["hello"]);
275
+ let err = cmd_send(&args).unwrap_err();
276
+ assert!(
277
+ matches!(err, CliError::Usage(ref message) if message.contains("--to-name and --pane/TARGET/--to are mutually exclusive")),
278
+ "expected --to-name mutual exclusion, got {err:?}"
279
+ );
280
+
281
+ let args = named_send_args(&cwd, Some("team-a/qa"), None, Some("worker"), &["hello"]);
282
+ let err = cmd_send(&args).unwrap_err();
283
+ assert!(
284
+ matches!(err, CliError::Usage(ref message) if message.contains("--to-name and --pane/TARGET/--to are mutually exclusive")),
285
+ "expected --to-name/--to mutual exclusion, got {err:?}"
286
+ );
287
+
288
+ let args = named_send_args(&cwd, Some("team-a/qa"), None, None, &[]);
289
+ let err = cmd_send(&args).unwrap_err();
290
+ assert!(
291
+ matches!(err, CliError::Usage(ref message) if message == "--to-name requires a non-empty message"),
292
+ "expected empty-message usage error, got {err:?}"
293
+ );
294
+ let _ = std::fs::remove_dir_all(&cwd);
295
+ }
296
+
297
+ #[test]
298
+ fn resolve_leader_name_uses_receiver_socket() {
299
+ let ws = named_ws("leader-socket");
300
+ seed_state(
301
+ &ws,
302
+ state_with_teams(json!({
303
+ "alpha": leader_team("alpha-session", "%leader", "/tmp/leader.sock")
304
+ })),
305
+ );
306
+ let transport = OfflineTransport::new()
307
+ .with_tmux_endpoint("/tmp/leader.sock")
308
+ .with_targets(vec![pane("alpha-session-leader", "codex", "%leader")]);
309
+
310
+ let resolved = resolve_name_with_transport(&ws, "alpha/leader", &transport).unwrap();
311
+ assert_eq!(resolved.target_kind, NamedTargetKind::Leader);
312
+ assert_eq!(resolved.pane_id, "%leader");
313
+ assert_eq!(resolved.tmux_endpoint.as_deref(), Some("/tmp/leader.sock"));
314
+ let _ = std::fs::remove_dir_all(&ws);
315
+ }
316
+
317
+ #[test]
318
+ fn resolve_leader_name_not_live() {
319
+ let ws = named_ws("leader-not-live");
320
+ seed_state(
321
+ &ws,
322
+ state_with_teams(json!({
323
+ "alpha": leader_team("alpha-session", "%missing", "/tmp/leader.sock")
324
+ })),
325
+ );
326
+ let transport = OfflineTransport::new()
327
+ .with_tmux_endpoint("/tmp/leader.sock")
328
+ .with_targets(vec![pane("other", "codex", "%other")]);
329
+
330
+ let err = resolve_name_with_transport(&ws, "alpha/leader", &transport).unwrap_err();
331
+ assert_eq!(err.kind, NamedAddressErrorKind::NameNotLive);
332
+ let n38 = err.n38_message();
333
+ assert!(n38.contains("claim-leader"), "{n38}");
334
+ assert!(n38.contains("attach-leader"), "{n38}");
335
+ assert!(n38.contains("takeover"), "{n38}");
336
+ let _ = std::fs::remove_dir_all(&ws);
337
+ }
338
+
339
+ #[test]
340
+ fn resolve_leader_name_after_rebind() {
341
+ let ws = named_ws("leader-rebind");
342
+ seed_state(
343
+ &ws,
344
+ state_with_teams(json!({
345
+ "alpha": leader_team("alpha-session", "%new", "/tmp/leader.sock")
346
+ })),
347
+ );
348
+ let transport = OfflineTransport::new()
349
+ .with_tmux_endpoint("/tmp/leader.sock")
350
+ .with_targets(vec![pane("alpha-session-leader", "codex", "%new")]);
351
+
352
+ let resolved = resolve_name_with_transport(&ws, "alpha/leader", &transport).unwrap();
353
+ assert_eq!(resolved.pane_id, "%new");
354
+ assert_eq!(resolved.target_kind, NamedTargetKind::Leader);
355
+ let _ = std::fs::remove_dir_all(&ws);
356
+ }
357
+
358
+ #[test]
359
+ fn resolve_app_server_leader_name_uses_typed_receiver_not_tmux_pane() {
360
+ let ws = named_ws("appserver-leader-resolve");
361
+ let fake = crate::app_server_test_support::FakeAppServer::start(
362
+ "named-resolve",
363
+ crate::app_server_test_support::FakeAppServerScript::happy(
364
+ "thread-live",
365
+ "session-live",
366
+ ws.to_str().unwrap(),
367
+ ),
368
+ );
369
+ seed_state(
370
+ &ws,
371
+ state_with_teams(json!({
372
+ "alpha": app_server_leader_team(&ws, fake.endpoint(), "thread-live", "session-live")
373
+ })),
374
+ );
375
+
376
+ let resolved = resolve_name_with_transport(&ws, "alpha/leader", &OfflineTransport::new())
377
+ .expect("app-server leader name resolves by typed receiver");
378
+
379
+ assert_eq!(resolved.target_kind, NamedTargetKind::Leader);
380
+ assert_eq!(resolved.transport_kind.as_deref(), Some("codex_app_server"));
381
+ assert!(
382
+ resolved.pane_id.is_empty(),
383
+ "app-server leader is not pane-addressed"
384
+ );
385
+ assert_eq!(
386
+ resolved
387
+ .app_server
388
+ .as_ref()
389
+ .and_then(|app| app.get("thread_id"))
390
+ .and_then(serde_json::Value::as_str),
391
+ Some("thread-live")
392
+ );
393
+ let _ = std::fs::remove_dir_all(&ws);
394
+ }
395
+
396
+ #[test]
397
+ fn resolve_app_server_leader_name_fails_closed_on_transport_conflict() {
398
+ let ws = named_ws("appserver-leader-conflict");
399
+ let mut team = app_server_leader_team(
400
+ &ws,
401
+ "unix:///tmp/team-agent-should-not-connect.sock",
402
+ "thread-live",
403
+ "session-live",
404
+ );
405
+ team["leader_receiver"]["mode"] = json!("direct_tmux");
406
+ seed_state(
407
+ &ws,
408
+ state_with_teams(json!({
409
+ "alpha": team
410
+ })),
411
+ );
412
+
413
+ let err = resolve_name_with_transport(&ws, "alpha/leader", &OfflineTransport::new())
414
+ .expect_err("conflicting mode/transport_kind must fail closed");
415
+
416
+ assert_eq!(err.kind, NamedAddressErrorKind::NameNotLive);
417
+ assert!(err.log.contains("mode=direct_tmux"), "{err}");
418
+ assert!(err.log.contains("transport_kind=codex_app_server"), "{err}");
419
+ let _ = std::fs::remove_dir_all(&ws);
420
+ }
421
+
422
+ #[test]
423
+ fn send_to_name_app_server_leader_submits_turn_without_tmux_pane() {
424
+ let ws = named_ws("appserver-leader-send");
425
+ let fake = crate::app_server_test_support::FakeAppServer::start(
426
+ "named-send",
427
+ crate::app_server_test_support::FakeAppServerScript::happy(
428
+ "thread-live",
429
+ "session-live",
430
+ ws.to_str().unwrap(),
431
+ ),
432
+ );
433
+ seed_state(
434
+ &ws,
435
+ state_with_teams(json!({
436
+ "alpha": app_server_leader_team(&ws, fake.endpoint(), "thread-live", "session-live")
437
+ })),
438
+ );
439
+ let args = named_send_args(&ws, Some("alpha/leader"), None, None, &["hello appserver"]);
440
+
441
+ let result = cmd_send(&args).expect("named app-server send should succeed");
442
+ let value = match result.output {
443
+ CmdOutput::Json(value) => value,
444
+ other => panic!("expected json output, got {other:?}"),
445
+ };
446
+
447
+ assert_eq!(value["ok"], json!(true));
448
+ assert_eq!(value["transport_kind"], json!("codex_app_server"));
449
+ assert_eq!(fake.received_turns().len(), 1);
450
+ assert_eq!(
451
+ fake.received_turns()[0]["params"]["clientUserMessageId"],
452
+ value["message_id"]
453
+ );
454
+ let _ = std::fs::remove_dir_all(&ws);
455
+ }
456
+
457
+ #[test]
458
+ fn resolve_name_invalid_syntax() {
459
+ let ws = named_ws("invalid");
460
+ let transport = OfflineTransport::new();
461
+ assert_kind(
462
+ resolve_name_with_transport(&ws, "", &transport),
463
+ NamedAddressErrorKind::NameInvalid,
464
+ );
465
+ assert_kind(
466
+ resolve_name_with_transport(&ws, "team//qa", &transport),
467
+ NamedAddressErrorKind::NameInvalid,
468
+ );
469
+ let _ = std::fs::remove_dir_all(&ws);
470
+ }
471
+
472
+ #[test]
473
+ fn resolve_name_workspace_not_found() {
474
+ let ws = named_ws("sender-missing-target");
475
+ let missing = std::env::temp_dir().join(format!(
476
+ "ta-named-missing-{}-{}",
477
+ std::process::id(),
478
+ chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default()
479
+ ));
480
+ let transport = OfflineTransport::new();
481
+ assert_kind(
482
+ resolve_name_with_transport(&ws, &format!("{}::alpha/qa", missing.display()), &transport),
483
+ NamedAddressErrorKind::WorkspaceNotFound,
484
+ );
485
+ let _ = std::fs::remove_dir_all(&ws);
486
+ }
487
+
488
+ #[test]
489
+ fn resolve_name_state_missing() {
490
+ let ws = named_ws("state-missing");
491
+ let state_path = ws.join(".team").join("runtime").join("state.json");
492
+ let _ = std::fs::remove_file(state_path);
493
+ let transport = OfflineTransport::new();
494
+ assert_kind(
495
+ resolve_name_with_transport(&ws, "alpha/qa", &transport),
496
+ NamedAddressErrorKind::StateNotFound,
497
+ );
498
+ let _ = std::fs::remove_dir_all(&ws);
499
+ }
500
+
501
+ #[test]
502
+ fn resolve_name_same_team_key_across_workspaces() {
503
+ let sender = named_ws("same-team-sender");
504
+ let other = named_ws("same-team-other");
505
+ seed_state(
506
+ &sender,
507
+ state_with_teams(json!({
508
+ "alpha": worker_team("alpha-a", "qa", "%a", "qa")
509
+ })),
510
+ );
511
+ seed_state(
512
+ &other,
513
+ state_with_teams(json!({
514
+ "alpha": worker_team("alpha-b", "qa", "%b", "qa")
515
+ })),
516
+ );
517
+ let sender_transport = OfflineTransport::new().with_targets(vec![pane("alpha-a", "qa", "%a")]);
518
+ let other_transport = OfflineTransport::new().with_targets(vec![pane("alpha-b", "qa", "%b")]);
519
+
520
+ let err = resolve_name_with_workspace_transports(
521
+ &sender,
522
+ "alpha/qa",
523
+ &[
524
+ NamedWorkspaceTransport {
525
+ workspace: &sender,
526
+ transport: &sender_transport,
527
+ },
528
+ NamedWorkspaceTransport {
529
+ workspace: &other,
530
+ transport: &other_transport,
531
+ },
532
+ ],
533
+ )
534
+ .unwrap_err();
535
+ assert_eq!(err.kind, NamedAddressErrorKind::NameAmbiguous);
536
+ assert!(err.n38_message().contains("<workspace>::alpha/qa"));
537
+ let _ = std::fs::remove_dir_all(&sender);
538
+ let _ = std::fs::remove_dir_all(&other);
539
+ }
540
+
541
+ #[test]
542
+ fn resolve_paused_agent_with_live_pane_succeeds_with_warning() {
543
+ let ws = named_ws("paused-live");
544
+ seed_state(
545
+ &ws,
546
+ state_with_teams(json!({
547
+ "alpha": worker_team_with_status("alpha-session", "qa", "%paused", "qa", "paused")
548
+ })),
549
+ );
550
+ let transport =
551
+ OfflineTransport::new().with_targets(vec![pane("alpha-session", "qa", "%paused")]);
552
+
553
+ let resolved = resolve_name_with_transport(&ws, "alpha/qa", &transport).unwrap();
554
+ assert_eq!(resolved.pane_id, "%paused");
555
+ assert_eq!(resolved.agent_status.as_deref(), Some("paused"));
556
+ assert!(resolved.warning.as_deref().unwrap_or("").contains("paused"));
557
+ let _ = std::fs::remove_dir_all(&ws);
558
+ }
559
+
560
+ #[test]
561
+ fn named_address_resolver_does_not_write_leader_receiver_or_owner_fields() {
562
+ let src = std::fs::read_to_string(concat!(
563
+ env!("CARGO_MANIFEST_DIR"),
564
+ "/src/cli/named_address.rs"
565
+ ))
566
+ .unwrap();
567
+ for forbidden in ["leader_receiver.*=", "team_owner.*=", "owner_epoch.*="] {
568
+ assert!(
569
+ !src.contains(forbidden),
570
+ "resolver must not contain binding write pattern {forbidden}"
571
+ );
572
+ }
573
+ for line in src.lines() {
574
+ let trimmed = line.trim();
575
+ for field in ["leader_receiver", "team_owner", "owner_epoch"] {
576
+ assert!(
577
+ !(trimmed.starts_with(field) && trimmed.contains('=')),
578
+ "resolver must not assign binding field `{field}`: {trimmed}"
579
+ );
580
+ assert!(
581
+ !(trimmed.starts_with(&format!(".{field}")) && trimmed.contains('=')),
582
+ "resolver must not assign binding field `{field}`: {trimmed}"
583
+ );
584
+ }
585
+ }
586
+ }
@@ -206,6 +206,7 @@ fn current_uid() -> Option<String> {
206
206
  team_id: None,
207
207
  yes: true,
208
208
  no_display: false,
209
+ backend: None,
209
210
  json: true,
210
211
  };
211
212
  let _ = cmd_quick_start(&args); // real quick_start compiles the spec before any coordinator/launch step
@@ -240,6 +241,7 @@ fn current_uid() -> Option<String> {
240
241
  team_id: None,
241
242
  yes: false,
242
243
  no_display: false,
244
+ backend: None,
243
245
  json: true,
244
246
  };
245
247
  let text = outcome_text(cmd_quick_start(&args));
@@ -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
 
@@ -272,6 +272,13 @@ pub struct QuickStartArgs {
272
272
  pub yes: bool,
273
273
  pub no_display: bool,
274
274
  pub json: bool,
275
+ /// 0.5.x Phase 1d Batch 2: explicit backend override
276
+ /// (`--backend <tmux|conpty>`). `None` = default (factory 5-layer
277
+ /// resolution; almost always `tmux` on a cold quick-start).
278
+ /// Explicit `Some("conpty")` on a host without a live shim client
279
+ /// surfaces `MuxUnavailable` honestly (CR C-1 ①) — NEVER silent
280
+ /// downgrade to tmux. Explicit `Some("pty")` refuses (CR C-1 ②).
281
+ pub backend: Option<String>,
275
282
  // Stage QR (quick-start/restart separation, design doc
276
283
  // .team/artifacts/quickstart-restart-separation-design.md): the
277
284
  // `fresh` field is intentionally REMOVED. Reset semantics now live
@@ -323,6 +330,10 @@ pub struct SendArgs {
323
330
  /// the **cross-team communication** primitive: the target pane does not
324
331
  /// need to be in the sender's team.
325
332
  pub pane: Option<String>,
333
+ /// `--to-name <name>` — stable named addressing. Mutually exclusive with
334
+ /// `target` / `targets` / `--pane`; resolves workspace/team/agent or leader
335
+ /// name to the current live pane before using direct pane injection.
336
+ pub to_name: Option<String>,
326
337
  }
327
338
 
328
339
  /// E23 worker-side emergency fallback for `team_orchestrator.send_message`
@@ -436,6 +447,16 @@ pub struct AttachLeaderArgs {
436
447
  pub json: bool,
437
448
  }
438
449
 
450
+ /// Explicit Codex app-server leader binding args.
451
+ #[derive(Debug, Clone, PartialEq, Eq)]
452
+ pub struct AttachAppServerLeaderArgs {
453
+ pub workspace: PathBuf,
454
+ pub team: Option<String>,
455
+ pub socket: String,
456
+ pub thread_id: String,
457
+ pub json: bool,
458
+ }
459
+
439
460
  /// `identity`(`parser.py:256`)。
440
461
  #[derive(Debug, Clone, PartialEq, Eq)]
441
462
  pub struct IdentityArgs {