@team-agent/installer 0.3.12 → 0.3.14

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 (35) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +23 -0
  4. package/crates/team-agent/src/cli/leader.rs +2 -5
  5. package/crates/team-agent/src/cli/mod.rs +258 -79
  6. package/crates/team-agent/src/cli/tests/compile.rs +69 -0
  7. package/crates/team-agent/src/cli/tests/main_preserved.rs +68 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +160 -2
  9. package/crates/team-agent/src/compiler.rs +30 -0
  10. package/crates/team-agent/src/coordinator/tick.rs +16 -83
  11. package/crates/team-agent/src/leader/lease.rs +4 -20
  12. package/crates/team-agent/src/leader/mod.rs +3 -1
  13. package/crates/team-agent/src/leader/owner_bind.rs +46 -48
  14. package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
  15. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
  16. package/crates/team-agent/src/leader/rediscover.rs +34 -17
  17. package/crates/team-agent/src/leader/start.rs +279 -4
  18. package/crates/team-agent/src/lifecycle/launch.rs +279 -24
  19. package/crates/team-agent/src/lifecycle/restart/common.rs +50 -40
  20. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +343 -49
  21. package/crates/team-agent/src/lifecycle/restart/selection.rs +9 -6
  22. package/crates/team-agent/src/lifecycle/tests/core.rs +181 -45
  23. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +390 -61
  24. package/crates/team-agent/src/lifecycle/types.rs +28 -0
  25. package/crates/team-agent/src/messaging/delivery.rs +5 -1
  26. package/crates/team-agent/src/messaging/helpers.rs +1 -1
  27. package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
  28. package/crates/team-agent/src/provider/adapter.rs +6 -3
  29. package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
  30. package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
  31. package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
  32. package/crates/team-agent/src/tmux_backend.rs +5 -0
  33. package/crates/team-agent/src/transport/test_support.rs +22 -7
  34. package/crates/team-agent/src/transport.rs +4 -0
  35. package/package.json +4 -4
@@ -0,0 +1,214 @@
1
+ //! Positive provider attribution for leader panes.
2
+
3
+ use std::collections::BTreeMap;
4
+ use std::process::Command;
5
+
6
+ use crate::provider::Provider;
7
+ use crate::transport::PaneInfo;
8
+
9
+ use super::helpers::{parse_provider, provider_wire};
10
+
11
+ const PROVIDER_COMMANDS: &[(&str, Provider)] = &[
12
+ ("claude", Provider::ClaudeCode),
13
+ ("claude.exe", Provider::ClaudeCode),
14
+ ("codex", Provider::Codex),
15
+ ("copilot", Provider::Copilot),
16
+ ("gemini", Provider::GeminiCli),
17
+ ("fake", Provider::Fake),
18
+ ];
19
+
20
+ pub(crate) fn attribute_pane_provider(pane: &PaneInfo) -> Option<Provider> {
21
+ attribute_pane_provider_with_process(pane, provider_from_pid_env, provider_from_pid_argv)
22
+ }
23
+
24
+ fn attribute_pane_provider_with_process(
25
+ pane: &PaneInfo,
26
+ provider_from_env_pid: impl Fn(u32) -> Option<Provider>,
27
+ provider_from_argv_pid: impl Fn(u32) -> Option<Provider>,
28
+ ) -> Option<Provider> {
29
+ provider_from_env(&pane.leader_env)
30
+ .or_else(|| pane.pane_pid.and_then(provider_from_env_pid))
31
+ .or_else(|| {
32
+ pane.current_command
33
+ .as_deref()
34
+ .and_then(attribute_command_provider)
35
+ })
36
+ .or_else(|| pane.pane_pid.and_then(provider_from_argv_pid))
37
+ }
38
+
39
+ pub(crate) fn attribute_command_provider(command: &str) -> Option<Provider> {
40
+ provider_from_text(command)
41
+ }
42
+
43
+ pub(crate) fn command_matches_provider(provider: Provider, command: &str) -> bool {
44
+ attribute_command_provider(command)
45
+ .is_some_and(|candidate| provider_matches(candidate, provider))
46
+ }
47
+
48
+ pub(crate) fn provider_wire_from_command(command: &str) -> Option<&'static str> {
49
+ attribute_command_provider(command).map(provider_wire_for_command_attribution)
50
+ }
51
+
52
+ fn provider_from_env(env: &BTreeMap<String, String>) -> Option<Provider> {
53
+ env.get("TEAM_AGENT_LEADER_PROVIDER")
54
+ .filter(|raw| !raw.is_empty())
55
+ .and_then(|raw| parse_provider(raw))
56
+ }
57
+
58
+ fn provider_from_pid_argv(pid: u32) -> Option<Provider> {
59
+ process_command_line(pid)
60
+ .as_deref()
61
+ .and_then(provider_from_text)
62
+ }
63
+
64
+ fn provider_from_pid_env(pid: u32) -> Option<Provider> {
65
+ process_environment(pid)
66
+ .as_deref()
67
+ .and_then(provider_from_env_text)
68
+ }
69
+
70
+ fn provider_from_text(text: &str) -> Option<Provider> {
71
+ let lower = text.to_ascii_lowercase();
72
+ PROVIDER_COMMANDS
73
+ .iter()
74
+ .find_map(|(needle, provider)| lower.contains(needle).then_some(*provider))
75
+ }
76
+
77
+ fn provider_from_env_text(text: &str) -> Option<Provider> {
78
+ text.split(|ch: char| ch == '\0' || ch.is_ascii_whitespace())
79
+ .find_map(|part| part.strip_prefix("TEAM_AGENT_LEADER_PROVIDER="))
80
+ .filter(|raw| !raw.is_empty())
81
+ .and_then(parse_provider)
82
+ }
83
+
84
+ pub(crate) fn provider_matches(candidate: Provider, expected: Provider) -> bool {
85
+ candidate == expected
86
+ || matches!(
87
+ (candidate, expected),
88
+ (Provider::Claude, Provider::ClaudeCode) | (Provider::ClaudeCode, Provider::Claude)
89
+ )
90
+ }
91
+
92
+ fn provider_wire_for_command_attribution(provider: Provider) -> &'static str {
93
+ match provider {
94
+ Provider::Claude | Provider::ClaudeCode => "claude",
95
+ other => provider_wire(other),
96
+ }
97
+ }
98
+
99
+ #[cfg(target_os = "linux")]
100
+ fn process_command_line(pid: u32) -> Option<String> {
101
+ let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
102
+ let parts = bytes
103
+ .split(|byte| *byte == 0)
104
+ .filter(|part| !part.is_empty())
105
+ .map(|part| String::from_utf8_lossy(part).to_string())
106
+ .collect::<Vec<_>>();
107
+ (!parts.is_empty()).then(|| parts.join(" "))
108
+ }
109
+
110
+ #[cfg(target_os = "linux")]
111
+ fn process_environment(pid: u32) -> Option<String> {
112
+ String::from_utf8(std::fs::read(format!("/proc/{pid}/environ")).ok()?).ok()
113
+ }
114
+
115
+ #[cfg(not(target_os = "linux"))]
116
+ fn process_command_line(pid: u32) -> Option<String> {
117
+ let output = crate::os_probe::bounded_command_output_with_probe(
118
+ Command::new("ps")
119
+ .arg("-p")
120
+ .arg(pid.to_string())
121
+ .args(["-o", "command="]),
122
+ "provider_argv",
123
+ Some(pid),
124
+ )
125
+ .ok()?;
126
+ output
127
+ .status
128
+ .success()
129
+ .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
130
+ .filter(|text| !text.is_empty())
131
+ }
132
+
133
+ #[cfg(not(target_os = "linux"))]
134
+ fn process_environment(pid: u32) -> Option<String> {
135
+ let _ = pid;
136
+ None
137
+ }
138
+
139
+ #[cfg(test)]
140
+ mod tests {
141
+ use super::*;
142
+ use crate::transport::{PaneId, SessionName};
143
+ fn pane(command: &str, pid: Option<u32>) -> PaneInfo {
144
+ PaneInfo {
145
+ pane_id: PaneId::new("%1"),
146
+ session: SessionName::new("team-test"),
147
+ window_index: None,
148
+ window_name: None,
149
+ pane_index: None,
150
+ tty: None,
151
+ current_command: Some(command.to_string()),
152
+ current_path: None,
153
+ active: true,
154
+ pane_pid: pid,
155
+ leader_env: BTreeMap::new(),
156
+ }
157
+ }
158
+
159
+ #[test]
160
+ fn direct_command_form_recognizes_copilot() {
161
+ assert_eq!(
162
+ attribute_pane_provider(&pane("copilot --allow-all-tools", None)),
163
+ Some(Provider::Copilot)
164
+ );
165
+ }
166
+
167
+ #[test]
168
+ fn node_running_non_provider_stays_none() {
169
+ assert_eq!(attribute_pane_provider(&pane("node", None)), None);
170
+ assert_eq!(
171
+ attribute_command_provider("node /tmp/not-a-provider.js"),
172
+ None
173
+ );
174
+ }
175
+
176
+ #[test]
177
+ fn pane_env_provider_wins_over_command() {
178
+ let mut pane = pane("codex", None);
179
+ pane.leader_env.insert(
180
+ "TEAM_AGENT_LEADER_PROVIDER".to_string(),
181
+ "copilot".to_string(),
182
+ );
183
+
184
+ assert_eq!(attribute_pane_provider(&pane), Some(Provider::Copilot));
185
+ }
186
+
187
+ #[test]
188
+ fn node_form_copilot_is_attributed_from_process_argv() {
189
+ let pane = pane("node", Some(4242));
190
+
191
+ assert_eq!(
192
+ attribute_pane_provider_with_process(
193
+ &pane,
194
+ |_| None,
195
+ |pid| (pid == 4242).then_some(Provider::Copilot),
196
+ ),
197
+ Some(Provider::Copilot)
198
+ );
199
+ }
200
+
201
+ #[test]
202
+ fn process_env_provider_wins_for_node_form() {
203
+ let pane = pane("node /opt/homebrew/bin/copilot", Some(4242));
204
+
205
+ assert_eq!(
206
+ attribute_pane_provider_with_process(
207
+ &pane,
208
+ |pid| (pid == 4242).then_some(Provider::Copilot),
209
+ |_| Some(Provider::Codex),
210
+ ),
211
+ Some(Provider::Copilot)
212
+ );
213
+ }
214
+ }
@@ -81,13 +81,13 @@ fn event_named(events: &[Value], name: &str) -> Value {
81
81
  }
82
82
 
83
83
  #[test]
84
- fn leader_command_provider_recognizes_copilot() {
84
+ fn attribute_command_provider_recognizes_copilot() {
85
85
  assert_eq!(
86
- leader_command_provider("copilot --allow-all-tools"),
86
+ super::super::attribute_command_provider("copilot --allow-all-tools"),
87
87
  Some(Provider::Copilot)
88
88
  );
89
89
  assert_eq!(
90
- leader_command_provider("/usr/local/bin/copilot"),
90
+ super::super::attribute_command_provider("/usr/local/bin/copilot"),
91
91
  Some(Provider::Copilot)
92
92
  );
93
93
  }
@@ -383,6 +383,7 @@ struct LeaderTarget {
383
383
  current_command: Option<String>,
384
384
  current_path: Option<PathBuf>,
385
385
  active: bool,
386
+ pane_pid: Option<u32>,
386
387
  leader_env: BTreeMap<String, String>,
387
388
  provider: Option<Provider>,
388
389
  leader_session_uuid: Option<LeaderSessionUuid>,
@@ -394,7 +395,7 @@ impl LeaderTarget {
394
395
  if !info.active {
395
396
  return None;
396
397
  }
397
- let provider = leader_command_provider(info.current_command.as_deref().unwrap_or(""));
398
+ let provider = super::attribute_pane_provider(info);
398
399
  let leader_session_uuid = info
399
400
  .leader_env
400
401
  .get("TEAM_AGENT_LEADER_SESSION_UUID")
@@ -411,6 +412,7 @@ impl LeaderTarget {
411
412
  current_command: info.current_command.clone(),
412
413
  current_path: info.current_path.clone(),
413
414
  active: info.active,
415
+ pane_pid: info.pane_pid,
414
416
  leader_env: info.leader_env.clone(),
415
417
  provider,
416
418
  leader_session_uuid,
@@ -508,8 +510,26 @@ impl OwnerIdentity {
508
510
  fn target_from_value(value: &Value) -> Option<LeaderTarget> {
509
511
  let pane_id = get_str(value, "pane_id").or_else(|| get_str(value, "pane"))?;
510
512
  let current_command = get_str(value, "pane_current_command").or_else(|| get_str(value, "current_command"));
511
- let provider = current_command.as_deref().and_then(leader_command_provider);
512
513
  let leader_env = map_env(value.get("leader_env").or_else(|| value.get("env")));
514
+ let pane_pid = get_u32(value, "pane_pid");
515
+ let provider = super::attribute_pane_provider(&PaneInfo {
516
+ pane_id: PaneId::new(pane_id.clone()),
517
+ session: get_str(value, "session_name")
518
+ .or_else(|| get_str(value, "session"))
519
+ .map(SessionName::new)
520
+ .unwrap_or_else(|| SessionName::new("")),
521
+ window_index: get_u32(value, "window_index"),
522
+ window_name: get_str(value, "window_name").map(WindowName::new),
523
+ pane_index: get_u32(value, "pane_index"),
524
+ tty: get_str(value, "pane_tty").or_else(|| get_str(value, "tty")),
525
+ current_command: current_command.clone(),
526
+ current_path: get_str(value, "pane_current_path")
527
+ .or_else(|| get_str(value, "current_path"))
528
+ .map(PathBuf::from),
529
+ active: value.get("active").and_then(Value::as_bool).unwrap_or(true),
530
+ pane_pid,
531
+ leader_env: leader_env.clone(),
532
+ });
513
533
  let leader_session_uuid = get_str(value, "leader_session_uuid")
514
534
  .or_else(|| leader_env.get("TEAM_AGENT_LEADER_SESSION_UUID").cloned())
515
535
  .and_then(|raw| serde_json::from_value(Value::String(raw)).ok());
@@ -528,6 +548,7 @@ fn target_from_value(value: &Value) -> Option<LeaderTarget> {
528
548
  .or_else(|| get_str(value, "current_path"))
529
549
  .map(PathBuf::from),
530
550
  active: value.get("active").and_then(Value::as_bool).unwrap_or(true),
551
+ pane_pid,
531
552
  leader_env,
532
553
  provider,
533
554
  leader_session_uuid,
@@ -584,21 +605,6 @@ fn path_in_workspace(path: &Path, workspace: &Path) -> bool {
584
605
  cwd == root || cwd.starts_with(root)
585
606
  }
586
607
 
587
- fn leader_command_provider(command: &str) -> Option<Provider> {
588
- let lower = command.to_ascii_lowercase();
589
- if lower.contains("claude") {
590
- Some(Provider::ClaudeCode)
591
- } else if lower.contains("codex") {
592
- Some(Provider::Codex)
593
- } else if lower.contains("copilot") {
594
- Some(Provider::Copilot)
595
- } else if lower.contains("fake") {
596
- Some(Provider::Fake)
597
- } else {
598
- None
599
- }
600
- }
601
-
602
608
  fn target_matches_owner_identity(target: &LeaderTarget, identity: &OwnerIdentity) -> bool {
603
609
  identity
604
610
  .pane_id
@@ -624,6 +630,7 @@ fn pane_info_value(
624
630
  "pane_index": info.pane_index,
625
631
  "pane_tty": info.tty,
626
632
  "pane_current_command": info.current_command,
633
+ "pane_pid": info.pane_pid,
627
634
  "pane_current_path": info.current_path.as_ref().map(|path| path.to_string_lossy().to_string()),
628
635
  "fingerprint": info.leader_env.get("TEAM_AGENT_MACHINE_FINGERPRINT"),
629
636
  "provider": provider.map(provider_wire),
@@ -1100,5 +1107,15 @@ fn get_str(value: &Value, key: &str) -> Option<String> {
1100
1107
  .filter(|s| !s.is_empty())
1101
1108
  }
1102
1109
 
1110
+ fn get_u32(value: &Value, key: &str) -> Option<u32> {
1111
+ value
1112
+ .get(key)
1113
+ .and_then(|raw| {
1114
+ raw.as_u64()
1115
+ .or_else(|| raw.as_i64().and_then(|n| u64::try_from(n).ok()))
1116
+ })
1117
+ .and_then(|n| u32::try_from(n).ok())
1118
+ }
1119
+
1103
1120
  #[cfg(test)]
1104
1121
  mod tests;
@@ -2,12 +2,12 @@
2
2
 
3
3
  use std::collections::BTreeMap;
4
4
  use std::io::IsTerminal;
5
- use std::path::Path;
5
+ use std::path::{Path, PathBuf};
6
6
  use std::process::{Command, Stdio};
7
7
 
8
8
  use crate::provider::{get_adapter, Provider};
9
9
  use crate::tmux_backend::TmuxBackend;
10
- use crate::transport::{SessionName, Transport};
10
+ use crate::transport::{PaneId, SessionName, Target, Transport};
11
11
 
12
12
  use super::helpers::{
13
13
  provider_wire, resolve_workspace_for_hash, sanitize_session_folder, sha1_hex_prefix,
@@ -125,7 +125,10 @@ pub(crate) fn leader_env_for_identity(
125
125
  identity.team_id.as_str().to_string(),
126
126
  );
127
127
  if provider == Provider::Copilot {
128
- leader_env.insert("COPILOT_DISABLE_TERMINAL_TITLE".to_string(), "1".to_string());
128
+ leader_env.insert(
129
+ "COPILOT_DISABLE_TERMINAL_TITLE".to_string(),
130
+ "1".to_string(),
131
+ );
129
132
  }
130
133
  leader_env
131
134
  }
@@ -171,7 +174,7 @@ pub fn execute_leader_plan(
171
174
  let detached = plan.mode == LeaderStartMode::NewTmuxSession
172
175
  && !std::io::stdin().is_terminal()
173
176
  && insert_detach_flag(&mut argv);
174
- let status = run_leader_argv(&argv, &plan.leader_env)?;
177
+ let status = run_leader_argv(&argv, &plan.leader_env, plan, workspace)?;
175
178
  let code = status.code();
176
179
  if !status.success() {
177
180
  return Err(LeaderError::Start(format!(
@@ -294,6 +297,8 @@ fn insert_detach_flag(argv: &mut Vec<String>) -> bool {
294
297
  fn run_leader_argv(
295
298
  argv: &[String],
296
299
  env: &BTreeMap<String, String>,
300
+ plan: &LeaderStartPlan,
301
+ workspace: &Path,
297
302
  ) -> Result<std::process::ExitStatus, LeaderError> {
298
303
  let Some(program) = argv.first() else {
299
304
  return Err(LeaderError::Start(
@@ -307,9 +312,84 @@ fn run_leader_argv(
307
312
  .stdout(Stdio::inherit())
308
313
  .stderr(Stdio::inherit())
309
314
  .spawn()?;
315
+ if plan.mode == LeaderStartMode::ExecProvider {
316
+ spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
317
+ }
310
318
  child.wait().map_err(LeaderError::Io)
311
319
  }
312
320
 
321
+ fn spawn_exec_provider_startup_prompt_handler(provider: Provider, workspace: PathBuf) {
322
+ let Some(pane_id) = std::env::var("TMUX_PANE")
323
+ .ok()
324
+ .filter(|value| !value.trim().is_empty())
325
+ else {
326
+ write_leader_startup_prompt_event(
327
+ &workspace,
328
+ "leader.startup_prompt_skipped",
329
+ serde_json::json!({
330
+ "provider": provider_wire(provider),
331
+ "reason": "tmux_pane_missing",
332
+ "action": "skip",
333
+ }),
334
+ );
335
+ return;
336
+ };
337
+ std::thread::spawn(move || {
338
+ let transport = tmux_transport_for_current_pane();
339
+ let _ = handle_exec_provider_startup_prompts(
340
+ provider, &workspace, &pane_id, &transport, 30, 0.5,
341
+ );
342
+ });
343
+ }
344
+
345
+ fn tmux_transport_for_current_pane() -> TmuxBackend {
346
+ crate::tmux_backend::socket_name_from_tmux_env()
347
+ .map(|endpoint| TmuxBackend::for_tmux_endpoint(&endpoint))
348
+ .unwrap_or_else(TmuxBackend::new)
349
+ }
350
+
351
+ pub fn handle_exec_provider_startup_prompts(
352
+ provider: Provider,
353
+ workspace: &Path,
354
+ pane_id: &str,
355
+ transport: &dyn Transport,
356
+ checks: usize,
357
+ sleep_s: f64,
358
+ ) -> crate::provider::StartupPromptOutcome {
359
+ let target = Target::Pane(PaneId::new(pane_id.to_string()));
360
+ let outcome =
361
+ get_adapter(provider).handle_startup_prompts_outcome(transport, &target, checks, sleep_s);
362
+ for handled in &outcome.handled {
363
+ write_leader_startup_prompt_event(
364
+ workspace,
365
+ "leader.startup_prompt_handled",
366
+ serde_json::json!({
367
+ "provider": provider_wire(provider),
368
+ "pane_id": pane_id,
369
+ "prompt": handled.prompt,
370
+ "action": handled.action,
371
+ }),
372
+ );
373
+ }
374
+ if let Some(error) = &outcome.capture_error {
375
+ write_leader_startup_prompt_event(
376
+ workspace,
377
+ "leader.startup_prompt_capture_failed",
378
+ serde_json::json!({
379
+ "provider": provider_wire(provider),
380
+ "pane_id": pane_id,
381
+ "action": "capture",
382
+ "error": error,
383
+ }),
384
+ );
385
+ }
386
+ outcome
387
+ }
388
+
389
+ fn write_leader_startup_prompt_event(workspace: &Path, event: &str, fields: serde_json::Value) {
390
+ let _ = crate::event_log::EventLog::new(workspace).write(event, fields);
391
+ }
392
+
313
393
  fn ensure_tmux_installed() -> Result<(), LeaderError> {
314
394
  match Command::new("tmux").arg("-V").output() {
315
395
  Ok(output) if output.status.success() => Ok(()),
@@ -388,3 +468,198 @@ fn shlex_quote(raw: &str) -> String {
388
468
  format!("'{}'", raw.replace('\'', "'\"'\"'"))
389
469
  }
390
470
  }
471
+
472
+ #[cfg(test)]
473
+ mod tests {
474
+ use std::collections::BTreeMap;
475
+ use std::path::Path;
476
+ use std::sync::Mutex;
477
+
478
+ use crate::model::enums::PaneLiveness;
479
+ use crate::provider::{Provider, COPILOT_READY_MARKER, COPILOT_TRUST_PROMPT_MARKER};
480
+ use crate::transport::{
481
+ AttachOutcome, BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport,
482
+ InjectStage, InjectVerification, Key, PaneField, PaneId, PaneInfo, SessionName,
483
+ SetEnvOutcome, SpawnResult, SubmitVerification, Target, Transport, TransportError,
484
+ TurnVerification, WindowName,
485
+ };
486
+
487
+ use super::handle_exec_provider_startup_prompts;
488
+
489
+ struct ScriptedTransport {
490
+ screens: Mutex<Vec<String>>,
491
+ sent: Mutex<Vec<(Target, Vec<Key>)>>,
492
+ }
493
+
494
+ impl ScriptedTransport {
495
+ fn new(screens: Vec<String>) -> Self {
496
+ Self {
497
+ screens: Mutex::new(screens),
498
+ sent: Mutex::new(Vec::new()),
499
+ }
500
+ }
501
+
502
+ fn sent(&self) -> Vec<(Target, Vec<Key>)> {
503
+ match self.sent.lock() {
504
+ Ok(guard) => guard.clone(),
505
+ Err(poisoned) => poisoned.into_inner().clone(),
506
+ }
507
+ }
508
+ }
509
+
510
+ impl Transport for ScriptedTransport {
511
+ fn kind(&self) -> BackendKind {
512
+ BackendKind::Tmux
513
+ }
514
+
515
+ fn spawn_first(
516
+ &self,
517
+ _session: &SessionName,
518
+ _window: &WindowName,
519
+ _argv: &[String],
520
+ _cwd: &Path,
521
+ _env: &BTreeMap<String, String>,
522
+ ) -> Result<SpawnResult, TransportError> {
523
+ Err(TransportError::Io(std::io::Error::other(
524
+ "spawn_first not used by startup-prompt test",
525
+ )))
526
+ }
527
+
528
+ fn spawn_into(
529
+ &self,
530
+ _session: &SessionName,
531
+ _window: &WindowName,
532
+ _argv: &[String],
533
+ _cwd: &Path,
534
+ _env: &BTreeMap<String, String>,
535
+ ) -> Result<SpawnResult, TransportError> {
536
+ Err(TransportError::Io(std::io::Error::other(
537
+ "spawn_into not used by startup-prompt test",
538
+ )))
539
+ }
540
+
541
+ fn inject(
542
+ &self,
543
+ _target: &Target,
544
+ _payload: &InjectPayload,
545
+ _submit: Key,
546
+ _bracketed: bool,
547
+ ) -> Result<InjectReport, TransportError> {
548
+ Ok(InjectReport {
549
+ stage_reached: InjectStage::Submit,
550
+ inject_verification: InjectVerification::CaptureContainsToken,
551
+ submit_verification: SubmitVerification::EnterSentWithoutPlaceholderCheck,
552
+ turn_verification: TurnVerification::NotRequired,
553
+ attempts: 1,
554
+ })
555
+ }
556
+
557
+ fn send_keys(&self, target: &Target, keys: &[Key]) -> Result<(), TransportError> {
558
+ match self.sent.lock() {
559
+ Ok(mut guard) => guard.push((target.clone(), keys.to_vec())),
560
+ Err(poisoned) => poisoned.into_inner().push((target.clone(), keys.to_vec())),
561
+ }
562
+ Ok(())
563
+ }
564
+
565
+ fn capture(
566
+ &self,
567
+ _target: &Target,
568
+ range: CaptureRange,
569
+ ) -> Result<CapturedText, TransportError> {
570
+ let text = match self.screens.lock() {
571
+ Ok(mut guard) => {
572
+ if guard.is_empty() {
573
+ String::new()
574
+ } else {
575
+ guard.remove(0)
576
+ }
577
+ }
578
+ Err(poisoned) => {
579
+ let mut guard = poisoned.into_inner();
580
+ if guard.is_empty() {
581
+ String::new()
582
+ } else {
583
+ guard.remove(0)
584
+ }
585
+ }
586
+ };
587
+ Ok(CapturedText { text, range })
588
+ }
589
+
590
+ fn query(
591
+ &self,
592
+ _target: &Target,
593
+ _field: PaneField,
594
+ ) -> Result<Option<String>, TransportError> {
595
+ Ok(None)
596
+ }
597
+
598
+ fn liveness(&self, _pane: &PaneId) -> Result<PaneLiveness, TransportError> {
599
+ Ok(PaneLiveness::Unknown)
600
+ }
601
+
602
+ fn list_targets(&self) -> Result<Vec<PaneInfo>, TransportError> {
603
+ Ok(Vec::new())
604
+ }
605
+
606
+ fn has_session(&self, _session: &SessionName) -> Result<bool, TransportError> {
607
+ Ok(false)
608
+ }
609
+
610
+ fn list_windows(&self, _session: &SessionName) -> Result<Vec<WindowName>, TransportError> {
611
+ Ok(Vec::new())
612
+ }
613
+
614
+ fn set_session_env(
615
+ &self,
616
+ _session: &SessionName,
617
+ _key: &str,
618
+ _value: &str,
619
+ ) -> Result<SetEnvOutcome, TransportError> {
620
+ Ok(SetEnvOutcome::Applied)
621
+ }
622
+
623
+ fn kill_session(&self, _session: &SessionName) -> Result<(), TransportError> {
624
+ Ok(())
625
+ }
626
+
627
+ fn kill_window(&self, _target: &Target) -> Result<(), TransportError> {
628
+ Ok(())
629
+ }
630
+
631
+ fn attach_session(&self, _session: &SessionName) -> Result<AttachOutcome, TransportError> {
632
+ Ok(AttachOutcome::Unsupported {
633
+ reason: "not used by startup-prompt test".to_string(),
634
+ })
635
+ }
636
+ }
637
+
638
+ #[test]
639
+ fn exec_provider_leader_startup_prompt_handler_reuses_copilot_adapter() {
640
+ let transport = ScriptedTransport::new(vec![
641
+ COPILOT_TRUST_PROMPT_MARKER.to_string(),
642
+ COPILOT_READY_MARKER.to_string(),
643
+ ]);
644
+ let workspace =
645
+ std::env::temp_dir().join(format!("ta_rs_red2_leader_startup_{}", std::process::id()));
646
+ let _ = std::fs::create_dir_all(&workspace);
647
+
648
+ let outcome = handle_exec_provider_startup_prompts(
649
+ Provider::Copilot,
650
+ &workspace,
651
+ "%0",
652
+ &transport,
653
+ 5,
654
+ 0.0,
655
+ );
656
+
657
+ assert_eq!(outcome.handled.len(), 1);
658
+ assert_eq!(outcome.handled[0].prompt, "copilot_workspace_trust");
659
+ assert_eq!(outcome.handled[0].action, "sent_enter_yes_session");
660
+ let sent = transport.sent();
661
+ assert_eq!(sent.len(), 1);
662
+ assert_eq!(sent[0].0, Target::Pane(PaneId::new("%0")));
663
+ assert_eq!(sent[0].1, vec![Key::Enter]);
664
+ }
665
+ }