@team-agent/installer 0.5.1 → 0.5.3

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 (55) hide show
  1. package/Cargo.lock +120 -9
  2. package/Cargo.toml +2 -2
  3. package/crates/team-agent/Cargo.toml +21 -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 +79 -1
  9. package/crates/team-agent/src/cli/mod.rs +190 -80
  10. package/crates/team-agent/src/cli/named_address.rs +111 -0
  11. package/crates/team-agent/src/cli/send.rs +98 -3
  12. package/crates/team-agent/src/cli/status_port.rs +44 -6
  13. package/crates/team-agent/src/cli/tests/named_address.rs +135 -0
  14. package/crates/team-agent/src/cli/tests/run_delegation.rs +2 -0
  15. package/crates/team-agent/src/cli/types.rs +17 -0
  16. package/crates/team-agent/src/codex_app_server.rs +549 -0
  17. package/crates/team-agent/src/conpty/backend.rs +822 -0
  18. package/crates/team-agent/src/conpty/mod.rs +32 -0
  19. package/crates/team-agent/src/coordinator/backoff.rs +132 -7
  20. package/crates/team-agent/src/coordinator/conpty_shim.rs +730 -0
  21. package/crates/team-agent/src/coordinator/health.rs +97 -11
  22. package/crates/team-agent/src/coordinator/mod.rs +8 -0
  23. package/crates/team-agent/src/coordinator/tests/daemon.rs +2 -1
  24. package/crates/team-agent/src/coordinator/tests/tick_core.rs +6 -0
  25. package/crates/team-agent/src/diagnose/orphans.rs +29 -10
  26. package/crates/team-agent/src/leader/lease.rs +144 -7
  27. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  28. package/crates/team-agent/src/leader/provider_attribution.rs +19 -34
  29. package/crates/team-agent/src/leader/start.rs +18 -5
  30. package/crates/team-agent/src/leader/tests/lease_api.rs +179 -0
  31. package/crates/team-agent/src/lib.rs +36 -0
  32. package/crates/team-agent/src/lifecycle/launch.rs +207 -94
  33. package/crates/team-agent/src/lifecycle/lock.rs +40 -33
  34. package/crates/team-agent/src/lifecycle/restart/agent.rs +10 -18
  35. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  36. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +7 -0
  37. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  38. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +63 -0
  39. package/crates/team-agent/src/mcp_server/wire.rs +6 -7
  40. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  41. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  42. package/crates/team-agent/src/messaging/mod.rs +2 -2
  43. package/crates/team-agent/src/messaging/results.rs +78 -21
  44. package/crates/team-agent/src/messaging/tests/runtime.rs +237 -0
  45. package/crates/team-agent/src/packaging/tests.rs +41 -6
  46. package/crates/team-agent/src/packaging/types.rs +31 -3
  47. package/crates/team-agent/src/platform/argv.rs +324 -0
  48. package/crates/team-agent/src/platform/errors.rs +95 -0
  49. package/crates/team-agent/src/platform/file_lock.rs +418 -0
  50. package/crates/team-agent/src/platform/mod.rs +66 -0
  51. package/crates/team-agent/src/platform/process.rs +555 -0
  52. package/crates/team-agent/src/state/persist.rs +205 -25
  53. package/crates/team-agent/src/tmux_backend.rs +94 -13
  54. package/crates/team-agent/src/transport_factory.rs +751 -0
  55. package/package.json +4 -4
@@ -98,6 +98,8 @@ pub(crate) struct ResolvedNamedAddress {
98
98
  pub session_name: Option<String>,
99
99
  pub window_name: Option<String>,
100
100
  pub tmux_endpoint: Option<String>,
101
+ pub transport_kind: Option<String>,
102
+ pub app_server: Option<Value>,
101
103
  pub state_pane_id: Option<String>,
102
104
  pub state_pane_stale: bool,
103
105
  pub agent_status: Option<String>,
@@ -428,6 +430,8 @@ fn resolve_worker(
428
430
  session_name: Some(session.to_string()),
429
431
  window_name: Some(window.to_string()),
430
432
  tmux_endpoint: transport.tmux_endpoint(),
433
+ transport_kind: Some("direct_tmux".to_string()),
434
+ app_server: None,
431
435
  state_pane_id,
432
436
  state_pane_stale,
433
437
  agent_status,
@@ -541,6 +545,28 @@ fn resolve_leader(
541
545
  state.get("leader_receiver")
542
546
  }
543
547
  .ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
548
+ if let Some((mode, transport_kind)) = receiver_transport_conflict(receiver) {
549
+ let mut err = NamedAddressError::new(
550
+ NamedAddressErrorKind::NameNotLive,
551
+ "leader_receiver mode/transport_kind conflict",
552
+ );
553
+ err.action =
554
+ "Rebind the leader: team-agent claim-leader, takeover, or attach-app-server-leader"
555
+ .to_string();
556
+ err.log = format!(
557
+ "name={team}/leader mode={mode} transport_kind={transport_kind} expected=single_transport_kind"
558
+ );
559
+ return Err(err);
560
+ }
561
+ if crate::codex_app_server::receiver_is_app_server(receiver) {
562
+ return resolve_app_server_leader(
563
+ sender_workspace,
564
+ target_workspace,
565
+ team,
566
+ parsed,
567
+ receiver,
568
+ );
569
+ }
544
570
  let pane_id = string_field(receiver, "pane_id")
545
571
  .filter(|pane| !pane.is_empty())
546
572
  .ok_or_else(|| name_not_live_leader(team, None, None, None, transport.tmux_endpoint()))?;
@@ -566,6 +592,8 @@ fn resolve_leader(
566
592
  session_name: session.map(str::to_string),
567
593
  window_name: window.map(str::to_string),
568
594
  tmux_endpoint: socket,
595
+ transport_kind: Some("direct_tmux".to_string()),
596
+ app_server: None,
569
597
  state_pane_id: Some(pane_id.to_string()),
570
598
  state_pane_stale: false,
571
599
  agent_status: None,
@@ -597,6 +625,87 @@ fn resolve_leader(
597
625
  }
598
626
  }
599
627
 
628
+ fn receiver_transport_conflict(receiver: &Value) -> Option<(String, String)> {
629
+ let mode = receiver.get("mode").and_then(Value::as_str)?;
630
+ let transport_kind = receiver.get("transport_kind").and_then(Value::as_str)?;
631
+ if !mode.is_empty() && !transport_kind.is_empty() && mode != transport_kind {
632
+ return Some((mode.to_string(), transport_kind.to_string()));
633
+ }
634
+ None
635
+ }
636
+
637
+ fn resolve_app_server_leader(
638
+ sender_workspace: &Path,
639
+ target_workspace: &Path,
640
+ team: &str,
641
+ parsed: &ParsedNamedAddress,
642
+ receiver: &Value,
643
+ ) -> Result<ResolvedNamedAddress, NamedAddressError> {
644
+ let expected = crate::codex_app_server::binding_from_receiver(receiver).map_err(|error| {
645
+ let mut err = NamedAddressError::new(
646
+ NamedAddressErrorKind::NameNotLive,
647
+ format!("app-server leader receiver is incomplete: {error}"),
648
+ );
649
+ err.action = format!("Rebind the leader: team-agent attach-app-server-leader --team {team} --socket <socket> --thread-id <thread_id>");
650
+ err.log = format!("name={team}/leader expected=leader_receiver.app_server");
651
+ err
652
+ })?;
653
+ let actual = crate::codex_app_server::attach_probe(&expected.socket, &expected.thread_id)
654
+ .map_err(|error| {
655
+ let mut err = NamedAddressError::new(
656
+ NamedAddressErrorKind::NameNotLive,
657
+ format!("app-server leader thread is not live: {error}"),
658
+ );
659
+ err.action = format!("Rebind the leader: team-agent attach-app-server-leader --team {team} --socket {} --thread-id {}", expected.socket, expected.thread_id);
660
+ err.log = format!(
661
+ "name={team}/leader socket={} thread_id={} reason={}",
662
+ expected.socket,
663
+ expected.thread_id,
664
+ error
665
+ );
666
+ err
667
+ })?;
668
+ if actual.thread_id != expected.thread_id
669
+ || actual.session_id != expected.session_id
670
+ || actual.cwd != expected.cwd
671
+ || actual.cli_version != expected.cli_version
672
+ {
673
+ let mut err = NamedAddressError::new(
674
+ NamedAddressErrorKind::NameNotLive,
675
+ "app-server leader thread identity tuple is stale",
676
+ );
677
+ err.action = format!("Rebind the leader: team-agent attach-app-server-leader --team {team} --socket {} --thread-id {}", expected.socket, expected.thread_id);
678
+ err.log = format!(
679
+ "name={team}/leader expected_thread={} actual_thread={} expected_session={} actual_session={} expected_cwd={} actual_cwd={}",
680
+ expected.thread_id,
681
+ actual.thread_id,
682
+ expected.session_id,
683
+ actual.session_id,
684
+ expected.cwd,
685
+ actual.cwd
686
+ );
687
+ return Err(err);
688
+ }
689
+ Ok(ResolvedNamedAddress {
690
+ raw_name: parsed.display_name(),
691
+ target_kind: NamedTargetKind::Leader,
692
+ sender_workspace: sender_workspace.to_path_buf(),
693
+ target_workspace: target_workspace.to_path_buf(),
694
+ team_key: Some(team.to_string()),
695
+ agent_id: None,
696
+ pane_id: String::new(),
697
+ session_name: None,
698
+ window_name: None,
699
+ tmux_endpoint: None,
700
+ transport_kind: Some("codex_app_server".to_string()),
701
+ app_server: receiver.get("app_server").cloned(),
702
+ state_pane_id: None,
703
+ state_pane_stale: false,
704
+ agent_status: None,
705
+ warning: None,
706
+ })
707
+ }
708
+
600
709
  fn resolve_session_window(
601
710
  sender_workspace: &Path,
602
711
  target_workspace: &Path,
@@ -619,6 +728,8 @@ fn resolve_session_window(
619
728
  session_name: Some(session.to_string()),
620
729
  window_name: Some(window.to_string()),
621
730
  tmux_endpoint: transport.tmux_endpoint(),
731
+ transport_kind: Some("direct_tmux".to_string()),
732
+ app_server: None,
622
733
  state_pane_id: None,
623
734
  state_pane_stale: false,
624
735
  agent_status: None,
@@ -17,7 +17,9 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
17
17
  }
18
18
  let content = args.message.join(" ");
19
19
  if content.is_empty() {
20
- return Err(CliError::Usage("--to-name requires a non-empty message".to_string()));
20
+ return Err(CliError::Usage(
21
+ "--to-name requires a non-empty message".to_string(),
22
+ ));
21
23
  }
22
24
  let (resolved, transport) =
23
25
  match crate::cli::named_address::resolve_name_for_cli(&args.workspace, to_name) {
@@ -52,7 +54,9 @@ pub fn cmd_send(args: &SendArgs) -> Result<CmdResult, CliError> {
52
54
  }
53
55
  let content = args.message.join(" ");
54
56
  if content.is_empty() {
55
- return Err(CliError::Usage("--pane requires a non-empty message".to_string()));
57
+ return Err(CliError::Usage(
58
+ "--pane requires a non-empty message".to_string(),
59
+ ));
56
60
  }
57
61
  let mut value = send_to_pane_direct(
58
62
  &args.workspace,
@@ -191,13 +195,22 @@ fn send_to_named_pane_direct(
191
195
 
192
196
  let message_id = format!("named_send_{}", chrono::Utc::now().timestamp_millis());
193
197
  let rendered = render_message(sender, task_id, content, &message_id);
194
- let target = Target::Pane(PaneId::new(&resolved.pane_id));
195
198
  let sender_run_workspace = crate::model::paths::canonical_run_workspace(sender_workspace)
196
199
  .unwrap_or_else(|_| sender_workspace.to_path_buf());
197
200
  let event_log = crate::event_log::EventLog::new(&sender_run_workspace);
198
201
  if let Some(warning) = &resolved.warning {
199
202
  eprintln!("warning: {warning}");
200
203
  }
204
+ if resolved.transport_kind.as_deref() == Some("codex_app_server") {
205
+ return send_to_named_app_server_leader(
206
+ &event_log,
207
+ resolved,
208
+ &message_id,
209
+ &rendered,
210
+ sender,
211
+ );
212
+ }
213
+ let target = Target::Pane(PaneId::new(&resolved.pane_id));
201
214
  let report = transport
202
215
  .inject(&target, &InjectPayload::Text(rendered), Key::Enter, true)
203
216
  .map_err(|e| {
@@ -241,6 +254,88 @@ fn send_to_named_pane_direct(
241
254
  Ok(value)
242
255
  }
243
256
 
257
+ fn send_to_named_app_server_leader(
258
+ event_log: &crate::event_log::EventLog,
259
+ resolved: &crate::cli::named_address::ResolvedNamedAddress,
260
+ message_id: &str,
261
+ rendered: &str,
262
+ sender: &str,
263
+ ) -> Result<serde_json::Value, CliError> {
264
+ let receiver = serde_json::json!({
265
+ "mode": "codex_app_server",
266
+ "transport_kind": "codex_app_server",
267
+ "app_server": resolved.app_server.clone().unwrap_or(serde_json::Value::Null),
268
+ });
269
+ let binding = crate::codex_app_server::binding_from_receiver(&receiver)
270
+ .map_err(|error| CliError::Runtime(format!("invalid app-server named leader: {error}")))?;
271
+ let target_kind = named_target_kind_wire(resolved.target_kind);
272
+ let base_event = serde_json::json!({
273
+ "to_name": resolved.raw_name,
274
+ "target_kind": target_kind,
275
+ "sender": sender,
276
+ "sender_workspace": resolved.sender_workspace.display().to_string(),
277
+ "target_workspace": resolved.target_workspace.display().to_string(),
278
+ "team_key": resolved.team_key,
279
+ "agent_id": resolved.agent_id,
280
+ "transport_kind": "codex_app_server",
281
+ "socket": binding.socket,
282
+ "thread_id": binding.thread_id,
283
+ "message_id": message_id,
284
+ });
285
+ match crate::codex_app_server::submit_to_bound_thread(&binding, message_id, rendered) {
286
+ Ok(submit) => {
287
+ let event = merge_json(
288
+ base_event.clone(),
289
+ serde_json::json!({
290
+ "ok": true,
291
+ "turn_id": submit.turn_id,
292
+ "turn_status": submit.turn_status,
293
+ }),
294
+ );
295
+ let _ = event_log.write("send.name_app_server", event.clone());
296
+ Ok(event)
297
+ }
298
+ Err(crate::codex_app_server::AppServerError::LeaderBusy(message)) => {
299
+ let event = merge_json(
300
+ base_event.clone(),
301
+ serde_json::json!({
302
+ "ok": false,
303
+ "status": "retry_scheduled",
304
+ "reason": "leader_busy",
305
+ "channel": "leader_busy",
306
+ "error": message,
307
+ }),
308
+ );
309
+ let _ = event_log.write("send.name_app_server", event.clone());
310
+ Ok(event)
311
+ }
312
+ Err(error) => {
313
+ let event = merge_json(
314
+ base_event.clone(),
315
+ serde_json::json!({
316
+ "ok": false,
317
+ "status": "refused",
318
+ "reason": error.code(),
319
+ "channel": "rebind_required",
320
+ "error": error.to_string(),
321
+ "action": "run team-agent attach-app-server-leader for the target team",
322
+ }),
323
+ );
324
+ let _ = event_log.write("send.name_app_server", event.clone());
325
+ Ok(event)
326
+ }
327
+ }
328
+ }
329
+
330
+ fn merge_json(mut left: serde_json::Value, right: serde_json::Value) -> serde_json::Value {
331
+ if let (Some(left), Some(right)) = (left.as_object_mut(), right.as_object()) {
332
+ for (key, value) in right {
333
+ left.insert(key.clone(), value.clone());
334
+ }
335
+ }
336
+ left
337
+ }
338
+
244
339
  fn named_target_kind_wire(kind: crate::cli::named_address::NamedTargetKind) -> &'static str {
245
340
  match kind {
246
341
  crate::cli::named_address::NamedTargetKind::Worker => "worker",
@@ -280,7 +280,29 @@ use rusqlite::params;
280
280
  if let (Some(session), Some(agents)) = (session, state.get("agents").and_then(Value::as_object)) {
281
281
  let run_ws = crate::model::paths::canonical_run_workspace(workspace)
282
282
  .unwrap_or_else(|_| workspace.to_path_buf());
283
- let backend = crate::tmux_backend::TmuxBackend::for_workspace(&run_ws);
283
+ // 0.5.x Phase 1d Batch 3: use the factory-resolved backend
284
+ // so conpty teams get their scrollback from the shim rather
285
+ // than a fake tmux capture that always returns empty. Tmux
286
+ // teams take the same code path as before (byte-equivalent).
287
+ let resolved = crate::transport_factory::resolve_read_only_transport(
288
+ &run_ws,
289
+ Some(state),
290
+ crate::transport_factory::TransportPurpose::Status,
291
+ );
292
+ let backend: Box<dyn crate::transport::Transport> = match resolved {
293
+ Ok(r) => r.backend,
294
+ Err(_) => {
295
+ // Read-path fallback: refused factory means we don't
296
+ // try to inspect approval prompts. Empty vec = no
297
+ // waiting approvals, honest.
298
+ return Ok(json!({
299
+ "ok": true,
300
+ "waiting": false,
301
+ "waiting_count": 0,
302
+ "approvals": [],
303
+ }));
304
+ }
305
+ };
284
306
  for (agent_id, agent_state) in agents {
285
307
  if agent.is_some_and(|wanted| wanted != agent_id) {
286
308
  continue;
@@ -469,14 +491,30 @@ use rusqlite::params;
469
491
  }
470
492
  let run_ws = crate::model::paths::canonical_run_workspace(workspace)
471
493
  .unwrap_or_else(|_| workspace.to_path_buf());
472
- let selection = crate::tmux_backend::tmux_backend_for_runtime_state_or_workspace(
494
+ // 0.5.x Phase 1d Batch 3: route through the factory so a
495
+ // conpty team does NOT get its `has_session` probe served by a
496
+ // tmux backend (which would always return false and drive the
497
+ // reader into a false `tmux_session_missing` state — design
498
+ // §Batch 3 Verification anchor). Tmux teams see byte-equivalent
499
+ // behavior because factory Layer 3 (legacy tmux endpoint) uses
500
+ // the same `tmux_backend_for_runtime_state_or_workspace` shape.
501
+ let resolved = crate::transport_factory::resolve_read_only_transport(
473
502
  &run_ws,
474
503
  Some(state),
504
+ crate::transport_factory::TransportPurpose::Status,
475
505
  );
476
- selection
477
- .backend
478
- .has_session(&crate::transport::SessionName::new(name))
479
- .unwrap_or(false)
506
+ match resolved {
507
+ Ok(r) => r
508
+ .backend
509
+ .has_session(&crate::transport::SessionName::new(name))
510
+ .unwrap_or(false),
511
+ Err(_) => {
512
+ // Factory refused (e.g. explicit conpty without a
513
+ // resolvable team_key). Honest: return false rather
514
+ // than pretend a tmux session exists.
515
+ false
516
+ }
517
+ }
480
518
  }
481
519
 
482
520
  fn message_counts(conn: &rusqlite::Connection, owner_team_id: Option<&str>) -> Result<Value, CliError> {
@@ -75,6 +75,38 @@ fn leader_team(session: &str, pane: &str, socket: &str) -> serde_json::Value {
75
75
  })
76
76
  }
77
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
+
78
110
  fn pane(session: &str, window: &str, pane_id: &str) -> PaneInfo {
79
111
  PaneInfo {
80
112
  pane_id: PaneId::new(pane_id),
@@ -323,6 +355,109 @@ fn resolve_leader_name_after_rebind() {
323
355
  let _ = std::fs::remove_dir_all(&ws);
324
356
  }
325
357
 
358
+ // 0.5.x Windows portability Batch 5: `FakeAppServer` uses Unix
359
+ // domain sockets — Unix-only.
360
+ #[cfg(unix)]
361
+ #[test]
362
+ fn resolve_app_server_leader_name_uses_typed_receiver_not_tmux_pane() {
363
+ let ws = named_ws("appserver-leader-resolve");
364
+ let fake = crate::app_server_test_support::FakeAppServer::start(
365
+ "named-resolve",
366
+ crate::app_server_test_support::FakeAppServerScript::happy(
367
+ "thread-live",
368
+ "session-live",
369
+ ws.to_str().unwrap(),
370
+ ),
371
+ );
372
+ seed_state(
373
+ &ws,
374
+ state_with_teams(json!({
375
+ "alpha": app_server_leader_team(&ws, fake.endpoint(), "thread-live", "session-live")
376
+ })),
377
+ );
378
+
379
+ let resolved = resolve_name_with_transport(&ws, "alpha/leader", &OfflineTransport::new())
380
+ .expect("app-server leader name resolves by typed receiver");
381
+
382
+ assert_eq!(resolved.target_kind, NamedTargetKind::Leader);
383
+ assert_eq!(resolved.transport_kind.as_deref(), Some("codex_app_server"));
384
+ assert!(
385
+ resolved.pane_id.is_empty(),
386
+ "app-server leader is not pane-addressed"
387
+ );
388
+ assert_eq!(
389
+ resolved
390
+ .app_server
391
+ .as_ref()
392
+ .and_then(|app| app.get("thread_id"))
393
+ .and_then(serde_json::Value::as_str),
394
+ Some("thread-live")
395
+ );
396
+ let _ = std::fs::remove_dir_all(&ws);
397
+ }
398
+
399
+ #[test]
400
+ fn resolve_app_server_leader_name_fails_closed_on_transport_conflict() {
401
+ let ws = named_ws("appserver-leader-conflict");
402
+ let mut team = app_server_leader_team(
403
+ &ws,
404
+ "unix:///tmp/team-agent-should-not-connect.sock",
405
+ "thread-live",
406
+ "session-live",
407
+ );
408
+ team["leader_receiver"]["mode"] = json!("direct_tmux");
409
+ seed_state(
410
+ &ws,
411
+ state_with_teams(json!({
412
+ "alpha": team
413
+ })),
414
+ );
415
+
416
+ let err = resolve_name_with_transport(&ws, "alpha/leader", &OfflineTransport::new())
417
+ .expect_err("conflicting mode/transport_kind must fail closed");
418
+
419
+ assert_eq!(err.kind, NamedAddressErrorKind::NameNotLive);
420
+ assert!(err.log.contains("mode=direct_tmux"), "{err}");
421
+ assert!(err.log.contains("transport_kind=codex_app_server"), "{err}");
422
+ let _ = std::fs::remove_dir_all(&ws);
423
+ }
424
+
425
+ #[cfg(unix)]
426
+ #[test]
427
+ fn send_to_name_app_server_leader_submits_turn_without_tmux_pane() {
428
+ let ws = named_ws("appserver-leader-send");
429
+ let fake = crate::app_server_test_support::FakeAppServer::start(
430
+ "named-send",
431
+ crate::app_server_test_support::FakeAppServerScript::happy(
432
+ "thread-live",
433
+ "session-live",
434
+ ws.to_str().unwrap(),
435
+ ),
436
+ );
437
+ seed_state(
438
+ &ws,
439
+ state_with_teams(json!({
440
+ "alpha": app_server_leader_team(&ws, fake.endpoint(), "thread-live", "session-live")
441
+ })),
442
+ );
443
+ let args = named_send_args(&ws, Some("alpha/leader"), None, None, &["hello appserver"]);
444
+
445
+ let result = cmd_send(&args).expect("named app-server send should succeed");
446
+ let value = match result.output {
447
+ CmdOutput::Json(value) => value,
448
+ other => panic!("expected json output, got {other:?}"),
449
+ };
450
+
451
+ assert_eq!(value["ok"], json!(true));
452
+ assert_eq!(value["transport_kind"], json!("codex_app_server"));
453
+ assert_eq!(fake.received_turns().len(), 1);
454
+ assert_eq!(
455
+ fake.received_turns()[0]["params"]["clientUserMessageId"],
456
+ value["message_id"]
457
+ );
458
+ let _ = std::fs::remove_dir_all(&ws);
459
+ }
460
+
326
461
  #[test]
327
462
  fn resolve_name_invalid_syntax() {
328
463
  let ws = named_ws("invalid");
@@ -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));
@@ -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
@@ -440,6 +447,16 @@ pub struct AttachLeaderArgs {
440
447
  pub json: bool,
441
448
  }
442
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
+
443
460
  /// `identity`(`parser.py:256`)。
444
461
  #[derive(Debug, Clone, PartialEq, Eq)]
445
462
  pub struct IdentityArgs {