@team-agent/installer 0.3.12 → 0.3.13

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 (29) 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 +90 -4
  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/lifecycle/launch.rs +158 -12
  18. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +229 -28
  19. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +135 -8
  20. package/crates/team-agent/src/lifecycle/types.rs +28 -0
  21. package/crates/team-agent/src/messaging/delivery.rs +5 -1
  22. package/crates/team-agent/src/messaging/helpers.rs +1 -1
  23. package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
  24. package/crates/team-agent/src/provider/adapter.rs +6 -3
  25. package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
  26. package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
  27. package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
  28. package/crates/team-agent/src/transport/test_support.rs +22 -7
  29. package/package.json +4 -4
@@ -1030,24 +1030,151 @@ fn restart_with_transport_spawns_resumable_workers_not_stub() {
1030
1030
  matches!(result, Ok(RestartReport::Restarted { coordinator_started: true, .. })),
1031
1031
  "restart must reach RestartReport::Restarted with coordinator_started=true (AlreadyRunning, seeded); got {result:?}"
1032
1032
  );
1033
+ let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1034
+ let completed = events
1035
+ .iter()
1036
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.completed"))
1037
+ .expect("restart.completed event for successful restart");
1038
+ assert_eq!(completed.get("rc").and_then(|v| v.as_str()), Some("ok"));
1033
1039
 
1034
1040
  let ws = restart_ws_two_resumable_workers();
1035
1041
  let dead_after_first = OfflineTransport::new().with_session_absent_after_spawn_first();
1036
- let result = restart_with_transport(&ws, false, None, &dead_after_first);
1042
+ let result =
1043
+ restart_with_transport_with_readiness_deadline(&ws, false, None, &dead_after_first, Some(0));
1037
1044
  let recorded = dead_after_first.spawn_records();
1038
1045
  assert_eq!(
1039
1046
  recorded.len(),
1040
- 1,
1041
- "if the session disappears after alpha, restart must stop before spawning bravo; got result={result:?} recorded={recorded:?}"
1047
+ 2,
1048
+ "if the session disappears after alpha, restart must isolate alpha and still try bravo; got result={result:?} recorded={recorded:?}"
1049
+ );
1050
+ assert!(
1051
+ recorded.iter().all(|(kind, _)| kind == "spawn_first"),
1052
+ "restart must not call spawn_into/new-window against a dead server; result={result:?} recorded={recorded:?}"
1053
+ );
1054
+ assert!(
1055
+ dead_after_first.calls().iter().all(|call| *call != "kill_session"),
1056
+ "a single worker/session disappearance must not trigger another shared-session kill; calls={:?}",
1057
+ dead_after_first.calls()
1058
+ );
1059
+ let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1060
+ let failed = events
1061
+ .iter()
1062
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.agent_failed"))
1063
+ .expect("restart.agent_failed event for isolated alpha");
1064
+ assert_eq!(failed.get("agent_id").and_then(|v| v.as_str()), Some("alpha"));
1065
+ assert!(
1066
+ failed
1067
+ .get("error")
1068
+ .and_then(|v| v.as_str())
1069
+ .is_some_and(|error| error.contains("session_disappeared_after_spawn")),
1070
+ "restart must report the first resumed agent/session disappearance explicitly; event={failed} result={result:?}"
1071
+ );
1072
+ }
1073
+
1074
+ #[test]
1075
+ fn restart_spawn_failure_isolated_to_partial_report() {
1076
+ let ws = restart_ws_two_resumable_workers();
1077
+ let transport = OfflineTransport::new().with_spawn_failure("bravo", "injected bravo failure");
1078
+
1079
+ let result = restart_with_transport(&ws, false, None, &transport);
1080
+
1081
+ let report = result.expect("single worker failure must be represented as partial report");
1082
+ let RestartReport::Partial {
1083
+ agents,
1084
+ failed_agents,
1085
+ coordinator_started,
1086
+ ..
1087
+ } = report
1088
+ else {
1089
+ panic!("single worker failure must return RestartReport::Partial; got {report:?}");
1090
+ };
1091
+ assert!(coordinator_started, "partial restart still starts coordinator for successful agents");
1092
+ assert_eq!(
1093
+ agents.iter().map(|agent| agent.agent_id.as_str()).collect::<Vec<_>>(),
1094
+ vec!["alpha"]
1095
+ );
1096
+ assert_eq!(failed_agents.len(), 1);
1097
+ assert_eq!(failed_agents[0].agent_id.as_str(), "bravo");
1098
+ assert_eq!(failed_agents[0].phase, "spawn");
1099
+
1100
+ let state = crate::state::persist::load_runtime_state(&ws).unwrap();
1101
+ assert_eq!(state.pointer("/agents/alpha/status").and_then(|v| v.as_str()), Some("running"));
1102
+ assert_eq!(state.pointer("/agents/bravo/status").and_then(|v| v.as_str()), Some("failed"));
1103
+ assert!(
1104
+ state
1105
+ .pointer("/agents/bravo/restart_error")
1106
+ .and_then(|v| v.as_str())
1107
+ .is_some_and(|error| error.contains("injected bravo failure")),
1108
+ "failed agent must retain restart_error in state: {state}"
1109
+ );
1110
+
1111
+ let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1112
+ let completed = events
1113
+ .iter()
1114
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.completed"))
1115
+ .expect("restart.completed event for partial restart");
1116
+ assert_eq!(completed.get("rc").and_then(|v| v.as_str()), Some("partial"));
1117
+ assert!(
1118
+ completed
1119
+ .get("successful_agents")
1120
+ .and_then(|v| v.as_array())
1121
+ .is_some_and(|agents| agents.iter().any(|agent| agent.as_str() == Some("alpha"))),
1122
+ "completed event must list successful agents: {completed}"
1042
1123
  );
1043
1124
  assert!(
1044
- recorded.iter().all(|(kind, _)| kind != "spawn_into"),
1045
- "restart must not call spawn_into/new-window for bravo against a dead server; result={result:?} recorded={recorded:?}"
1125
+ completed
1126
+ .get("failed_agents")
1127
+ .and_then(|v| v.as_array())
1128
+ .is_some_and(|agents| agents.iter().any(|agent| {
1129
+ agent.get("agent_id").and_then(|v| v.as_str()) == Some("bravo")
1130
+ && agent.get("phase").and_then(|v| v.as_str()) == Some("spawn")
1131
+ })),
1132
+ "completed event must list failed agents with phase: {completed}"
1046
1133
  );
1047
- let error = format!("{result:?}");
1134
+ }
1135
+
1136
+ #[test]
1137
+ fn restart_all_spawn_failures_return_failed_report() {
1138
+ let ws = restart_ws_one_resumable_worker();
1139
+ let transport = OfflineTransport::new().with_spawn_failure("alpha", "injected alpha failure");
1140
+
1141
+ let result = restart_with_transport(&ws, false, None, &transport);
1142
+
1143
+ let report = result.expect("all worker failures should return typed failed report");
1144
+ let RestartReport::Failed { failed_agents, .. } = report else {
1145
+ panic!("all worker failures must return RestartReport::Failed; got {report:?}");
1146
+ };
1147
+ assert_eq!(failed_agents.len(), 1);
1148
+ assert_eq!(failed_agents[0].agent_id.as_str(), "alpha");
1149
+ assert_eq!(failed_agents[0].phase, "spawn");
1150
+
1151
+ let events = crate::event_log::EventLog::new(&ws).tail(20).unwrap();
1152
+ let completed = events
1153
+ .iter()
1154
+ .find(|event| event.get("event").and_then(|v| v.as_str()) == Some("restart.completed"))
1155
+ .expect("restart.completed event for failed restart");
1156
+ assert_eq!(completed.get("rc").and_then(|v| v.as_str()), Some("fail"));
1157
+ }
1158
+
1159
+ #[test]
1160
+ fn restart_spawn_loop_keeps_failure_isolation_guard() {
1161
+ let source = include_str!("../restart/rebuild.rs");
1162
+ let start = source
1163
+ .find("BEGIN_B5_RESTART_ISOLATION_LOOP")
1164
+ .expect("B5 isolation loop start marker");
1165
+ let end = source
1166
+ .find("END_B5_RESTART_ISOLATION_LOOP")
1167
+ .expect("B5 isolation loop end marker");
1168
+ let body = &source[start..end];
1169
+ for forbidden in ["?;", "break", "return "] {
1170
+ assert!(
1171
+ !body.contains(forbidden),
1172
+ "B5 spawn loop must not fail-fast via {forbidden:?}; body={body}"
1173
+ );
1174
+ }
1048
1175
  assert!(
1049
- error.contains("session_disappeared_after_spawn") || error.contains("provider_resume_exited"),
1050
- "restart must report the first resumed agent/session disappearance explicitly, not continue to a later no-server failure; result={result:?}"
1176
+ body.contains("continue"),
1177
+ "B5 spawn loop should isolate per-agent failures and continue; body={body}"
1051
1178
  );
1052
1179
  }
1053
1180
 
@@ -620,6 +620,23 @@ pub enum RestartReport {
620
620
  next_actions: Vec<String>,
621
621
  attach_commands: Vec<String>,
622
622
  },
623
+ /// At least one worker failed during live spawn, but other workers were isolated
624
+ /// and restarted. The CLI reports `status=partial` and exits non-zero.
625
+ Partial {
626
+ session_name: SessionName,
627
+ agents: Vec<RestartedAgent>,
628
+ failed_agents: Vec<RestartFailedAgent>,
629
+ coordinator_started: bool,
630
+ next_actions: Vec<String>,
631
+ attach_commands: Vec<String>,
632
+ },
633
+ /// All workers failed during live spawn. No worker is reported as restarted.
634
+ Failed {
635
+ session_name: SessionName,
636
+ failed_agents: Vec<RestartFailedAgent>,
637
+ next_actions: Vec<String>,
638
+ attach_commands: Vec<String>,
639
+ },
623
640
  /// atomic refusal(`reason=resume_atomicity`):某 interacted worker 不可 resume
624
641
  /// 且非 allow_fresh。**nothing created yet**,无需回滚。
625
642
  RefusedResumeAtomicity {
@@ -652,6 +669,17 @@ pub struct RestartedAgent {
652
669
  pub session_id: Option<SessionId>,
653
670
  }
654
671
 
672
+ /// Worker restart failure isolated from the rest of the restart pass.
673
+ #[derive(Debug, Clone, PartialEq, Eq)]
674
+ pub struct RestartFailedAgent {
675
+ pub agent_id: AgentId,
676
+ pub restart_mode: StartMode,
677
+ pub decision: ResumeDecision,
678
+ pub session_id: Option<SessionId>,
679
+ pub phase: String,
680
+ pub error: String,
681
+ }
682
+
655
683
  /// atomic refusal 里的不可重建 worker(`orchestration.py:517`)。
656
684
  #[derive(Debug, Clone, PartialEq, Eq)]
657
685
  pub struct UnresumableWorker {
@@ -744,7 +744,7 @@ fn recipient_pane_has_actionable_startup_prompt(
744
744
  let provider = agent
745
745
  .and_then(|agent| agent.get("provider"))
746
746
  .and_then(serde_json::Value::as_str);
747
- if !matches!(provider, Some("codex" | "claude" | "claude_code")) {
747
+ if !matches!(provider, Some("codex" | "claude" | "claude_code" | "copilot")) {
748
748
  return false;
749
749
  }
750
750
  // step2-retry/scrollback root-cause (rt binary 6c9c6c1c): once the agent's
@@ -777,6 +777,10 @@ fn recipient_pane_has_actionable_startup_prompt(
777
777
  crate::provider::classify_claude_startup_screen(&captured),
778
778
  crate::provider::StartupScreenDecision::AnswerWorkspaceTrust
779
779
  ),
780
+ Some("copilot") => matches!(
781
+ crate::provider::classify_copilot_startup_screen(&captured),
782
+ crate::provider::StartupScreenDecision::AnswerWorkspaceTrust
783
+ ),
780
784
  _ => false,
781
785
  }
782
786
  }
@@ -123,7 +123,7 @@ pub(crate) fn non_provider_command(command: &str) -> Option<&str> {
123
123
  let base = command.rsplit('/').next().unwrap_or(command);
124
124
  let normalized = base.to_ascii_lowercase();
125
125
  match normalized.as_str() {
126
- "" | "codex" | "claude" | "gemini" | "openai" | "team-agent" => None,
126
+ "" | "codex" | "claude" | "copilot" | "gemini" | "openai" | "team-agent" => None,
127
127
  _ => Some(base),
128
128
  }
129
129
  }
@@ -174,6 +174,20 @@ fn classify_idle_prompt_beats_recent_output_for_just_launched_agent() {
174
174
  );
175
175
  }
176
176
 
177
+ #[test]
178
+ fn classify_copilot_idle_prompt_is_not_masked_by_current_command() {
179
+ let state = json(serde_json::json!({}));
180
+ let a = classify_agent_activity(
181
+ &state,
182
+ " / commands · ? help\n❯ \n",
183
+ false,
184
+ Some("copilot"),
185
+ None,
186
+ );
187
+ assert_eq!(a.status, ActivityStatus::Idle);
188
+ assert_eq!(a.confidence, 0.9);
189
+ }
190
+
177
191
  // ════════════════════════════════════════════════════════════════════════
178
192
  // GROUP H — attempt_trust_auto_answer: own-vs-foreign realpath + fail-safe
179
193
  // pane-width + opt-in gate + reason byte-locks. leader_panes.py:383-470.
@@ -233,6 +233,11 @@ pub trait ProviderAdapter {
233
233
  transport, target, checks, sleep_s,
234
234
  )
235
235
  }
236
+ Provider::Copilot => {
237
+ super::startup_prompt::copilot_handle_startup_prompts(
238
+ transport, target, checks, sleep_s,
239
+ )
240
+ }
236
241
  _ => super::startup_prompt::StartupPromptOutcome::default(),
237
242
  }))
238
243
  .unwrap_or_default()
@@ -869,9 +874,7 @@ impl ProviderAdapter for BasicProviderAdapter {
869
874
  match self.provider {
870
875
  Provider::Claude | Provider::ClaudeCode => patterns(r"[>❯]\s", r"[✶✢✽✻✳·].*…", r"Error|Traceback"),
871
876
  Provider::Codex => patterns(r"(›|❯|codex>)", r"•.*esc to interrupt", r"Error|Traceback|panic"),
872
- // C-3-3 cr verdict: copilot 真值待用户真会话样本(§E3 line 105),一期占位
873
- // 仅 error 行至少能识别;idle/processing 留 Unknown(N11 守,classify→None)。
874
- Provider::Copilot => patterns(r">", r"working|processing", r"Error|panic"),
877
+ Provider::Copilot => patterns(r"(?m)^\s*❯\s*$| / commands · \? help", r"working|processing", r"Error|panic"),
875
878
  Provider::GeminiCli | Provider::Fake => patterns(r">", r"working|processing", r"Error|Traceback"),
876
879
  }
877
880
  }
@@ -28,6 +28,18 @@ const CLAUDE_TRUST_MARKERS: &[&str] = &[
28
28
  "Enter to confirm",
29
29
  ];
30
30
  const CLAUDE_READY_MARKERS: &[&str] = &["Claude Code"];
31
+ pub const COPILOT_TRUST_PROMPT_MARKER: &str =
32
+ include_str!("testdata/copilot-trust-prompt.txt");
33
+ pub const COPILOT_READY_MARKER: &str =
34
+ include_str!("testdata/copilot-ready-marker.txt");
35
+ const COPILOT_TRUST_MARKERS: &[&str] = &[COPILOT_TRUST_PROMPT_MARKER];
36
+ const COPILOT_READY_MARKERS: &[&str] = &[COPILOT_READY_MARKER];
37
+ const COPILOT_TRUST_TITLE: &str = "Confirm folder trust";
38
+ const COPILOT_TRUST_QUESTION: &str = "Do you trust the files in this folder?";
39
+ const COPILOT_TRUST_YES_SESSION: &str = "❯ 1. Yes";
40
+ const COPILOT_TRUST_REMEMBER: &str = "2. Yes, and remember this folder for future sessions";
41
+ const COPILOT_TRUST_NO: &str = "3. No (Esc)";
42
+ const COPILOT_READY_FOOTER: &str = " / commands · ? help";
31
43
  /// Plain ready markers (not the bare `›` glyph — that glyph also indicates a
32
44
  /// numbered-menu selector and is handled by [`rightmost_input_prompt_glyph`] with
33
45
  /// shape gating per N15 / CR-063: detect by SHAPE, not a single Unicode codepoint).
@@ -106,6 +118,20 @@ pub fn classify_claude_startup_screen(output: &str) -> StartupScreenDecision {
106
118
  }
107
119
  }
108
120
 
121
+ pub fn classify_copilot_startup_screen(output: &str) -> StartupScreenDecision {
122
+ let ready_pos = copilot_ready_pos(output);
123
+ if has_active_copilot_trust_shape(output)
124
+ && is_more_recent(copilot_trust_pos(output), ready_pos)
125
+ {
126
+ return StartupScreenDecision::AnswerWorkspaceTrust;
127
+ }
128
+ if ready_pos.is_some() {
129
+ StartupScreenDecision::Ready
130
+ } else {
131
+ StartupScreenDecision::KeepPolling
132
+ }
133
+ }
134
+
109
135
  /// Actionable trust shape (N15): the captured text contains a trust phrase AND a
110
136
  /// numbered-menu selector line `› <digit>. `. This is the modal-still-active signal
111
137
  /// that survives Codex's pre-rendering of the banner/input prompt below the menu.
@@ -161,6 +187,53 @@ fn has_claude_ready_shape(output: &str) -> bool {
161
187
  && rightmost_claude_input_prompt_glyph(output).is_some()
162
188
  }
163
189
 
190
+ fn has_active_copilot_trust_shape(output: &str) -> bool {
191
+ if max_rfind(output, COPILOT_TRUST_MARKERS).is_some() {
192
+ return true;
193
+ }
194
+ [
195
+ COPILOT_TRUST_TITLE,
196
+ COPILOT_TRUST_QUESTION,
197
+ COPILOT_TRUST_YES_SESSION,
198
+ COPILOT_TRUST_REMEMBER,
199
+ COPILOT_TRUST_NO,
200
+ ]
201
+ .iter()
202
+ .all(|marker| output.contains(marker))
203
+ }
204
+
205
+ fn copilot_trust_pos(output: &str) -> Option<usize> {
206
+ max_two(
207
+ max_rfind(output, COPILOT_TRUST_MARKERS),
208
+ max_rfind(output, &[COPILOT_TRUST_TITLE, COPILOT_TRUST_QUESTION]),
209
+ )
210
+ }
211
+
212
+ fn copilot_ready_pos(output: &str) -> Option<usize> {
213
+ let fixture_pos = max_rfind(output, COPILOT_READY_MARKERS);
214
+ let footer_pos = output.rfind(COPILOT_READY_FOOTER);
215
+ let prompt_pos = rightmost_copilot_input_prompt_glyph(output);
216
+ if footer_pos.is_some() && prompt_pos.is_some() {
217
+ max_two(fixture_pos, max_two(footer_pos, prompt_pos))
218
+ } else {
219
+ fixture_pos
220
+ }
221
+ }
222
+
223
+ fn rightmost_copilot_input_prompt_glyph(output: &str) -> Option<usize> {
224
+ let mut best = None;
225
+ let mut offset = 0;
226
+ for line in output.split_inclusive('\n') {
227
+ if line.trim() == "❯" {
228
+ if let Some(idx) = line.find('❯') {
229
+ best = Some(offset + idx);
230
+ }
231
+ }
232
+ offset += line.len();
233
+ }
234
+ best
235
+ }
236
+
164
237
  fn rightmost_claude_input_prompt_glyph(output: &str) -> Option<usize> {
165
238
  let mut best = rightmost_input_prompt_for_glyph(output, '❯');
166
239
  best = max_two(best, rightmost_input_prompt_for_glyph(output, '>'));
@@ -316,6 +389,40 @@ pub fn claude_handle_startup_prompts(
316
389
  StartupPromptOutcome { handled, capture_error }
317
390
  }
318
391
 
392
+ pub fn copilot_handle_startup_prompts(
393
+ transport: &dyn Transport,
394
+ target: &Target,
395
+ checks: usize,
396
+ sleep_s: f64,
397
+ ) -> StartupPromptOutcome {
398
+ let mut handled = Vec::new();
399
+ let mut capture_error: Option<String> = None;
400
+ for _ in 0..checks {
401
+ let screen = match transport.capture(target, CaptureRange::Full) {
402
+ Ok(captured) => captured.text,
403
+ Err(error) => {
404
+ capture_error.get_or_insert_with(|| error.to_string());
405
+ String::new()
406
+ }
407
+ };
408
+ match classify_copilot_startup_screen(&screen) {
409
+ StartupScreenDecision::AnswerWorkspaceTrust => {
410
+ let _ = transport.send_keys(target, &[Key::Enter]);
411
+ handled.push(HandledPrompt {
412
+ prompt: "copilot_workspace_trust".to_string(),
413
+ action: "sent_enter_yes_session".to_string(),
414
+ });
415
+ sleep_between_polls(sleep_s);
416
+ }
417
+ StartupScreenDecision::Ready => break,
418
+ StartupScreenDecision::SkipUpdatePrompt | StartupScreenDecision::KeepPolling => {
419
+ sleep_between_polls(sleep_s);
420
+ }
421
+ }
422
+ }
423
+ StartupPromptOutcome { handled, capture_error }
424
+ }
425
+
319
426
  fn max_rfind(output: &str, needles: &[&str]) -> Option<usize> {
320
427
  needles.iter().filter_map(|needle| output.rfind(needle)).max()
321
428
  }
@@ -445,6 +552,44 @@ mod tests {
445
552
  );
446
553
  }
447
554
 
555
+ #[test]
556
+ fn copilot_trust_fixture_answers_yes_for_this_session() {
557
+ assert_eq!(
558
+ classify_copilot_startup_screen(COPILOT_TRUST_PROMPT_MARKER),
559
+ StartupScreenDecision::AnswerWorkspaceTrust
560
+ );
561
+ assert!(
562
+ COPILOT_TRUST_PROMPT_MARKER.contains(COPILOT_TRUST_YES_SESSION),
563
+ "copilot trust default must remain option 1, not persistent option 2"
564
+ );
565
+ }
566
+
567
+ #[test]
568
+ fn copilot_trust_prompt_tolerates_path_variation() {
569
+ let dir_b = COPILOT_TRUST_PROMPT_MARKER.replace("/dir-a", "/dir-b");
570
+ assert_eq!(
571
+ classify_copilot_startup_screen(&dir_b),
572
+ StartupScreenDecision::AnswerWorkspaceTrust
573
+ );
574
+ }
575
+
576
+ #[test]
577
+ fn copilot_ready_fixture_is_ready_without_trust_prompt() {
578
+ assert_eq!(
579
+ classify_copilot_startup_screen(COPILOT_READY_MARKER),
580
+ StartupScreenDecision::Ready
581
+ );
582
+ }
583
+
584
+ #[test]
585
+ fn copilot_ready_after_stale_trust_wins() {
586
+ let screen = format!("{COPILOT_TRUST_PROMPT_MARKER}\n{COPILOT_READY_MARKER}");
587
+ assert_eq!(
588
+ classify_copilot_startup_screen(&screen),
589
+ StartupScreenDecision::Ready
590
+ );
591
+ }
592
+
448
593
  // ── ④ transport.capture() SEAM — the loop answers trust then breaks on ready, via the seam ───────
449
594
  /// Scripted transport: `capture` pops the next canned screen; `send_keys` records the keys. All
450
595
  /// other methods are unreachable by the startup-prompt loop.
@@ -532,4 +677,32 @@ mod tests {
532
677
  "on workspace-trust the loop must send Enter via the transport.capture() seam; got {sent:?}"
533
678
  );
534
679
  }
680
+
681
+ #[test]
682
+ fn copilot_loop_answers_trust_then_breaks_on_ready_via_capture_seam() {
683
+ let t = ScriptedTransport {
684
+ screens: Mutex::new(vec![
685
+ COPILOT_TRUST_PROMPT_MARKER.to_string(),
686
+ COPILOT_READY_MARKER.to_string(),
687
+ ]),
688
+ sent: Mutex::new(Vec::new()),
689
+ };
690
+ let target = Target::Pane(PaneId::new("%1"));
691
+
692
+ let handled = copilot_handle_startup_prompts(&t, &target, 5, 0.0).handled;
693
+
694
+ assert_eq!(
695
+ handled,
696
+ vec![HandledPrompt {
697
+ prompt: "copilot_workspace_trust".to_string(),
698
+ action: "sent_enter_yes_session".to_string(),
699
+ }]
700
+ );
701
+ let sent = t.sent.lock().unwrap();
702
+ assert_eq!(
703
+ sent.iter().filter(|keys| keys.as_slice() == [Key::Enter]).count(),
704
+ 1,
705
+ "copilot trust prompt must choose option 1 with one Enter; got {sent:?}"
706
+ );
707
+ }
535
708
  }
@@ -0,0 +1,5 @@
1
+ /private/tmp/copilot-trust-fixture-20260611T160323Z/dir-a
2
+ ────────────────────────────────────────────────────────────────────────────────
3
+
4
+ ────────────────────────────────────────────────────────────────────────────────
5
+ / commands · ? help qwen3.6-local
@@ -0,0 +1,19 @@
1
+ ╭──────────────────────────────────────────────────────────────────────────────╮
2
+ │ Confirm folder trust │
3
+ │ ──────────────────────────────────────────────────────────────────────────── │
4
+ │ ╭──────────────────────────────────────────────────────────────────────────╮ │
5
+ │ │ /private/tmp/copilot-trust-fixture-20260611T160323Z/dir-a │ │
6
+ │ ╰──────────────────────────────────────────────────────────────────────────╯ │
7
+ │ │
8
+ │ Copilot can read files in this folder and, with your permission, edit them │
9
+ │ or run code and shell commands. It will remember your permissions for the │
10
+ │ rest of this session. │
11
+ │ │
12
+ │ Do you trust the files in this folder? │
13
+ │ │
14
+ │ ❯ 1. Yes │
15
+ │ 2. Yes, and remember this folder for future sessions │
16
+ │ 3. No (Esc) │
17
+ │ │
18
+ │ ↑/↓ to navigate · enter to select · esc to cancel │
19
+ ╰──────────────────────────────────────────────────────────────────────────────╯
@@ -25,6 +25,7 @@ struct OfflineState {
25
25
  targets: Vec<PaneInfo>,
26
26
  windows: Vec<WindowName>,
27
27
  pane_presence: BTreeMap<String, bool>,
28
+ spawn_failures: BTreeMap<String, String>,
28
29
  spawned_panes_addressable: bool,
29
30
  liveness: BTreeMap<String, PaneLiveness>,
30
31
  default_liveness: PaneLiveness,
@@ -42,6 +43,7 @@ impl Default for OfflineState {
42
43
  targets: Vec::new(),
43
44
  windows: Vec::new(),
44
45
  pane_presence: BTreeMap::new(),
46
+ spawn_failures: BTreeMap::new(),
45
47
  spawned_panes_addressable: true,
46
48
  liveness: BTreeMap::new(),
47
49
  default_liveness: PaneLiveness::Unknown,
@@ -102,6 +104,13 @@ impl OfflineTransport {
102
104
  self
103
105
  }
104
106
 
107
+ pub fn with_spawn_failure(self, window: impl Into<String>, error: impl Into<String>) -> Self {
108
+ self.with_state(|state| {
109
+ state.spawn_failures.insert(window.into(), error.into());
110
+ });
111
+ self
112
+ }
113
+
105
114
  pub fn with_spawned_panes_addressable(self, present: bool) -> Self {
106
115
  self.with_state(|state| state.spawned_panes_addressable = present);
107
116
  self
@@ -149,10 +158,16 @@ impl OfflineTransport {
149
158
  session: &SessionName,
150
159
  window: &WindowName,
151
160
  argv: &[String],
152
- ) -> SpawnResult {
161
+ ) -> Result<SpawnResult, TransportError> {
153
162
  let pane_index = self.with_state(|state| {
154
163
  state.calls.push(kind);
155
164
  state.spawns.push(SpawnRecord { kind: kind.to_string(), argv: argv.to_vec() });
165
+ if let Some(error) = state.spawn_failures.get(window.as_str()) {
166
+ return Err(TransportError::Spawn {
167
+ backend: BackendKind::Tmux,
168
+ source: std::io::Error::other(error.clone()),
169
+ });
170
+ }
156
171
  if kind == "spawn_first" && !state.session_absent_after_spawn_first {
157
172
  state.session_present = true;
158
173
  }
@@ -160,14 +175,14 @@ impl OfflineTransport {
160
175
  state
161
176
  .pane_presence
162
177
  .insert(format!("%{pane_index}"), state.spawned_panes_addressable);
163
- pane_index
164
- });
165
- SpawnResult {
178
+ Ok(pane_index)
179
+ })?;
180
+ Ok(SpawnResult {
166
181
  pane_id: PaneId::new(format!("%{pane_index}")),
167
182
  session: session.clone(),
168
183
  window: window.clone(),
169
184
  child_pid: None,
170
- }
185
+ })
171
186
  }
172
187
 
173
188
  fn inject_report() -> InjectReport {
@@ -194,7 +209,7 @@ impl Transport for OfflineTransport {
194
209
  _cwd: &Path,
195
210
  _env: &BTreeMap<String, String>,
196
211
  ) -> Result<SpawnResult, TransportError> {
197
- Ok(self.spawn_result("spawn_first", session, window, argv))
212
+ self.spawn_result("spawn_first", session, window, argv)
198
213
  }
199
214
 
200
215
  fn spawn_into(
@@ -205,7 +220,7 @@ impl Transport for OfflineTransport {
205
220
  _cwd: &Path,
206
221
  _env: &BTreeMap<String, String>,
207
222
  ) -> Result<SpawnResult, TransportError> {
208
- Ok(self.spawn_result("spawn_into", session, window, argv))
223
+ self.spawn_result("spawn_into", session, window, argv)
209
224
  }
210
225
 
211
226
  fn inject(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.12",
3
+ "version": "0.3.13",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.3.12",
24
- "@team-agent/cli-darwin-x64": "0.3.12",
25
- "@team-agent/cli-linux-x64": "0.3.12"
23
+ "@team-agent/cli-darwin-arm64": "0.3.13",
24
+ "@team-agent/cli-darwin-x64": "0.3.13",
25
+ "@team-agent/cli-linux-x64": "0.3.13"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",