@team-agent/installer 0.3.4 → 0.3.5
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 +8 -0
- package/crates/team-agent/src/cli/diagnose.rs +51 -10
- package/crates/team-agent/src/cli/emit.rs +2 -1
- package/crates/team-agent/src/cli/mod.rs +217 -80
- package/crates/team-agent/src/cli/send.rs +1 -0
- package/crates/team-agent/src/cli/status_port.rs +135 -7
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +8 -1
- package/crates/team-agent/src/cli/tests/mod.rs +1 -0
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +39 -0
- package/crates/team-agent/src/cli/types.rs +5 -1
- package/crates/team-agent/src/coordinator/backoff.rs +57 -9
- package/crates/team-agent/src/coordinator/health.rs +65 -2
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
- package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
- package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
- package/crates/team-agent/src/coordinator/tick.rs +195 -43
- package/crates/team-agent/src/leader/helpers.rs +2 -0
- package/crates/team-agent/src/leader/rediscover.rs +1 -0
- package/crates/team-agent/src/leader/start.rs +9 -1
- package/crates/team-agent/src/leader/takeover.rs +18 -1
- package/crates/team-agent/src/lifecycle/launch.rs +434 -29
- package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
- package/crates/team-agent/src/lifecycle/restart/common.rs +19 -2
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -1
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +44 -9
- package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
- package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
- package/crates/team-agent/src/mcp_server/tools.rs +65 -9
- package/crates/team-agent/src/mcp_server/wire.rs +2 -1
- package/crates/team-agent/src/message_store.rs +80 -0
- package/crates/team-agent/src/messaging/results.rs +76 -5
- package/crates/team-agent/src/messaging/send.rs +3 -1
- package/crates/team-agent/src/messaging/types.rs +15 -1
- package/crates/team-agent/src/messaging/watchers.rs +68 -30
- package/crates/team-agent/src/model/enums.rs +7 -1
- package/crates/team-agent/src/model/permissions.rs +7 -0
- package/crates/team-agent/src/model/spec.rs +3 -1
- package/crates/team-agent/src/provider/adapter.rs +472 -7
- package/crates/team-agent/src/provider/classify.rs +6 -2
- package/crates/team-agent/src/provider/faults.rs +3 -2
- package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
- package/crates/team-agent/src/provider/types.rs +11 -0
- package/crates/team-agent/src/session_capture.rs +1 -0
- package/crates/team-agent/src/state/persist.rs +95 -19
- package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
- package/crates/team-agent/src/tmux_backend.rs +80 -6
- package/crates/team-agent/src/transport.rs +32 -0
- package/package.json +4 -4
|
@@ -140,6 +140,10 @@ fn spawn_agents(
|
|
|
140
140
|
transport: &dyn Transport,
|
|
141
141
|
) -> Result<Vec<StartedAgent>, LifecycleError> {
|
|
142
142
|
let team_dir = spec_path.parent().unwrap_or_else(|| Path::new("."));
|
|
143
|
+
let runtime_fast = matches!(
|
|
144
|
+
spec.get("runtime").and_then(|v| v.get("fast")),
|
|
145
|
+
Some(Value::Bool(true))
|
|
146
|
+
);
|
|
143
147
|
let mut started = Vec::new();
|
|
144
148
|
for agent in spec_agent_values(spec) {
|
|
145
149
|
let Some(agent_id_raw) = agent.get("id").and_then(Value::as_str) else {
|
|
@@ -185,7 +189,12 @@ fn spawn_agents(
|
|
|
185
189
|
.mcp_config(auth_mode)
|
|
186
190
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
187
191
|
let mcp_config = resolve_mcp_config(mcp_config, workspace, agent_id_raw, &mcp_team_id);
|
|
188
|
-
let mcp_config_path =
|
|
192
|
+
let mcp_config_path = write_worker_mcp_config_for_provider(
|
|
193
|
+
workspace,
|
|
194
|
+
agent_id_raw,
|
|
195
|
+
&mcp_config,
|
|
196
|
+
Some(provider),
|
|
197
|
+
)?;
|
|
189
198
|
let profile_dir = team_dir.join("profiles");
|
|
190
199
|
let profile_launch =
|
|
191
200
|
crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
|
|
@@ -209,15 +218,119 @@ fn spawn_agents(
|
|
|
209
218
|
if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
|
|
210
219
|
point_native_mcp_config_at_file(&mut plan.argv, provider, &mcp_config_path);
|
|
211
220
|
}
|
|
221
|
+
// C-A-4 cr verdict v2 — Copilot BYOK(compatible_api)硬性校验:
|
|
222
|
+
// "A model is required for BYOK"(help-providers 原文)。检查 agent
|
|
223
|
+
// 的 model 来源:角色 spec.model > profile COPILOT_MODEL(经 env_overlay)
|
|
224
|
+
// > --model 旗(本 worker 路径不在 argv 后追加用户 --model)。三者全空 → 报错
|
|
225
|
+
// 含 "model" 字面,失败信息透传给 leader。
|
|
226
|
+
if matches!(provider, Provider::Copilot) && auth_mode == AuthMode::CompatibleApi {
|
|
227
|
+
let has_model = model.is_some_and(|s| !s.is_empty())
|
|
228
|
+
|| profile_launch.command_overrides.model.as_deref().is_some_and(|s| !s.is_empty())
|
|
229
|
+
|| profile_launch
|
|
230
|
+
.env_overlay
|
|
231
|
+
.get("COPILOT_MODEL")
|
|
232
|
+
.is_some_and(|v| !v.is_empty());
|
|
233
|
+
if !has_model {
|
|
234
|
+
return Err(LifecycleError::RequirementUnmet(
|
|
235
|
+
"copilot BYOK profile requires a model (set COPILOT_MODEL, agent.model, or --model)"
|
|
236
|
+
.to_string(),
|
|
237
|
+
));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
// §B1 + C-7-1 + C-6-2 + C-3-2 cr verdict v2 — Copilot launch-time argv 注入:
|
|
241
|
+
// -n <agent_id> 会话命名(main-help:104)→ resume-by-name + 人查 双键
|
|
242
|
+
// -C <workspace> 双保险 cwd(main-help:55-56),防 shell 包装意外
|
|
243
|
+
// --log-dir <path> per-worker 定向日志(help-logging)→ 故障期可读 + N18 隔离
|
|
244
|
+
// --log-level info 配套日志级别
|
|
245
|
+
// --disable-mcp-server <n>... C-3-2 残留 MCP server 按名禁(扫 mcp list)
|
|
246
|
+
if matches!(provider, Provider::Copilot) {
|
|
247
|
+
plan.argv.push("-n".to_string());
|
|
248
|
+
plan.argv.push(agent_id_raw.to_string());
|
|
249
|
+
plan.argv.push("-C".to_string());
|
|
250
|
+
plan.argv.push(workspace.to_string_lossy().to_string());
|
|
251
|
+
let log_dir = workspace
|
|
252
|
+
.join(".team")
|
|
253
|
+
.join("logs")
|
|
254
|
+
.join("copilot")
|
|
255
|
+
.join(agent_id_raw);
|
|
256
|
+
std::fs::create_dir_all(&log_dir).map_err(|e| {
|
|
257
|
+
LifecycleError::StatePersist(format!("{}: {e}", log_dir.display()))
|
|
258
|
+
})?;
|
|
259
|
+
plan.argv.push("--log-dir".to_string());
|
|
260
|
+
plan.argv.push(log_dir.to_string_lossy().to_string());
|
|
261
|
+
plan.argv.push("--log-level".to_string());
|
|
262
|
+
plan.argv.push("info".to_string());
|
|
263
|
+
// C-3-2/C-3-3 cr verdict v2 — spawn 前扫 `copilot mcp list` 找用户全局/
|
|
264
|
+
// workspace 的 MCP 残留,对每个非 team_orchestrator server 追加
|
|
265
|
+
// --disable-mcp-server <name>,并落 mcp-residual.txt + event。
|
|
266
|
+
apply_copilot_mcp_residual_disables(
|
|
267
|
+
&workspace,
|
|
268
|
+
agent_id_raw,
|
|
269
|
+
&mut plan.argv,
|
|
270
|
+
&log_dir,
|
|
271
|
+
)?;
|
|
272
|
+
}
|
|
212
273
|
fill_spawn_placeholders_full(&mut plan.argv, workspace, agent_id_raw, Some(&mcp_team_id));
|
|
213
274
|
let window = WindowName::new(agent_id_raw);
|
|
214
275
|
let mut env =
|
|
215
276
|
inherited_env_with_team_overrides(workspace, agent_id_raw, Some(&mcp_team_id));
|
|
216
277
|
apply_profile_launch_env(&mut env, &profile_launch);
|
|
278
|
+
// Python providers.py:145 + launch/core.py:253 — fresh launch runs the worker
|
|
279
|
+
// with cwd=workspace, same as the RS fork/add and restart paths.
|
|
280
|
+
let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
|
|
281
|
+
// BUG / C-1-2 / C-6-1 cr verdict — Copilot system_prompt 走 spawn env overlay +
|
|
282
|
+
// per-worker AGENTS.md(B2 灵魂件降级):写
|
|
283
|
+
// <workspace>/.team/runtime/copilot-instructions/<agent_id>/AGENTS.md
|
|
284
|
+
// 全文 == compile_worker_system_prompt 输出,并通过 spawn env
|
|
285
|
+
// `COPILOT_CUSTOM_INSTRUCTIONS_DIRS=<该目录>` 让 copilot CLI 加载。
|
|
286
|
+
// **禁** silent 写 ~/.copilot/AGENTS.md(C-1-2)+ **禁** -i 作首条消息(C-1-5)。
|
|
287
|
+
if matches!(provider, Provider::Copilot) {
|
|
288
|
+
apply_copilot_instructions_overlay(
|
|
289
|
+
workspace,
|
|
290
|
+
agent_id_raw,
|
|
291
|
+
system_prompt.as_str(),
|
|
292
|
+
&mut env,
|
|
293
|
+
)?;
|
|
294
|
+
// C-A-6 cr verdict v2 — Copilot worker env 全量继承下,用户 shell 的
|
|
295
|
+
// COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN 会穿透 + 按 cmd-login 实证
|
|
296
|
+
// **优先于凭据库**(可能静默改变 auth 通道)。一期只观测不剥除(剥除是
|
|
297
|
+
// 行为变更,cr 裁);命中任一就发 warn event 让 user 可见。
|
|
298
|
+
let mut passthrough: Vec<String> = Vec::new();
|
|
299
|
+
for key in ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"] {
|
|
300
|
+
if env.get(key).is_some_and(|v| !v.is_empty()) {
|
|
301
|
+
passthrough.push(key.to_string());
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
if !passthrough.is_empty() {
|
|
305
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
306
|
+
let _ = event_log.write(
|
|
307
|
+
"provider.copilot.token_passthrough_warning",
|
|
308
|
+
serde_json::json!({
|
|
309
|
+
"agent_id": agent_id_raw,
|
|
310
|
+
"tokens": passthrough,
|
|
311
|
+
"reason": "user shell GITHUB_TOKEN family takes precedence over copilot credential store (cmd-login)",
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
217
316
|
let spawn = if started.is_empty() {
|
|
218
|
-
transport.
|
|
317
|
+
transport.spawn_first_with_env_unset(
|
|
318
|
+
session_name,
|
|
319
|
+
&window,
|
|
320
|
+
&plan.argv,
|
|
321
|
+
workspace,
|
|
322
|
+
&env,
|
|
323
|
+
&env_unset,
|
|
324
|
+
)
|
|
219
325
|
} else {
|
|
220
|
-
transport.
|
|
326
|
+
transport.spawn_into_with_env_unset(
|
|
327
|
+
session_name,
|
|
328
|
+
&window,
|
|
329
|
+
&plan.argv,
|
|
330
|
+
workspace,
|
|
331
|
+
&env,
|
|
332
|
+
&env_unset,
|
|
333
|
+
)
|
|
221
334
|
}
|
|
222
335
|
.map_err(|e| LifecycleError::Transport(e.to_string()))?;
|
|
223
336
|
let _ = adapter.handle_startup_prompts(
|
|
@@ -226,6 +339,11 @@ fn spawn_agents(
|
|
|
226
339
|
30,
|
|
227
340
|
0.5,
|
|
228
341
|
);
|
|
342
|
+
// Python launch/core.py:235-237 — runtime.fast toggles the provider's fast mode
|
|
343
|
+
// after spawn; provider specifics live behind the adapter (F032).
|
|
344
|
+
if runtime_fast {
|
|
345
|
+
let _ = adapter.enable_fast_mode(transport, &Target::Pane(spawn.pane_id.clone()));
|
|
346
|
+
}
|
|
229
347
|
if matches!(transport.liveness(&spawn.pane_id), Ok(PaneLiveness::Dead)) {
|
|
230
348
|
continue;
|
|
231
349
|
}
|
|
@@ -330,7 +448,7 @@ fn persist_spawn_agent_state(
|
|
|
330
448
|
id,
|
|
331
449
|
provider,
|
|
332
450
|
workspace,
|
|
333
|
-
|
|
451
|
+
workspace,
|
|
334
452
|
&spawned_at,
|
|
335
453
|
&team_id,
|
|
336
454
|
Some(agent_id_to_pane_id(started, id)),
|
|
@@ -717,7 +835,8 @@ fn running_agent_state(
|
|
|
717
835
|
.mcp_config(auth_mode)
|
|
718
836
|
.map_err(|e| LifecycleError::Provider(e.to_string()))?;
|
|
719
837
|
let mcp_config = resolve_mcp_config(mcp_config, workspace, id, team_id);
|
|
720
|
-
let mcp_config_path =
|
|
838
|
+
let mcp_config_path =
|
|
839
|
+
write_worker_mcp_config_for_provider(workspace, id, &mcp_config, Some(provider))?;
|
|
721
840
|
let mut state = serde_json::Map::new();
|
|
722
841
|
state.insert("status".to_string(), serde_json::json!("running"));
|
|
723
842
|
state.insert("provider".to_string(), serde_json::json!(provider));
|
|
@@ -849,6 +968,20 @@ pub(crate) fn write_worker_mcp_config(
|
|
|
849
968
|
workspace: &Path,
|
|
850
969
|
agent_id: &str,
|
|
851
970
|
config: &crate::provider::McpConfig,
|
|
971
|
+
) -> Result<PathBuf, LifecycleError> {
|
|
972
|
+
write_worker_mcp_config_for_provider(workspace, agent_id, config, None)
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/// C-3-4 cr verdict v2 — Copilot 的 mcp config schema 字段名是 `transport`
|
|
976
|
+
/// (实测 cmd-mcp-add 原文取值 stdio|http|sse),不是 canonical 的 `type`。当
|
|
977
|
+
/// provider==Copilot 时写出文件前先做 type→transport 翻译;其它 provider 不动。
|
|
978
|
+
/// 文件路径同 canonical `<ws>/.team/runtime/mcp/<agent_id>.json`,因为 launch
|
|
979
|
+
/// 路径会用 `--additional-mcp-config @<file>` 直指它。
|
|
980
|
+
pub(crate) fn write_worker_mcp_config_for_provider(
|
|
981
|
+
workspace: &Path,
|
|
982
|
+
agent_id: &str,
|
|
983
|
+
config: &crate::provider::McpConfig,
|
|
984
|
+
provider: Option<Provider>,
|
|
852
985
|
) -> Result<PathBuf, LifecycleError> {
|
|
853
986
|
let path = workspace
|
|
854
987
|
.join(".team/runtime/mcp")
|
|
@@ -857,26 +990,70 @@ pub(crate) fn write_worker_mcp_config(
|
|
|
857
990
|
std::fs::create_dir_all(parent)
|
|
858
991
|
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", parent.display())))?;
|
|
859
992
|
}
|
|
860
|
-
let
|
|
993
|
+
let raw = if matches!(provider, Some(Provider::Copilot)) {
|
|
994
|
+
copilot_translate_mcp_servers(&config.raw)
|
|
995
|
+
} else {
|
|
996
|
+
config.raw.clone()
|
|
997
|
+
};
|
|
998
|
+
let body = serde_json::to_string_pretty(&serde_json::json!({"mcpServers": raw}))
|
|
861
999
|
.map_err(|e| LifecycleError::StatePersist(format!("serialize mcp config: {e}")))?;
|
|
862
1000
|
std::fs::write(&path, body)
|
|
863
1001
|
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", path.display())))?;
|
|
864
1002
|
Ok(path)
|
|
865
1003
|
}
|
|
866
1004
|
|
|
1005
|
+
/// C-3-4 cr verdict v2 — McpConfig.raw 是 `{name: {type, command, args, env}}` 形;
|
|
1006
|
+
/// copilot mcp add schema 取 `transport` 替 `type`(stdio|http|sse 同值)。仅
|
|
1007
|
+
/// 字段名变换,其余字段全保留。
|
|
1008
|
+
fn copilot_translate_mcp_servers(raw: &serde_json::Value) -> serde_json::Value {
|
|
1009
|
+
let Some(servers) = raw.as_object() else {
|
|
1010
|
+
return raw.clone();
|
|
1011
|
+
};
|
|
1012
|
+
let mut translated = serde_json::Map::new();
|
|
1013
|
+
for (name, server) in servers {
|
|
1014
|
+
let Some(obj) = server.as_object() else {
|
|
1015
|
+
translated.insert(name.clone(), server.clone());
|
|
1016
|
+
continue;
|
|
1017
|
+
};
|
|
1018
|
+
let mut out = serde_json::Map::new();
|
|
1019
|
+
for (key, value) in obj {
|
|
1020
|
+
if key == "type" {
|
|
1021
|
+
out.insert("transport".to_string(), value.clone());
|
|
1022
|
+
} else {
|
|
1023
|
+
out.insert(key.clone(), value.clone());
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
translated.insert(name.clone(), serde_json::Value::Object(out));
|
|
1027
|
+
}
|
|
1028
|
+
serde_json::Value::Object(translated)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
867
1031
|
pub(crate) fn point_native_mcp_config_at_file(
|
|
868
1032
|
argv: &mut [String],
|
|
869
1033
|
provider: Provider,
|
|
870
1034
|
path: &Path,
|
|
871
1035
|
) {
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1036
|
+
match provider {
|
|
1037
|
+
Provider::Claude | Provider::ClaudeCode => {
|
|
1038
|
+
let Some(index) = argv.iter().position(|arg| arg == "--mcp-config") else {
|
|
1039
|
+
return;
|
|
1040
|
+
};
|
|
1041
|
+
if let Some(value) = argv.get_mut(index.saturating_add(1)) {
|
|
1042
|
+
*value = path.to_string_lossy().to_string();
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
// §C1 note: copilot `--additional-mcp-config` 接受 `@file`,直接指向既有
|
|
1046
|
+
// `.team/runtime/mcp/<agent>.json`(launch 路径 write_worker_mcp_config 已写)。
|
|
1047
|
+
// 既避免 inline JSON 包 mcpServers wrapper 的语义错位,也更利于 ps 验法。
|
|
1048
|
+
Provider::Copilot => {
|
|
1049
|
+
let Some(index) = argv.iter().position(|arg| arg == "--additional-mcp-config") else {
|
|
1050
|
+
return;
|
|
1051
|
+
};
|
|
1052
|
+
if let Some(value) = argv.get_mut(index.saturating_add(1)) {
|
|
1053
|
+
*value = format!("@{}", path.to_string_lossy());
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
_ => {}
|
|
880
1057
|
}
|
|
881
1058
|
}
|
|
882
1059
|
|
|
@@ -995,6 +1172,10 @@ pub(crate) fn inherited_env_with_team_overrides(
|
|
|
995
1172
|
"TEAM_AGENT_WORKSPACE".to_string(),
|
|
996
1173
|
workspace.to_string_lossy().to_string(),
|
|
997
1174
|
);
|
|
1175
|
+
// Python providers.py:131 — TEAM_AGENT_ID must be the worker ITSELF, overriding any
|
|
1176
|
+
// value inherited from the launching process (an add-agent/fork issued from another
|
|
1177
|
+
// worker's MCP server carries the CALLER's TEAM_AGENT_ID in its environ).
|
|
1178
|
+
env.insert("TEAM_AGENT_ID".to_string(), agent_id.to_string());
|
|
998
1179
|
env.insert("TEAM_AGENT_AGENT_ID".to_string(), agent_id.to_string());
|
|
999
1180
|
if let Some(tid) = team_id.filter(|s| !s.is_empty()) {
|
|
1000
1181
|
env.insert("TEAM_AGENT_OWNER_TEAM_ID".to_string(), tid.to_string());
|
|
@@ -1002,6 +1183,190 @@ pub(crate) fn inherited_env_with_team_overrides(
|
|
|
1002
1183
|
env
|
|
1003
1184
|
}
|
|
1004
1185
|
|
|
1186
|
+
/// BUG / B2 灵魂件 + C-1-2 + C-6-1 cr verdict — Copilot per-worker AGENTS.md
|
|
1187
|
+
/// 写入 + `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` 注入。
|
|
1188
|
+
///
|
|
1189
|
+
/// 目录布局:`<workspace>/.team/runtime/copilot-instructions/<agent_id>/AGENTS.md`
|
|
1190
|
+
/// * 含 `<agent_id>` segment(C-6-2 per-agent isolation,N18 精神)
|
|
1191
|
+
/// * 文件内容 ≡ `compile_worker_system_prompt` 输出(B2 ps/文件双验法)
|
|
1192
|
+
/// * **禁** silent 写全局 `~/.copilot/AGENTS.md`(C-1-2 grep guard)
|
|
1193
|
+
///
|
|
1194
|
+
/// 失败回 `LifecycleError::StatePersist` 以与既有 state 持久化错误同源,
|
|
1195
|
+
/// 不 silent 吞(MUST-NOT-13 诚实)。
|
|
1196
|
+
pub(crate) fn apply_copilot_instructions_overlay(
|
|
1197
|
+
workspace: &Path,
|
|
1198
|
+
agent_id: &str,
|
|
1199
|
+
system_prompt: &str,
|
|
1200
|
+
env: &mut BTreeMap<String, String>,
|
|
1201
|
+
) -> Result<(), LifecycleError> {
|
|
1202
|
+
let dir = workspace
|
|
1203
|
+
.join(".team")
|
|
1204
|
+
.join("runtime")
|
|
1205
|
+
.join("copilot-instructions")
|
|
1206
|
+
.join(agent_id);
|
|
1207
|
+
std::fs::create_dir_all(&dir)
|
|
1208
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", dir.display())))?;
|
|
1209
|
+
let agents_md = dir.join("AGENTS.md");
|
|
1210
|
+
std::fs::write(&agents_md, system_prompt.as_bytes())
|
|
1211
|
+
.map_err(|e| LifecycleError::StatePersist(format!("{}: {e}", agents_md.display())))?;
|
|
1212
|
+
env.insert(
|
|
1213
|
+
"COPILOT_CUSTOM_INSTRUCTIONS_DIRS".to_string(),
|
|
1214
|
+
dir.to_string_lossy().to_string(),
|
|
1215
|
+
);
|
|
1216
|
+
// ★ C-4 P0(N39 红线 / MUST-12) — copilot config 默认 `updateTerminalTitle=true`
|
|
1217
|
+
// 会改 tmux window 名(help-config 原文)。tmux window 名是框架定位 agent 的
|
|
1218
|
+
// anchor(window==agent_id);copilot 静默改写 → 寻址 / kill / 保护集 三处同源
|
|
1219
|
+
// 派生漂移 → B5 protected_set 误判、MUST-12 pane 身份失锚、N39 同源派生破。
|
|
1220
|
+
// 漏关后果定级为【B5 leader 误杀同级 incident】,绝不允许 silent 跳过。
|
|
1221
|
+
// 主案:env `COPILOT_DISABLE_TERMINAL_TITLE=1`(help-config 原文 "Can also be
|
|
1222
|
+
// disabled via the COPILOT_DISABLE_TERMINAL_TITLE environment variable")。
|
|
1223
|
+
env.insert("COPILOT_DISABLE_TERMINAL_TITLE".to_string(), "1".to_string());
|
|
1224
|
+
Ok(())
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/// C-3-2/C-3-3 cr verdict v2 — Copilot spawn 前调 `copilot mcp list` 扫用户全局
|
|
1228
|
+
/// `~/.copilot/mcp-config.json` 与 workspace `.mcp.json` 的 MCP 残留;对每个非
|
|
1229
|
+
/// `team_orchestrator` server 追加 `--disable-mcp-server <name>`(main-help:72-73)
|
|
1230
|
+
/// 并落 `<log_dir>/mcp-residual.txt` + emit `provider.copilot.mcp_residual_detected`
|
|
1231
|
+
/// event(MUST-NOT-13 诚实记录,非 silent)。
|
|
1232
|
+
///
|
|
1233
|
+
/// 失败回 `LifecycleError::StatePersist`,不 silent 吞;`copilot mcp list` 自身
|
|
1234
|
+
/// 无法运行(命令缺失 / 退出码非零)时,仅记 `mcp-residual.txt` 的 unavailable
|
|
1235
|
+
/// 行,不阻断 spawn(provider 一期 subscription-only,工具链可能未完全就绪)。
|
|
1236
|
+
fn apply_copilot_mcp_residual_disables(
|
|
1237
|
+
workspace: &Path,
|
|
1238
|
+
agent_id: &str,
|
|
1239
|
+
argv: &mut Vec<String>,
|
|
1240
|
+
log_dir: &Path,
|
|
1241
|
+
) -> Result<(), LifecycleError> {
|
|
1242
|
+
let listing = std::process::Command::new("copilot")
|
|
1243
|
+
.arg("mcp")
|
|
1244
|
+
.arg("list")
|
|
1245
|
+
.output();
|
|
1246
|
+
let residual_path = log_dir.join("mcp-residual.txt");
|
|
1247
|
+
match listing {
|
|
1248
|
+
Ok(out) if out.status.success() => {
|
|
1249
|
+
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
|
1250
|
+
std::fs::write(&residual_path, &text).map_err(|e| {
|
|
1251
|
+
LifecycleError::StatePersist(format!("{}: {e}", residual_path.display()))
|
|
1252
|
+
})?;
|
|
1253
|
+
let residual_servers = parse_copilot_mcp_list_server_names(&text);
|
|
1254
|
+
let non_orchestrator: Vec<String> = residual_servers
|
|
1255
|
+
.iter()
|
|
1256
|
+
.filter(|name| name.as_str() != "team_orchestrator")
|
|
1257
|
+
.cloned()
|
|
1258
|
+
.collect();
|
|
1259
|
+
for name in &non_orchestrator {
|
|
1260
|
+
argv.push("--disable-mcp-server".to_string());
|
|
1261
|
+
argv.push(name.clone());
|
|
1262
|
+
}
|
|
1263
|
+
if !non_orchestrator.is_empty() {
|
|
1264
|
+
let event_log = crate::event_log::EventLog::new(workspace);
|
|
1265
|
+
let _ = event_log.write(
|
|
1266
|
+
"provider.copilot.mcp_residual_detected",
|
|
1267
|
+
serde_json::json!({
|
|
1268
|
+
"agent_id": agent_id,
|
|
1269
|
+
"residual_servers": non_orchestrator,
|
|
1270
|
+
"log_path": residual_path.to_string_lossy(),
|
|
1271
|
+
}),
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
Ok(out) => {
|
|
1276
|
+
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
|
|
1277
|
+
std::fs::write(
|
|
1278
|
+
&residual_path,
|
|
1279
|
+
format!("copilot mcp list exit={:?} stderr={stderr}\n", out.status.code()),
|
|
1280
|
+
)
|
|
1281
|
+
.map_err(|e| {
|
|
1282
|
+
LifecycleError::StatePersist(format!("{}: {e}", residual_path.display()))
|
|
1283
|
+
})?;
|
|
1284
|
+
}
|
|
1285
|
+
Err(e) => {
|
|
1286
|
+
std::fs::write(
|
|
1287
|
+
&residual_path,
|
|
1288
|
+
format!("copilot mcp list unavailable: {e}\n"),
|
|
1289
|
+
)
|
|
1290
|
+
.map_err(|e| {
|
|
1291
|
+
LifecycleError::StatePersist(format!("{}: {e}", residual_path.display()))
|
|
1292
|
+
})?;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
Ok(())
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/// 解析 `copilot mcp list` 输出取 server 名集合(te 真机实证 v2,1.0.59 形态):
|
|
1299
|
+
/// ```text
|
|
1300
|
+
/// User servers:
|
|
1301
|
+
/// foo (local)
|
|
1302
|
+
/// bar (http)
|
|
1303
|
+
/// Builtin servers:
|
|
1304
|
+
/// github-mcp-server (local)
|
|
1305
|
+
/// ```
|
|
1306
|
+
/// 或空集形态(te 真机实证 fake HOME 无 mcp-config.json):
|
|
1307
|
+
/// ```text
|
|
1308
|
+
/// No MCP servers configured.
|
|
1309
|
+
///
|
|
1310
|
+
/// Add a server with:
|
|
1311
|
+
/// copilot mcp add <name> -- <command> [args...]
|
|
1312
|
+
/// copilot mcp add --transport http <name> <url>
|
|
1313
|
+
/// ```
|
|
1314
|
+
///
|
|
1315
|
+
/// 规则:
|
|
1316
|
+
/// 1. 首行含 "No MCP servers configured" → 立即返空(避免把 "Add a server with"
|
|
1317
|
+
/// 段下的 help 行误识为 server)
|
|
1318
|
+
/// 2. 段标题行(非缩进、以 `:` 结尾):只有 *servers:* 后缀的段(User/Builtin/
|
|
1319
|
+
/// Workspace servers:)才进 server-listing 模式;其余段(如 "Add a server with:")
|
|
1320
|
+
/// 进 skip 模式直到下个 servers: 段或文档结束
|
|
1321
|
+
/// 3. servers: 段内的缩进行取首段 token,剥 ` (local)`/` (http)`/` (sse)` 后缀
|
|
1322
|
+
/// 4. 空行 / 不识别行容忍跳过(诚实降级:漏识 = silent 残留,在 mcp-residual.txt
|
|
1323
|
+
/// 全量落盘留证)
|
|
1324
|
+
fn parse_copilot_mcp_list_server_names(text: &str) -> Vec<String> {
|
|
1325
|
+
let mut out: Vec<String> = Vec::new();
|
|
1326
|
+
let mut in_servers_section = false;
|
|
1327
|
+
for line in text.lines() {
|
|
1328
|
+
let trimmed_end = line.trim_end();
|
|
1329
|
+
if trimmed_end.is_empty() {
|
|
1330
|
+
continue;
|
|
1331
|
+
}
|
|
1332
|
+
// C-3-2 fix(te 真机实证):空集 sentinel 立即返空。
|
|
1333
|
+
if trimmed_end
|
|
1334
|
+
.trim_start()
|
|
1335
|
+
.starts_with("No MCP servers configured")
|
|
1336
|
+
{
|
|
1337
|
+
return Vec::new();
|
|
1338
|
+
}
|
|
1339
|
+
// 段标题行(非缩进):决定后续缩进行是否取 server 名。"*servers:" 是
|
|
1340
|
+
// listing 段(User/Builtin/Workspace),其它段都 skip(如 "Add a server with:"
|
|
1341
|
+
// 下面的 help 命令缩进行)。
|
|
1342
|
+
if !(line.starts_with(' ') || line.starts_with('\t')) {
|
|
1343
|
+
let lower = trimmed_end.to_ascii_lowercase();
|
|
1344
|
+
in_servers_section = lower.trim_end_matches(':').ends_with("servers");
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
if !in_servers_section {
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
let trimmed = trimmed_end.trim_start();
|
|
1351
|
+
if trimmed.is_empty() {
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
let mut token = trimmed.split_whitespace().next().unwrap_or("").to_string();
|
|
1355
|
+
// 剥常见装饰后缀(实测形如 "(local)"/"(http)"/"(sse)" 是独立 whitespace
|
|
1356
|
+
// 分隔的 token,首段 token 通常不带括号;若实际 copilot 把括号粘连首段
|
|
1357
|
+
// token,这里多做一次后缀剥离守护)。
|
|
1358
|
+
if let Some(idx) = token.find('(') {
|
|
1359
|
+
token.truncate(idx);
|
|
1360
|
+
}
|
|
1361
|
+
token = token.trim_end_matches(':').trim().to_string();
|
|
1362
|
+
if token.is_empty() {
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
out.push(token);
|
|
1366
|
+
}
|
|
1367
|
+
out
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1005
1370
|
pub(crate) fn apply_profile_launch_env(
|
|
1006
1371
|
env: &mut BTreeMap<String, String>,
|
|
1007
1372
|
profile_launch: &crate::provider::ProviderProfileLaunch,
|
|
@@ -1174,6 +1539,7 @@ fn parse_provider(raw: &str) -> Option<Provider> {
|
|
|
1174
1539
|
"claude" => Some(Provider::Claude),
|
|
1175
1540
|
"claude_code" => Some(Provider::ClaudeCode),
|
|
1176
1541
|
"codex" => Some(Provider::Codex),
|
|
1542
|
+
"copilot" => Some(Provider::Copilot),
|
|
1177
1543
|
"gemini_cli" => Some(Provider::GeminiCli),
|
|
1178
1544
|
"fake" => Some(Provider::Fake),
|
|
1179
1545
|
_ => None,
|
|
@@ -2120,6 +2486,7 @@ pub fn fork_agent(
|
|
|
2120
2486
|
workspace: &Path,
|
|
2121
2487
|
source_agent_id: &AgentId,
|
|
2122
2488
|
as_agent_id: &AgentId,
|
|
2489
|
+
label: Option<&str>,
|
|
2123
2490
|
open_display: bool,
|
|
2124
2491
|
team: Option<&str>,
|
|
2125
2492
|
) -> Result<ForkAgentReport, LifecycleError> {
|
|
@@ -2133,6 +2500,7 @@ pub fn fork_agent(
|
|
|
2133
2500
|
workspace,
|
|
2134
2501
|
source_agent_id,
|
|
2135
2502
|
as_agent_id,
|
|
2503
|
+
label,
|
|
2136
2504
|
open_display,
|
|
2137
2505
|
team,
|
|
2138
2506
|
&crate::tmux_backend::TmuxBackend::for_workspace(&selected.run_workspace),
|
|
@@ -2143,6 +2511,7 @@ pub fn fork_agent_with_transport(
|
|
|
2143
2511
|
workspace: &Path,
|
|
2144
2512
|
source_agent_id: &AgentId,
|
|
2145
2513
|
as_agent_id: &AgentId,
|
|
2514
|
+
label: Option<&str>,
|
|
2146
2515
|
open_display: bool,
|
|
2147
2516
|
team: Option<&str>,
|
|
2148
2517
|
transport: &dyn Transport,
|
|
@@ -2201,7 +2570,7 @@ pub fn fork_agent_with_transport(
|
|
|
2201
2570
|
as_agent_id.as_str()
|
|
2202
2571
|
)));
|
|
2203
2572
|
}
|
|
2204
|
-
let new_spec = append_forked_agent(&spec, source_agent, source_agent_id, as_agent_id)?;
|
|
2573
|
+
let new_spec = append_forked_agent(&spec, source_agent, source_agent_id, as_agent_id, label)?;
|
|
2205
2574
|
crate::model::spec::validate_spec(&new_spec, &spec_workspace)
|
|
2206
2575
|
.map_err(|e| LifecycleError::Compile(e.to_string()))?;
|
|
2207
2576
|
std::fs::write(&spec_path, yaml::dumps(&new_spec))
|
|
@@ -2251,11 +2620,16 @@ pub fn fork_agent_with_transport(
|
|
|
2251
2620
|
LifecycleError::Provider(e.to_string())
|
|
2252
2621
|
})?;
|
|
2253
2622
|
let mcp_config = resolve_mcp_config(mcp_config, &workspace, as_agent_id.as_str(), &fork_team);
|
|
2254
|
-
let mcp_config_path =
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2623
|
+
let mcp_config_path = write_worker_mcp_config_for_provider(
|
|
2624
|
+
&workspace,
|
|
2625
|
+
as_agent_id.as_str(),
|
|
2626
|
+
&mcp_config,
|
|
2627
|
+
Some(provider),
|
|
2628
|
+
)
|
|
2629
|
+
.map_err(|e| {
|
|
2630
|
+
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
2631
|
+
e
|
|
2632
|
+
})?;
|
|
2259
2633
|
let profile_dir = spec_workspace.join("profiles");
|
|
2260
2634
|
let profile_launch =
|
|
2261
2635
|
crate::lifecycle::profile_launch::prepare_provider_profile_launch_with_profile_dir(
|
|
@@ -2300,10 +2674,25 @@ pub fn fork_agent_with_transport(
|
|
|
2300
2674
|
// _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
|
|
2301
2675
|
// (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
|
|
2302
2676
|
let session_live = transport.has_session(&session_name).unwrap_or(false);
|
|
2677
|
+
let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
|
|
2303
2678
|
let spawn_result = if session_live {
|
|
2304
|
-
transport.
|
|
2679
|
+
transport.spawn_into_with_env_unset(
|
|
2680
|
+
&session_name,
|
|
2681
|
+
&window,
|
|
2682
|
+
&plan.argv,
|
|
2683
|
+
&workspace,
|
|
2684
|
+
&env,
|
|
2685
|
+
&env_unset,
|
|
2686
|
+
)
|
|
2305
2687
|
} else {
|
|
2306
|
-
transport.
|
|
2688
|
+
transport.spawn_first_with_env_unset(
|
|
2689
|
+
&session_name,
|
|
2690
|
+
&window,
|
|
2691
|
+
&plan.argv,
|
|
2692
|
+
&workspace,
|
|
2693
|
+
&env,
|
|
2694
|
+
&env_unset,
|
|
2695
|
+
)
|
|
2307
2696
|
};
|
|
2308
2697
|
let spawn = spawn_result.map_err(|e| {
|
|
2309
2698
|
let _ = std::fs::write(&spec_path, text.as_bytes());
|
|
@@ -2493,6 +2882,7 @@ fn append_forked_agent(
|
|
|
2493
2882
|
source_agent: &Value,
|
|
2494
2883
|
source_agent_id: &AgentId,
|
|
2495
2884
|
as_agent_id: &AgentId,
|
|
2885
|
+
label: Option<&str>,
|
|
2496
2886
|
) -> Result<Value, LifecycleError> {
|
|
2497
2887
|
let mut new_agent = source_agent.clone();
|
|
2498
2888
|
set_yaml_map_value(
|
|
@@ -2501,11 +2891,16 @@ fn append_forked_agent(
|
|
|
2501
2891
|
Value::Str(as_agent_id.as_str().to_string()),
|
|
2502
2892
|
)?;
|
|
2503
2893
|
// golden operations.py:315 `str(label or new_agent.get("role") or as_agent_id)` — Python `or`
|
|
2504
|
-
// falsiness: an EMPTY-string role is falsy and falls through to
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
.and_then(Value::as_str)
|
|
2894
|
+
// falsiness: an EMPTY-string label/role is falsy and falls through to the next tier.
|
|
2895
|
+
// The label IS the forked agent's new role (it feeds the identity prompt — B2 family).
|
|
2896
|
+
let role = label
|
|
2508
2897
|
.filter(|s| !s.is_empty())
|
|
2898
|
+
.or_else(|| {
|
|
2899
|
+
new_agent
|
|
2900
|
+
.get("role")
|
|
2901
|
+
.and_then(Value::as_str)
|
|
2902
|
+
.filter(|s| !s.is_empty())
|
|
2903
|
+
})
|
|
2509
2904
|
.unwrap_or_else(|| as_agent_id.as_str())
|
|
2510
2905
|
.to_string();
|
|
2511
2906
|
set_yaml_map_value(&mut new_agent, "role", Value::Str(role.clone()))?;
|
|
@@ -2955,12 +3350,22 @@ fn override_spec_session_name(spec: &mut Value, session_name: &str) {
|
|
|
2955
3350
|
}
|
|
2956
3351
|
|
|
2957
3352
|
fn spec_session_name(spec: &Value) -> SessionName {
|
|
2958
|
-
let name = spec
|
|
3353
|
+
if let Some(name) = spec
|
|
2959
3354
|
.get("runtime")
|
|
2960
3355
|
.and_then(|v| v.get("session_name"))
|
|
2961
3356
|
.and_then(Value::as_str)
|
|
2962
|
-
.
|
|
2963
|
-
|
|
3357
|
+
.filter(|name| !name.is_empty())
|
|
3358
|
+
{
|
|
3359
|
+
return SessionName::new(name);
|
|
3360
|
+
}
|
|
3361
|
+
// Python launch/core.py:56 — fallback derives from the team name, not a constant.
|
|
3362
|
+
let team_name = spec
|
|
3363
|
+
.get("team")
|
|
3364
|
+
.and_then(|team| team.get("name"))
|
|
3365
|
+
.and_then(Value::as_str)
|
|
3366
|
+
.filter(|name| !name.is_empty())
|
|
3367
|
+
.unwrap_or("agent");
|
|
3368
|
+
SessionName::new(format!("team-{team_name}"))
|
|
2964
3369
|
}
|
|
2965
3370
|
|
|
2966
3371
|
fn spec_agents(spec: &Value) -> Vec<AgentId> {
|