@team-agent/installer 0.5.1 → 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 (37) 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 +74 -1
  9. package/crates/team-agent/src/cli/mod.rs +70 -25
  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 +131 -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 +488 -0
  17. package/crates/team-agent/src/conpty/backend.rs +710 -0
  18. package/crates/team-agent/src/conpty/mod.rs +32 -0
  19. package/crates/team-agent/src/coordinator/backoff.rs +45 -6
  20. package/crates/team-agent/src/diagnose/orphans.rs +11 -3
  21. package/crates/team-agent/src/leader/lease.rs +144 -7
  22. package/crates/team-agent/src/leader/owner_bind.rs +5 -2
  23. package/crates/team-agent/src/leader/start.rs +18 -5
  24. package/crates/team-agent/src/leader/tests/lease_api.rs +172 -0
  25. package/crates/team-agent/src/lib.rs +23 -0
  26. package/crates/team-agent/src/lifecycle/launch.rs +127 -3
  27. package/crates/team-agent/src/lifecycle/restart/common.rs +70 -0
  28. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +7 -0
  29. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +63 -0
  30. package/crates/team-agent/src/messaging/delivery.rs +329 -9
  31. package/crates/team-agent/src/messaging/leader_receiver.rs +17 -138
  32. package/crates/team-agent/src/messaging/mod.rs +2 -2
  33. package/crates/team-agent/src/messaging/results.rs +78 -21
  34. package/crates/team-agent/src/messaging/tests/runtime.rs +232 -0
  35. package/crates/team-agent/src/tmux_backend.rs +31 -0
  36. package/crates/team-agent/src/transport_factory.rs +632 -0
  37. package/package.json +4 -4
@@ -112,6 +112,10 @@ fn dispatch(command: &str, args: &[String], cwd: &Path) -> Result<ExitCode, CliE
112
112
  "claim-leader" => cmd_claim_leader(&claim_leader_args(args, cwd)).map(emit_result),
113
113
  // Real dispatch: `cmd_attach_leader` writes the `leader_receiver` binding.
114
114
  "attach-leader" => cmd_attach_leader(&attach_leader_args(args, cwd)?).map(emit_result),
115
+ "attach-app-server-leader" => {
116
+ cmd_attach_app_server_leader(&attach_app_server_leader_args(args, cwd)?)
117
+ .map(emit_result)
118
+ }
115
119
  "identity" => cmd_identity(&identity_args(args, cwd)).map(emit_result),
116
120
  "approvals" => cmd_approvals(&approvals_args(args, cwd)).map(emit_result),
117
121
  "inbox" => cmd_inbox(&inbox_args(args, cwd)?).map(emit_result),
@@ -167,6 +171,7 @@ const DISPATCH_COMMANDS: &[&str] = &[
167
171
  "takeover",
168
172
  "claim-leader",
169
173
  "attach-leader",
174
+ "attach-app-server-leader",
170
175
  "identity",
171
176
  "approvals",
172
177
  "inbox",
@@ -210,6 +215,23 @@ fn is_known_subcommand(command: &str) -> bool {
210
215
  DISPATCH_COMMANDS.contains(&command) || SPEC_ONLY_HELP_COMMANDS.contains(&command)
211
216
  }
212
217
 
218
+ /// Test-only public accessor for `command_help` — allows integration
219
+ /// tests to grep the help copy without depending on internal parser
220
+ /// machinery.
221
+ pub fn __test_command_help(command: Option<&str>) -> String {
222
+ command_help(command)
223
+ }
224
+
225
+ /// Test-only public accessor for `quick_start_args` — allows
226
+ /// integration tests to exercise the parser without going through
227
+ /// stdio + the full `main` entrypoint.
228
+ pub fn __test_quick_start_args(
229
+ args: &[String],
230
+ cwd: &std::path::Path,
231
+ ) -> Result<crate::cli::types::QuickStartArgs, crate::cli::CliError> {
232
+ quick_start_args(args, cwd)
233
+ }
234
+
213
235
  fn command_help(command: Option<&str>) -> String {
214
236
  match command {
215
237
  None => {
@@ -222,7 +244,7 @@ fn command_help(command: Option<&str>) -> String {
222
244
  )
223
245
  }
224
246
  Some("init") => "usage: team-agent init [--workspace WORKSPACE] [--force] [--json]".to_string(),
225
- Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--json]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.".to_string(),
247
+ Some("quick-start") => "usage: team-agent quick-start [TEAMDIR] [--workspace WORKSPACE] [--name NAME] [--team-id TEAM|--team TEAM] [--yes] [--no-display] [--backend tmux|conpty] [--json]\n\ndefaults: display_backend=adaptive; set display_backend: none in TEAM.md or pass --no-display to use one worker window per agent.\n\n--backend selects the worker transport (Phase 1d Batch 2): tmux (default on POSIX; unchanged behavior), conpty (Windows-native ConPTY worker transport; requires the shim binary and Windows host).".to_string(),
226
248
  Some("start") => "usage: team-agent start [TEAMDIR] [--yes] [--fresh] [--json]".to_string(),
227
249
  Some("compile") => "usage: team-agent compile --team TEAM [--out FILE] [--json]".to_string(),
228
250
  Some("send") => "usage: team-agent send TARGET MESSAGE... [--workspace WORKSPACE] [--team TEAM] [--targets AGENTS] [--to-name NAME] [--pane PANE] [--task TASK] [--sender SENDER] [--watch-result] [--requires-ack|--no-ack] [--no-wait] [--timeout SECONDS] [--confirm-human] [--message-id ID] [--json]\n\nMVP: name-based cross-workspace addressing assumes trusted local caller; no auth gate.".to_string(),
@@ -247,6 +269,7 @@ fn command_help(command: Option<&str>) -> String {
247
269
  Some("takeover") => "usage: team-agent takeover [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]".to_string(),
248
270
  Some("claim-leader") => "usage: team-agent claim-leader [--workspace WORKSPACE] [--team TEAM] [--confirm] [--json]".to_string(),
249
271
  Some("attach-leader") => "usage: team-agent attach-leader [--workspace WORKSPACE] [--team TEAM] [--pane PANE] [--provider PROVIDER] [--confirm] [--json]".to_string(),
272
+ Some("attach-app-server-leader") => "usage: team-agent attach-app-server-leader [--workspace WORKSPACE] [--team TEAM] --socket unix:///path.sock --thread-id THREAD_ID [--json]".to_string(),
250
273
  Some("identity") => "usage: team-agent identity [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
251
274
  Some("approvals") => "usage: team-agent approvals [AGENT] [--workspace WORKSPACE] [--team TEAM] [--json]".to_string(),
252
275
  Some("inbox") => "usage: team-agent inbox AGENT [--workspace WORKSPACE] [--team TEAM] [--limit N] [--since CURSOR] [--json]".to_string(),
@@ -670,12 +693,18 @@ struct ParsedArgs {
670
693
  pane: Option<String>,
671
694
  to_name: Option<String>,
672
695
  provider: Option<String>,
696
+ socket: Option<String>,
697
+ thread_id: Option<String>,
673
698
  message_id: Option<String>,
674
699
  content: Option<String>,
675
700
  primary_error: Option<String>,
676
701
  agent_id: Option<String>,
677
702
  task_id: Option<String>,
678
703
  result_json: Option<String>,
704
+ /// 0.5.x Phase 1d Batch 2: quick-start `--backend <tmux|conpty>`.
705
+ /// Raw string (validated at the quick-start builder); the factory
706
+ /// enforces literal semantics.
707
+ backend: Option<String>,
679
708
  }
680
709
 
681
710
  fn parse_args(args: &[String]) -> ParsedArgs {
@@ -717,6 +746,7 @@ fn parse_args(args: &[String]) -> ParsedArgs {
717
746
  }
718
747
  "--force" => parsed.force = true,
719
748
  "--no-display" => parsed.no_display = true,
749
+ "--backend" => parsed.backend = next_arg(args, &mut i),
720
750
  "--discard-session" => parsed.discard_session = true,
721
751
  "--role-file" => parsed.role_file = next_arg(args, &mut i),
722
752
  "--as" => parsed.as_agent = next_arg(args, &mut i),
@@ -753,6 +783,8 @@ fn parse_args(args: &[String]) -> ParsedArgs {
753
783
  "--pane" => parsed.pane = next_arg(args, &mut i),
754
784
  "--to-name" => parsed.to_name = next_arg(args, &mut i),
755
785
  "--provider" => parsed.provider = next_arg(args, &mut i),
786
+ "--socket" => parsed.socket = next_arg(args, &mut i),
787
+ "--thread-id" => parsed.thread_id = next_arg(args, &mut i),
756
788
  "--message-id" => parsed.message_id = next_arg(args, &mut i),
757
789
  "--content" => parsed.content = next_arg(args, &mut i),
758
790
  "--primary-error" => parsed.primary_error = next_arg(args, &mut i),
@@ -770,6 +802,12 @@ fn parse_args(args: &[String]) -> ParsedArgs {
770
802
  other if other.starts_with("--provider=") => {
771
803
  parsed.provider = Some(other.trim_start_matches("--provider=").to_string());
772
804
  }
805
+ other if other.starts_with("--socket=") => {
806
+ parsed.socket = Some(other.trim_start_matches("--socket=").to_string());
807
+ }
808
+ other if other.starts_with("--thread-id=") => {
809
+ parsed.thread_id = Some(other.trim_start_matches("--thread-id=").to_string());
810
+ }
773
811
  other if other.starts_with('-') => {}
774
812
  other => parsed.positionals.push(other.to_string()),
775
813
  }
@@ -833,6 +871,20 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
833
871
  } else {
834
872
  workspace.join(agents_dir)
835
873
  };
874
+ // 0.5.x Phase 1d Batch 2: validate the `--backend` literal up-front
875
+ // so users get a fast, actionable error instead of a downstream
876
+ // factory refusal. Accept the same literals as the factory
877
+ // `RequestedTransportBackend::parse_literal`.
878
+ if let Some(literal) = parsed.backend.as_deref() {
879
+ let normalized = literal.trim().to_ascii_lowercase();
880
+ if normalized != "tmux" && normalized != "conpty" {
881
+ return Err(CliError::Usage(format!(
882
+ "--backend must be `tmux` or `conpty`, got {literal:?}. \
883
+ `pty` is not a supported literal in Phase 1d (design \
884
+ §Non-Goals + CR C-1 ②)."
885
+ )));
886
+ }
887
+ }
836
888
  Ok(QuickStartArgs {
837
889
  workspace,
838
890
  agents_dir,
@@ -841,6 +893,7 @@ fn quick_start_args(args: &[String], cwd: &Path) -> Result<QuickStartArgs, CliEr
841
893
  yes: parsed.yes,
842
894
  no_display: parsed.no_display,
843
895
  json: parsed.json,
896
+ backend: parsed.backend,
844
897
  })
845
898
  }
846
899
 
@@ -1079,6 +1132,26 @@ fn attach_leader_args(args: &[String], cwd: &Path) -> Result<AttachLeaderArgs, C
1079
1132
  })
1080
1133
  }
1081
1134
 
1135
+ fn attach_app_server_leader_args(
1136
+ args: &[String],
1137
+ cwd: &Path,
1138
+ ) -> Result<AttachAppServerLeaderArgs, CliError> {
1139
+ let parsed = parse_args(args);
1140
+ Ok(AttachAppServerLeaderArgs {
1141
+ workspace: workspace(&parsed, cwd),
1142
+ team: parsed.team,
1143
+ socket: parsed
1144
+ .socket
1145
+ .filter(|socket| !socket.is_empty())
1146
+ .ok_or_else(|| CliError::Usage("missing --socket".to_string()))?,
1147
+ thread_id: parsed
1148
+ .thread_id
1149
+ .filter(|thread_id| !thread_id.is_empty())
1150
+ .ok_or_else(|| CliError::Usage("missing --thread-id".to_string()))?,
1151
+ json: parsed.json,
1152
+ })
1153
+ }
1154
+
1082
1155
  fn identity_args(args: &[String], cwd: &Path) -> IdentityArgs {
1083
1156
  let parsed = parse_args(args);
1084
1157
  IdentityArgs {
@@ -53,6 +53,7 @@ pub(crate) const SEND_REMINDER: &str = "Message delivered. Wait for the worker t
53
53
  pub(crate) const STATUS_REMINDER: &str = "To wait for results use --watch-result or team-agent collect. Do not capture-pane worker terminals.";
54
54
 
55
55
  pub mod adapters;
56
+ pub mod attach_app_server_leader;
56
57
  pub mod diagnose;
57
58
  pub mod emit;
58
59
  pub mod helpers;
@@ -64,6 +65,7 @@ pub mod status;
64
65
  pub mod types;
65
66
 
66
67
  pub use adapters::*;
68
+ pub use attach_app_server_leader::*;
67
69
  pub use diagnose::*;
68
70
  pub use emit::*;
69
71
  pub use leader::*;
@@ -120,14 +122,16 @@ pub mod lifecycle_port {
120
122
  team_id: Option<&str>,
121
123
  yes: bool,
122
124
  open_display: bool,
125
+ backend: Option<&str>,
123
126
  ) -> Result<Value, CliError> {
124
- match crate::lifecycle::quick_start_in_workspace_with_display(
127
+ match crate::lifecycle::quick_start_in_workspace_with_display_and_backend(
125
128
  workspace,
126
129
  agents_dir,
127
130
  name,
128
131
  yes,
129
132
  team_id,
130
133
  open_display,
134
+ backend,
131
135
  ) {
132
136
  Ok(report) => Ok(quick_start_value(report)),
133
137
  Err(e) => Ok(error_value(e)),
@@ -192,16 +196,40 @@ pub mod lifecycle_port {
192
196
  crate::tmux_backend::attach_command_for_workspace(cwd, session, window.as_str())
193
197
  }
194
198
  /// `runtime.shutdown`(`cmd_shutdown`)。
199
+ ///
200
+ /// 0.5.x Phase 1d Batch 5: the workspace-transport branch now routes
201
+ /// through `transport_factory::resolve_read_only_transport` so a
202
+ /// conpty team's shutdown reaches the shim rather than a
203
+ /// no-op tmux backend. Legacy tmux endpoint literal in state still
204
+ /// takes the `shutdown_transport_for_endpoint` tmux channel helper
205
+ /// path (intentional — this is the "attached explicit tmux
206
+ /// endpoint" branch and stays tmux-typed).
207
+ ///
208
+ /// `shutdown_with_transport_and_state` is generic over `&dyn
209
+ /// Transport`, so we hand the boxed factory backend down as
210
+ /// `&*box`. Factory refusal falls back to the workspace tmux
211
+ /// backend byte-equivalent to today so daemon liveness paths
212
+ /// don't crash.
195
213
  pub fn shutdown(workspace: &Path, keep_logs: bool, team: Option<&str>) -> Result<Value, CliError> {
196
214
  let run_ws = crate::model::paths::canonical_run_workspace(workspace)
197
215
  .map_err(|e| CliError::Runtime(e.to_string()))?;
198
216
  let state = shutdown_state_for_team(&run_ws, team)?;
199
- let transport = if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
200
- shutdown_transport_for_endpoint(endpoint)
201
- } else {
202
- shutdown_workspace_transport(&run_ws)
203
- };
204
- shutdown_with_transport_and_state(workspace, keep_logs, team, &transport, Some(state))
217
+ if let Some(endpoint) = legacy_worker_tmux_endpoint(&state) {
218
+ let transport = shutdown_transport_for_endpoint(endpoint);
219
+ return shutdown_with_transport_and_state(
220
+ workspace, keep_logs, team, &transport, Some(state),
221
+ );
222
+ }
223
+ let boxed: Box<dyn crate::transport::Transport> =
224
+ match crate::transport_factory::resolve_read_only_transport(
225
+ &run_ws,
226
+ Some(&state),
227
+ crate::transport_factory::TransportPurpose::Shutdown,
228
+ ) {
229
+ Ok(r) => r.backend,
230
+ Err(_) => Box::new(shutdown_workspace_transport(&run_ws)),
231
+ };
232
+ shutdown_with_transport_and_state(workspace, keep_logs, team, boxed.as_ref(), Some(state))
205
233
  }
206
234
 
207
235
  /// E12 ①:从 state 锚 pane_id(leader_receiver/team_owner,top+teams)映射到其所在 session
@@ -1191,24 +1219,41 @@ pub mod lifecycle_port {
1191
1219
  }
1192
1220
  }
1193
1221
  }
1194
- let workspace_transport = shutdown_workspace_transport(workspace);
1195
- match crate::transport::Transport::has_session(&workspace_transport, session) {
1196
- Ok(true) => residual = true,
1197
- Ok(false) => {}
1198
- Err(err) if tmux_absent_error(&err.to_string()) => {}
1199
- Err(err) => {
1200
- error.get_or_insert_with(|| err.to_string());
1201
- residual = true;
1202
- }
1203
- }
1204
- let default_transport = crate::tmux_backend::TmuxBackend::new();
1205
- match crate::transport::Transport::has_session(&default_transport, session) {
1206
- Ok(true) => residual = true,
1207
- Ok(false) => {}
1208
- Err(err) if tmux_absent_error(&err.to_string()) => {}
1209
- Err(err) => {
1210
- error.get_or_insert_with(|| err.to_string());
1211
- residual = true;
1222
+ // 0.5.x Phase 1d Batch 5 (design §Batch 5 point 4): a ConPTY
1223
+ // team's residual check MUST NOT read the workspace tmux socket
1224
+ // + shared default tmux socket as evidence for/against ConPTY
1225
+ // residual. Those probes are meaningless for a shim-owned pane
1226
+ // universe: they will always return `has_session=false` (no
1227
+ // tmux session exists) which would look like "no residual" but
1228
+ // gives zero honest information about whether the shim / child
1229
+ // panes have actually been reaped. Skip the tmux fallback
1230
+ // probes when the primary transport is ConPTY; the primary
1231
+ // transport check above already covered the honest question
1232
+ // ("does the shim still have this session?").
1233
+ let primary_is_conpty = matches!(
1234
+ transport.kind(),
1235
+ crate::transport::BackendKind::ConPty
1236
+ );
1237
+ if !primary_is_conpty {
1238
+ let workspace_transport = shutdown_workspace_transport(workspace);
1239
+ match crate::transport::Transport::has_session(&workspace_transport, session) {
1240
+ Ok(true) => residual = true,
1241
+ Ok(false) => {}
1242
+ Err(err) if tmux_absent_error(&err.to_string()) => {}
1243
+ Err(err) => {
1244
+ error.get_or_insert_with(|| err.to_string());
1245
+ residual = true;
1246
+ }
1247
+ }
1248
+ let default_transport = crate::tmux_backend::TmuxBackend::new();
1249
+ match crate::transport::Transport::has_session(&default_transport, session) {
1250
+ Ok(true) => residual = true,
1251
+ Ok(false) => {}
1252
+ Err(err) if tmux_absent_error(&err.to_string()) => {}
1253
+ Err(err) => {
1254
+ error.get_or_insert_with(|| err.to_string());
1255
+ residual = true;
1256
+ }
1212
1257
  }
1213
1258
  }
1214
1259
  let sessions = if residual {
@@ -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> {