@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/adapters.rs +23 -0
- package/crates/team-agent/src/cli/leader.rs +2 -5
- package/crates/team-agent/src/cli/mod.rs +90 -4
- package/crates/team-agent/src/cli/tests/compile.rs +69 -0
- package/crates/team-agent/src/cli/tests/main_preserved.rs +68 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +160 -2
- package/crates/team-agent/src/compiler.rs +30 -0
- package/crates/team-agent/src/coordinator/tick.rs +16 -83
- package/crates/team-agent/src/leader/lease.rs +4 -20
- package/crates/team-agent/src/leader/mod.rs +3 -1
- package/crates/team-agent/src/leader/owner_bind.rs +46 -48
- package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
- package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
- package/crates/team-agent/src/leader/rediscover.rs +34 -17
- package/crates/team-agent/src/lifecycle/launch.rs +158 -12
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +229 -28
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +135 -8
- package/crates/team-agent/src/lifecycle/types.rs +28 -0
- package/crates/team-agent/src/messaging/delivery.rs +5 -1
- package/crates/team-agent/src/messaging/helpers.rs +1 -1
- package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
- package/crates/team-agent/src/provider/adapter.rs +6 -3
- package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
- package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
- package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
- package/crates/team-agent/src/transport/test_support.rs +22 -7
- 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 =
|
|
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
|
-
|
|
1041
|
-
"if the session disappears after alpha, restart must
|
|
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
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1050
|
-
"
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.3.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.3.
|
|
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",
|