@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.
Files changed (55) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +8 -0
  4. package/crates/team-agent/src/cli/diagnose.rs +51 -10
  5. package/crates/team-agent/src/cli/emit.rs +2 -1
  6. package/crates/team-agent/src/cli/mod.rs +217 -80
  7. package/crates/team-agent/src/cli/send.rs +1 -0
  8. package/crates/team-agent/src/cli/status_port.rs +135 -7
  9. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +8 -1
  10. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  11. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +39 -0
  12. package/crates/team-agent/src/cli/types.rs +5 -1
  13. package/crates/team-agent/src/coordinator/backoff.rs +57 -9
  14. package/crates/team-agent/src/coordinator/health.rs +65 -2
  15. package/crates/team-agent/src/coordinator/runtime_detectors.rs +28 -16
  16. package/crates/team-agent/src/coordinator/tests/a0_lostupdate.rs +87 -0
  17. package/crates/team-agent/src/coordinator/tests/mod.rs +1 -0
  18. package/crates/team-agent/src/coordinator/tick.rs +195 -43
  19. package/crates/team-agent/src/leader/helpers.rs +2 -0
  20. package/crates/team-agent/src/leader/rediscover.rs +1 -0
  21. package/crates/team-agent/src/leader/start.rs +9 -1
  22. package/crates/team-agent/src/leader/takeover.rs +18 -1
  23. package/crates/team-agent/src/lifecycle/launch.rs +434 -29
  24. package/crates/team-agent/src/lifecycle/profile_launch.rs +110 -4
  25. package/crates/team-agent/src/lifecycle/profile_smoke.rs +4 -1
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +19 -2
  27. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -2
  28. package/crates/team-agent/src/lifecycle/tests/core.rs +1 -1
  29. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +4 -4
  30. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -1
  31. package/crates/team-agent/src/lifecycle/worker_command_context.rs +44 -9
  32. package/crates/team-agent/src/mcp_server/lifecycle_tools/agent_ops.rs +2 -1
  33. package/crates/team-agent/src/mcp_server/tests/scoped.rs +14 -1
  34. package/crates/team-agent/src/mcp_server/tests/send.rs +15 -1
  35. package/crates/team-agent/src/mcp_server/tools.rs +65 -9
  36. package/crates/team-agent/src/mcp_server/wire.rs +2 -1
  37. package/crates/team-agent/src/message_store.rs +80 -0
  38. package/crates/team-agent/src/messaging/results.rs +76 -5
  39. package/crates/team-agent/src/messaging/send.rs +3 -1
  40. package/crates/team-agent/src/messaging/types.rs +15 -1
  41. package/crates/team-agent/src/messaging/watchers.rs +68 -30
  42. package/crates/team-agent/src/model/enums.rs +7 -1
  43. package/crates/team-agent/src/model/permissions.rs +7 -0
  44. package/crates/team-agent/src/model/spec.rs +3 -1
  45. package/crates/team-agent/src/provider/adapter.rs +472 -7
  46. package/crates/team-agent/src/provider/classify.rs +6 -2
  47. package/crates/team-agent/src/provider/faults.rs +3 -2
  48. package/crates/team-agent/src/provider/startup_prompt.rs +25 -7
  49. package/crates/team-agent/src/provider/types.rs +11 -0
  50. package/crates/team-agent/src/session_capture.rs +1 -0
  51. package/crates/team-agent/src/state/persist.rs +95 -19
  52. package/crates/team-agent/src/tmux_backend/tests.rs +8 -7
  53. package/crates/team-agent/src/tmux_backend.rs +80 -6
  54. package/crates/team-agent/src/transport.rs +32 -0
  55. package/package.json +4 -4
@@ -320,6 +320,54 @@ impl MessageStore {
320
320
  Ok(rows.into_iter().rev().collect())
321
321
  }
322
322
 
323
+ /// `latest_results` (`core.py:458-471`): newest non-invalid result rows, oldest
324
+ /// first (Python fetches `created_at desc limit ?` then reverses).
325
+ pub fn latest_results(
326
+ &self,
327
+ limit: usize,
328
+ owner_team_id: Option<&str>,
329
+ ) -> Result<Vec<serde_json::Value>, MessageStoreError> {
330
+ let conn = crate::db::schema::open_db(&self.path)?;
331
+ let limit = i64::try_from(limit).unwrap_or(i64::MAX);
332
+ let sql = match owner_team_id {
333
+ Some(_) => {
334
+ "select owner_team_id, result_id, task_id, agent_id, envelope, status, created_at
335
+ from results
336
+ where status != 'invalid' and owner_team_id = ?2
337
+ order by created_at desc
338
+ limit ?1"
339
+ }
340
+ None => {
341
+ "select owner_team_id, result_id, task_id, agent_id, envelope, status, created_at
342
+ from results
343
+ where status != 'invalid'
344
+ order by created_at desc
345
+ limit ?1"
346
+ }
347
+ };
348
+ let mut stmt = conn.prepare(sql)?;
349
+ let map_row = |row: &rusqlite::Row<'_>| {
350
+ Ok(serde_json::json!({
351
+ "owner_team_id": row.get::<_, Option<String>>(0)?,
352
+ "result_id": row.get::<_, String>(1)?,
353
+ "task_id": row.get::<_, Option<String>>(2)?,
354
+ "agent_id": row.get::<_, Option<String>>(3)?,
355
+ "envelope": row.get::<_, Option<String>>(4)?,
356
+ "status": row.get::<_, Option<String>>(5)?,
357
+ "created_at": row.get::<_, Option<String>>(6)?,
358
+ }))
359
+ };
360
+ let rows = match owner_team_id {
361
+ Some(team) => stmt
362
+ .query_map(params![limit, team], map_row)?
363
+ .collect::<Result<Vec<_>, _>>()?,
364
+ None => stmt
365
+ .query_map(params![limit], map_row)?
366
+ .collect::<Result<Vec<_>, _>>()?,
367
+ };
368
+ Ok(rows.into_iter().rev().collect())
369
+ }
370
+
323
371
  /// Allow direct peer messages in both directions. Golden stores `(a,b)` and
324
372
  /// `(b,a)` so either sender/recipient lookup can use a single ordered key.
325
373
  pub fn allow_peer(&self, a: &str, b: &str) -> Result<(), MessageStoreError> {
@@ -410,6 +458,38 @@ fn row_to_message_value(row: &rusqlite::Row<'_>) -> rusqlite::Result<serde_json:
410
458
  }))
411
459
  }
412
460
 
461
+ /// `result_summary_from_row`(`status/queries.py:92-106`):解析 result 行的 envelope,
462
+ /// 产出 status/watch 共用的 result 摘要;envelope 坏/非对象 → `None`。
463
+ pub fn result_summary_from_row(row: &serde_json::Value) -> Option<serde_json::Value> {
464
+ let envelope = match row.get("envelope") {
465
+ Some(serde_json::Value::String(text)) => {
466
+ serde_json::from_str::<serde_json::Value>(text).ok()?
467
+ }
468
+ Some(value @ serde_json::Value::Object(_)) => value.clone(),
469
+ _ => return None,
470
+ };
471
+ if !envelope.is_object() {
472
+ return None;
473
+ }
474
+ // Python `envelope.get(k) or row.get(k)` — falsy (null/empty) falls through to the row.
475
+ let pick = |key: &str| {
476
+ envelope
477
+ .get(key)
478
+ .filter(|v| !v.is_null() && v.as_str() != Some(""))
479
+ .or_else(|| row.get(key))
480
+ .cloned()
481
+ .unwrap_or(serde_json::Value::Null)
482
+ };
483
+ Some(serde_json::json!({
484
+ "result_id": row.get("result_id").cloned().unwrap_or(serde_json::Value::Null),
485
+ "task_id": pick("task_id"),
486
+ "agent_id": pick("agent_id"),
487
+ "status": pick("status"),
488
+ "summary": envelope.get("summary").cloned().unwrap_or(serde_json::Value::Null),
489
+ "created_at": row.get("created_at").cloned().unwrap_or(serde_json::Value::Null),
490
+ }))
491
+ }
492
+
413
493
  fn now_ts() -> String {
414
494
  chrono::Utc::now().to_rfc3339()
415
495
  }
@@ -40,7 +40,6 @@ fn collect_scoped(
40
40
  ensure_coordinator: bool,
41
41
  owner_team_id: Option<&str>,
42
42
  ) -> Result<serde_json::Value, MessagingError> {
43
- let _ = ensure_coordinator;
44
43
  let paths = collect_paths(workspace)?;
45
44
  let log = EventLog::new(&paths.run_workspace);
46
45
  let resolved_owner_team_id = match owner_team_id.filter(|team| !team.is_empty()) {
@@ -195,6 +194,13 @@ fn collect_scoped(
195
194
  }
196
195
  }
197
196
  let counts = result_counts(&conn, owner_team_id)?;
197
+ // results.py:157 — ensure_coordinator=true runs the REAL ensure step; the
198
+ // `{ok:false,status:"not_required"}` literal is ONLY the ensure=false branch.
199
+ let coordinator = if ensure_coordinator {
200
+ ensure_coordinator_after_collect(&paths.run_workspace, &state, &log)
201
+ } else {
202
+ serde_json::json!({"ok": false, "status": "not_required"})
203
+ };
198
204
  Ok(serde_json::json!({
199
205
  "ok": fatal_invalid_results == 0,
200
206
  "collected": collected,
@@ -203,13 +209,78 @@ fn collect_scoped(
203
209
  "invalid_results": invalid_results,
204
210
  "results": counts,
205
211
  "state_file": spec_workspace.join("team_state.md").to_string_lossy().to_string(),
206
- "coordinator": {
207
- "ok": false,
208
- "status": "not_required",
209
- },
212
+ "coordinator": coordinator,
210
213
  }))
211
214
  }
212
215
 
216
+ /// `_ensure_coordinator_after_collect`(`results.py:176-184`)。
217
+ fn ensure_coordinator_after_collect(
218
+ workspace: &Path,
219
+ state: &serde_json::Value,
220
+ log: &EventLog,
221
+ ) -> serde_json::Value {
222
+ if !coordinator_should_run(state) {
223
+ return serde_json::json!({"ok": false, "status": "not_required"});
224
+ }
225
+ let workspace_path = crate::coordinator::WorkspacePath::new(workspace.to_path_buf());
226
+ let coordinator = match crate::coordinator::start_coordinator(&workspace_path) {
227
+ Ok(report) => start_report_value(&report),
228
+ Err(e) => serde_json::json!({"ok": false, "status": "start_failed", "error": e.to_string()}),
229
+ };
230
+ let _ = log.write(
231
+ "collect.coordinator_checked",
232
+ serde_json::json!({"coordinator": coordinator.clone()}),
233
+ );
234
+ coordinator
235
+ }
236
+
237
+ /// `_coordinator_should_run`(`results.py:187-188`)。
238
+ fn coordinator_should_run(state: &serde_json::Value) -> bool {
239
+ let has_session = state
240
+ .get("session_name")
241
+ .and_then(serde_json::Value::as_str)
242
+ .is_some_and(|s| !s.is_empty());
243
+ has_session || leader_receiver_is_direct(state.get("leader_receiver"))
244
+ }
245
+
246
+ /// `_leader_receiver_is_direct`(`messaging/leader.py:449-450`)。
247
+ fn leader_receiver_is_direct(receiver: Option<&serde_json::Value>) -> bool {
248
+ receiver.is_some_and(|receiver| {
249
+ receiver.get("mode").and_then(serde_json::Value::as_str) == Some("direct_tmux")
250
+ && receiver
251
+ .get("pane_id")
252
+ .and_then(serde_json::Value::as_str)
253
+ .is_some_and(|pane| !pane.is_empty())
254
+ })
255
+ }
256
+
257
+ /// `start_coordinator` dict 形(`lifecycle.py:54/86/121` 的 JSON 面)。
258
+ fn start_report_value(report: &crate::coordinator::StartReport) -> serde_json::Value {
259
+ let status = match report.status {
260
+ crate::coordinator::StartOutcome::AlreadyRunning => "already_running",
261
+ crate::coordinator::StartOutcome::RestartIncompatibleStopFailed => {
262
+ "restart_incompatible_stop_failed"
263
+ }
264
+ crate::coordinator::StartOutcome::SchemaIncompatible => "schema_incompatible",
265
+ crate::coordinator::StartOutcome::Started => "started",
266
+ };
267
+ let mut value = serde_json::json!({
268
+ "ok": report.ok,
269
+ "pid": report.pid.map(|p| p.get()),
270
+ "status": status,
271
+ });
272
+ if let Some(log) = &report.log {
273
+ value["log"] = serde_json::json!(log.to_string_lossy().to_string());
274
+ }
275
+ if let Some(error) = &report.schema_error {
276
+ value["schema_error"] = serde_json::json!(format!("{error:?}"));
277
+ }
278
+ if let Some(action) = &report.action {
279
+ value["action"] = serde_json::json!(action);
280
+ }
281
+ value
282
+ }
283
+
213
284
  fn resolve_owner_team_for_read(
214
285
  workspace: &Path,
215
286
  requested: &str,
@@ -121,6 +121,8 @@ pub fn send_message(
121
121
  return fanout_send(workspace, &state, &recipients, content, opts, &event_log, "*");
122
122
  }
123
123
  MessageTarget::Fanout(recipients) if recipients.is_empty() => {
124
+ // swallow batch 3 ②: a failed send carries its reason (Python send error
125
+ // reason style) — "failed with no reason" is an unexplained exit.
124
126
  return Ok(DeliveryOutcome {
125
127
  ok: false,
126
128
  status: DeliveryStatus::Failed,
@@ -128,7 +130,7 @@ pub fn send_message(
128
130
  message_id: None,
129
131
  verification: None,
130
132
  stage: None,
131
- reason: None,
133
+ reason: Some(crate::messaging::DeliveryRefusal::EmptyTargetList),
132
134
  channel: None,
133
135
  });
134
136
  }
@@ -39,7 +39,7 @@ pub enum DeliveryStatus {
39
39
 
40
40
  /// 投递/发件拒绝原因 (card §42)。Python 散裸字符串靠 `==` 比对易拼错;Rust 穷尽 enum。
41
41
  /// 值散落 `send.py`/`delivery.py`/`leader.py`/`session_drift.py`/`owner_gate`。
42
- #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
42
+ #[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
43
43
  #[serde(rename_all = "snake_case")]
44
44
  pub enum DeliveryRefusal {
45
45
  TargetNotInTeam,
@@ -62,6 +62,20 @@ pub enum DeliveryRefusal {
62
62
  /// is content, not a target. Distinct from `TargetNotInTeam` (where caller
63
63
  /// did pick a target but it's unknown).
64
64
  RoutingAmbiguous,
65
+ /// swallow batch 3: an explicit empty `--to` target list (a failed send always
66
+ /// carries its reason; an unexplained `failed` is a swallowed error).
67
+ EmptyTargetList,
68
+ }
69
+
70
+ /// Debug 输出 = wire snake_case 字面(单一真相源 = serde rename),与事件/JSON 面一致,
71
+ /// 测试与日志里的 `{:?}` 不再出现与 wire 不同的 CamelCase 第二形态。
72
+ impl std::fmt::Debug for DeliveryRefusal {
73
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74
+ match serde_json::to_value(self) {
75
+ Ok(serde_json::Value::String(wire)) => write!(f, "{wire}"),
76
+ _ => write!(f, "delivery_refusal"),
77
+ }
78
+ }
65
79
  }
66
80
 
67
81
  /// 注入失败阶段 (审计用;`delivery.py:309` injection.stage)。card §43。
@@ -298,48 +298,86 @@ pub fn retry_result_deliveries(
298
298
  ) -> Result<Vec<WatcherNotice>, MessagingError> {
299
299
  let store = MessageStore::open(workspace)?;
300
300
  let conn = crate::db::schema::open_db(store.db_path())?;
301
+ // result_delivery.py:19-35 — retries route through notify_result_watchers (the REAL
302
+ // delivery path with dedupe/attempt bounds); a watcher is never flipped to
303
+ // `notified` without a delivery. Missing result rows are skipped (still retryable).
301
304
  let mut stmt = conn.prepare(
302
- "select watcher_id, result_id from result_watchers
303
- where status in ('pending', 'notify_failed') and result_id is not null and notified_message_id is null
305
+ "select watcher_id, owner_team_id, task_id, agent_id, leader_id, status, created_at,
306
+ result_id, notified_message_id
307
+ from result_watchers
308
+ where status in ('pending', 'notify_failed')
304
309
  order by created_at, watcher_id",
305
310
  )?;
306
- let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))?;
311
+ let watchers = stmt
312
+ .query_map([], |row| {
313
+ Ok(serde_json::json!({
314
+ "watcher_id": row.get::<_, String>(0)?,
315
+ "owner_team_id": row.get::<_, Option<String>>(1)?,
316
+ "task_id": row.get::<_, Option<String>>(2)?,
317
+ "agent_id": row.get::<_, Option<String>>(3)?,
318
+ "leader_id": row.get::<_, Option<String>>(4)?,
319
+ "status": row.get::<_, Option<String>>(5)?,
320
+ "created_at": row.get::<_, Option<String>>(6)?,
321
+ "result_id": row.get::<_, Option<String>>(7)?,
322
+ "notified_message_id": row.get::<_, Option<String>>(8)?,
323
+ }))
324
+ })?
325
+ .collect::<Result<Vec<_>, _>>()?;
326
+ drop(stmt);
307
327
  let mut notices = Vec::new();
308
- for row in rows {
309
- let (watcher_id, result_id) = row?;
310
- let result: Option<String> = conn
328
+ for watcher in watchers {
329
+ if watcher.get("status").and_then(|v| v.as_str()) != Some("notify_failed") {
330
+ continue;
331
+ }
332
+ let Some(result_id) = watcher
333
+ .get("result_id")
334
+ .and_then(|v| v.as_str())
335
+ .filter(|id| !id.is_empty())
336
+ .map(ToString::to_string)
337
+ else {
338
+ continue;
339
+ };
340
+ let row: Option<(String, Option<String>)> = conn
311
341
  .query_row(
312
- "select envelope from results where result_id = ?1",
342
+ "select envelope, created_at from results where result_id = ?1",
313
343
  params![result_id],
314
- |r| r.get(0),
344
+ |r| Ok((r.get(0)?, r.get(1)?)),
315
345
  )
316
346
  .optional()?;
317
- if let Some(envelope) = result {
318
- let parsed: serde_json::Value = serde_json::from_str(&envelope)?;
319
- conn.execute(
320
- "update result_watchers set status = 'notified', completed_at = ?2 where watcher_id = ?1",
321
- params![watcher_id, chrono::Utc::now().to_rfc3339()],
322
- )?;
323
- event_log.write(
324
- "result_watcher.retry_notified",
325
- serde_json::json!({"watcher_id": watcher_id, "result_id": result_id}),
326
- )?;
327
- notices.push(WatcherNotice {
328
- watcher_id,
329
- result_id: Some(result_id),
330
- ok: true,
331
- status: Some("notified".to_string()),
332
- notified_message_id: delivered_result_message(&store, parsed.get("result_id").and_then(|v| v.as_str()).unwrap_or(""), None, None)?
333
- .and_then(|v| v.get("message_id").and_then(|id| id.as_str()).map(ToString::to_string)),
334
- primary_watcher_id: None,
335
- prior_state: None,
336
- error: None,
337
- });
338
- }
347
+ let Some((envelope, created_at)) = row else {
348
+ continue;
349
+ };
350
+ let result = result_entry_from_row(&result_id, &envelope, created_at.as_deref())?;
351
+ notices.extend(notify_result_watchers(
352
+ workspace,
353
+ &result,
354
+ event_log,
355
+ Some(std::slice::from_ref(&watcher)),
356
+ Some("rebind_retry"),
357
+ )?);
339
358
  }
340
359
  Ok(notices)
341
360
  }
342
361
 
362
+ /// `_result_entry_from_row`(`result_delivery.py:365-377`)。
363
+ fn result_entry_from_row(
364
+ result_id: &str,
365
+ envelope: &str,
366
+ created_at: Option<&str>,
367
+ ) -> Result<serde_json::Value, MessagingError> {
368
+ let envelope: serde_json::Value = serde_json::from_str(envelope)?;
369
+ Ok(serde_json::json!({
370
+ "result_id": result_id,
371
+ "task_id": envelope.get("task_id").cloned().unwrap_or(serde_json::Value::Null),
372
+ "agent_id": envelope.get("agent_id").cloned().unwrap_or(serde_json::Value::Null),
373
+ "status": envelope.get("status").cloned().unwrap_or(serde_json::Value::Null),
374
+ "summary": envelope.get("summary").cloned().unwrap_or(serde_json::Value::Null),
375
+ "tests": envelope.get("tests").cloned().unwrap_or_else(|| serde_json::json!([])),
376
+ "created_at": created_at,
377
+ "scope": "task",
378
+ }))
379
+ }
380
+
343
381
  /// `requeue_after_claim_leader` (`result_delivery.py:428`):Gap 26 —— 认领新 leader pane 后把
344
382
  /// 未投递 watcher 重路由到新 pane。**`notified_message_id` 必须存活** (Gap 32,清空会二次注入)。
345
383
  /// step 10 claim-leader 调。
@@ -15,6 +15,10 @@ pub enum Provider {
15
15
  Claude,
16
16
  ClaudeCode,
17
17
  Codex,
18
+ /// GitHub Copilot CLI(0.3.x 新增)。一期 subscription-only(已登录态),无 fork
19
+ /// 能力(caps.fork=false → CapabilityUnsupported),system prompt 走 per-worker
20
+ /// AGENTS.md + `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` env(B2 灵魂件降级,§C2)。
21
+ Copilot,
18
22
  GeminiCli,
19
23
  Fake,
20
24
  }
@@ -257,7 +261,9 @@ mod tests {
257
261
  #[test]
258
262
  fn unknown_provider_string_is_rejected() {
259
263
  // 与 receive_worker_outputs 不同:Provider 是封闭集,未知值必须 Err(不 passthrough)。
260
- assert!(serde_json::from_str::<Provider>("\"copilot\"").is_err());
264
+ // NOTE: "copilot" 已 0.3.5 加入(design + cr verdict 全 APPROVE),改用一个
265
+ // 仍未注册的串验"封闭集"语义。
266
+ assert!(serde_json::from_str::<Provider>("\"gibberish\"").is_err());
261
267
  }
262
268
 
263
269
  #[test]
@@ -184,6 +184,13 @@ pub fn provider_enforcement(provider: Provider, tool: Tool) -> Enforcement {
184
184
  FsRead | FsWrite | FsList | ExecuteBash | GitDiff | ProviderBuiltin => Hard,
185
185
  },
186
186
  Provider::Fake => Hard,
187
+ // Copilot(C-2-1 cr verdict):execute_bash/fs_write/network/mcp_team = hard,
188
+ // fs_read/fs_list/git_diff/provider_builtin = prompt_only(诚实:copilot 无
189
+ // 对应 deny kind,framework 不替决,留给 provider prompt 控制;MUST-NOT-13)。
190
+ Provider::Copilot => match tool {
191
+ FsRead | FsList | GitDiff | ProviderBuiltin => PromptOnly,
192
+ Network | FsWrite | ExecuteBash | McpTeam => Hard,
193
+ },
187
194
  // codex: 全 prompt_only。claude: 不在表中 → 全 prompt_only(同 fallback)。
188
195
  Provider::Codex | Provider::Claude => PromptOnly,
189
196
  }
@@ -175,7 +175,9 @@ fn result_schema_errors(envelope: &Value) -> Vec<String> {
175
175
  const ROOT_KEYS: &[&str] = &[
176
176
  "version", "team", "leader", "agents", "routing", "communication", "runtime", "context", "tasks",
177
177
  ];
178
- const SUPPORTED_PROVIDERS: &[&str] = &["claude", "claude_code", "codex", "gemini_cli", "fake"];
178
+ // Copilot 一期加入白名单(design §B compiler.py:249-251 同位 + cr verdict 总裁,
179
+ // MUST-NOT-7 跨厂商等价 — 设计 / cr 已落地 26 约束)。
180
+ const SUPPORTED_PROVIDERS: &[&str] = &["claude", "claude_code", "codex", "copilot", "gemini_cli", "fake"];
179
181
  const AUTH_MODES: &[&str] = &["subscription", "official_api", "compatible_api"];
180
182
  const VALID_DISPLAY_BACKENDS: &[&str] = &[
181
183
  "none", "tmux_attach", "iterm", "ghostty", "ghostty_window", "ghostty_workspace", "adaptive",