@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
@@ -184,7 +184,7 @@ impl Coordinator {
184
184
  let mut state = crate::state::persist::load_runtime_state(self.workspace.as_path())?;
185
185
  let store = crate::message_store::MessageStore::open(self.workspace.as_path())?;
186
186
  let event_log = EventLog::new(self.workspace.as_path());
187
- increment_coordinator_tick_iteration_count(&mut state);
187
+ increment_coordinator_tick_iteration_count(&self.workspace);
188
188
 
189
189
  self.record_step("tmux_session_gate");
190
190
  if let Some(session_name) = state
@@ -232,8 +232,37 @@ impl Coordinator {
232
232
  self.handle_runtime_approval_prompts(&mut state, &event_log)?;
233
233
 
234
234
  self.record_step("sync_health");
235
- let captures_by_agent = self.sync_agent_health(&mut state, &store, &event_log)?;
236
- self.detect_abnormal_exits(&mut state, &event_log)?;
235
+ // P5 (C-P5-1, N3): ONE pane snapshot per tick, shared by sync_health and the
236
+ // abnormal-exit pass (same-tick reuse only — the snapshot does not outlive
237
+ // this tick; every tick re-reads).
238
+ let pane_snapshot = self.transport.list_targets().unwrap_or_default();
239
+ let captures_by_agent =
240
+ self.sync_agent_health(&mut state, &store, &event_log, &pane_snapshot)?;
241
+ // C-3-4 cr verdict — copilot 一期 classify→None(Unknown);为防 silent,
242
+ // tick 每次发现 copilot agent(从 state.agents 直接扫,不依赖 captures —
243
+ // 离线/未起 tmux 场景仍能写)就发 `provider.classify.unsupported` 事件
244
+ // (字面 reason=`phase1_unknown_pending_sample`,含 provider="copilot" + "classify"
245
+ // 串)。二期接 sqlite turns 表后这条删/降级,届时改 reason 区分。
246
+ if let Some(agents) = state.get("agents").and_then(Value::as_object) {
247
+ for (agent_id, agent) in agents {
248
+ let is_copilot = agent
249
+ .get("provider")
250
+ .and_then(Value::as_str)
251
+ .and_then(parse_provider)
252
+ .is_some_and(|p| matches!(p, crate::model::enums::Provider::Copilot));
253
+ if is_copilot {
254
+ let _ = event_log.write(
255
+ "provider.classify.unsupported",
256
+ serde_json::json!({
257
+ "provider": "copilot",
258
+ "agent_id": agent_id,
259
+ "reason": "phase1_unknown_pending_sample",
260
+ }),
261
+ );
262
+ }
263
+ }
264
+ }
265
+ self.detect_abnormal_exits(&mut state, &event_log, &pane_snapshot)?;
237
266
 
238
267
  self.record_step("deliver_pending");
239
268
  let delivered = crate::messaging::deliver_pending_messages(
@@ -353,13 +382,17 @@ impl Coordinator {
353
382
  state: &mut Value,
354
383
  store: &crate::message_store::MessageStore,
355
384
  event_log: &EventLog,
385
+ pane_infos: &[crate::transport::PaneInfo],
356
386
  ) -> Result<BTreeMap<AgentId, CapturedRuntimeFact>, TickError> {
357
387
  let mut captures = BTreeMap::new();
358
388
  let snapshot = state.clone();
359
389
  let team = crate::state::projection::team_state_key(&snapshot);
360
390
  let team_key = Some(crate::model::ids::TeamKey::new(team.clone()));
361
391
  let session_name = state.get("session_name").and_then(Value::as_str).map(str::to_string);
362
- let pane_infos = self.transport.list_targets().unwrap_or_default();
392
+ // P5 (C-P5-2): one list-windows per SESSION per tick — memoized across the
393
+ // agent loop instead of one fork per agent.
394
+ let mut windows_by_session: BTreeMap<String, Result<Vec<crate::transport::WindowName>, String>> =
395
+ BTreeMap::new();
363
396
  let Some(agents) = state.get_mut("agents").and_then(Value::as_object_mut) else {
364
397
  return Ok(captures);
365
398
  };
@@ -367,15 +400,21 @@ impl Coordinator {
367
400
  let Some((session, window, target)) = capture_window_target(agent, session_name.as_deref()) else {
368
401
  continue;
369
402
  };
370
- let windows = match self.transport.list_windows(&session) {
371
- Ok(windows) => windows,
403
+ let windows = match windows_by_session
404
+ .entry(session.as_str().to_string())
405
+ .or_insert_with(|| {
406
+ self.transport
407
+ .list_windows(&session)
408
+ .map_err(|error| error.to_string())
409
+ }) {
410
+ Ok(windows) => windows.clone(),
372
411
  Err(error) => {
373
412
  event_log.write(
374
413
  "coordinator.agent_capture_failed",
375
414
  serde_json::json!({
376
415
  "agent_id": agent_id,
377
416
  "target": format!("{target:?}"),
378
- "error": error.to_string(),
417
+ "error": error.clone(),
379
418
  }),
380
419
  )?;
381
420
  continue;
@@ -408,18 +447,38 @@ impl Coordinator {
408
447
  let current_command = agent
409
448
  .get("pane_current_command")
410
449
  .or_else(|| agent.get("current_command"))
411
- .and_then(Value::as_str);
412
- let last_output_at_before = agent.get("last_output_at").and_then(Value::as_str);
450
+ .and_then(Value::as_str)
451
+ .map(str::to_string);
452
+ // Python approvals/status.py:68-73 — last_output_at advances ONLY when the
453
+ // scrollback sha256 digest changed (last_output_hash gate), and it is
454
+ // refreshed BEFORE classification (the classifier sees the updated value).
455
+ // A non-empty but UNCHANGED capture must not dirty the state every tick
456
+ // (P3 umbrella: steady second tick is a zero state write).
457
+ let output_advanced =
458
+ !captured.text.is_empty() && scrollback_digest_advanced(agent, &captured.text);
459
+ if output_advanced {
460
+ if let Some(agent_obj) = agent.as_object_mut() {
461
+ agent_obj.insert(
462
+ "last_output_at".to_string(),
463
+ serde_json::json!(chrono::Utc::now().to_rfc3339()),
464
+ );
465
+ }
466
+ }
467
+ let last_output_at_now = agent
468
+ .get("last_output_at")
469
+ .and_then(Value::as_str)
470
+ .map(str::to_string);
413
471
  let activity = crate::messaging::classify_agent_activity(
414
472
  &snapshot,
415
473
  &captured.text,
416
474
  pane_in_mode,
417
- current_command,
418
- last_output_at_before,
475
+ current_command.as_deref(),
476
+ last_output_at_now.as_deref(),
419
477
  );
420
- let last_output_at = write_activity(agent, &activity, !captured.text.is_empty());
478
+ write_activity(agent, &activity, false);
479
+ let last_output_at = last_output_at_now;
421
480
  write_agent_health(store, &team, agent_id, agent, &activity, last_output_at.as_deref())?;
422
- let pane_info = matching_capture_pane_info(agent, &session, &window, &pane_infos);
481
+ let pane_info = matching_capture_pane_info(agent, &session, &window, pane_infos);
423
482
  let pane_id = pane_info
424
483
  .as_ref()
425
484
  .map(|info| info.pane_id.clone())
@@ -488,11 +547,11 @@ impl Coordinator {
488
547
  &self,
489
548
  state: &mut Value,
490
549
  event_log: &EventLog,
550
+ targets: &[crate::transport::PaneInfo],
491
551
  ) -> Result<(), TickError> {
492
552
  let snapshot = state.clone();
493
553
  let team = crate::state::projection::team_state_key(&snapshot);
494
554
  let session_name = snapshot.get("session_name").and_then(Value::as_str);
495
- let targets = self.transport.list_targets().unwrap_or_default();
496
555
  for agent in abnormal_watch_agents(&snapshot) {
497
556
  let rollout_path = resolve_agent_rollout_path(self.workspace.as_path(), &agent.rollout_path);
498
557
  let metadata = match std::fs::metadata(&rollout_path) {
@@ -508,7 +567,21 @@ impl Coordinator {
508
567
  };
509
568
  let size = metadata.len();
510
569
  let mtime_ns = metadata_mtime_ns(&metadata);
511
- let text = match std::fs::read_to_string(&rollout_path) {
570
+ // P1 (C-P1-2/3): (size, mtime_ns) pair gate — an unchanged transcript is not
571
+ // read at all (live sample: 332MB whole-file read per agent per 2s tick).
572
+ // ANY field change (including a size shrink / truncate) falls through to the
573
+ // re-read below.
574
+ if let (Some(mtime), Some(stored)) =
575
+ (mtime_ns, abnormal_watch_stored_metadata(&snapshot, &agent.agent_id))
576
+ {
577
+ if stored == (size, mtime) {
578
+ continue;
579
+ }
580
+ }
581
+ // P1 (C-P1-1): bounded tail read — the abnormal decision only consumes the
582
+ // LATEST transcript record; window matches Python `_TAIL_BYTES` (131072,
583
+ // idle_takeover_wiring.py:13), never less.
584
+ let text = match read_tail_text(&rollout_path, ABNORMAL_TAIL_BYTES) {
512
585
  Ok(text) => text,
513
586
  Err(error) => {
514
587
  upsert_abnormal_watch(
@@ -522,7 +595,7 @@ impl Coordinator {
522
595
  let liveness = agent_process_liveness(
523
596
  &agent,
524
597
  session_name,
525
- &targets,
598
+ targets,
526
599
  self.transport.as_ref(),
527
600
  );
528
601
  let fact = crate::provider::latest_explicit_error_fact(agent.provider, &text);
@@ -643,7 +716,21 @@ impl Coordinator {
643
716
  continue;
644
717
  };
645
718
  let adapter = self.provider_registry.adapter_for(provider);
646
- let handled = adapter.handle_startup_prompts(self.transport.as_ref(), &target, 1, 0.0);
719
+ let outcome =
720
+ adapter.handle_startup_prompts_outcome(self.transport.as_ref(), &target, 1, 0.0);
721
+ // swallow batch 2 ② (A1): an unobservable pane is a surfaced failure, not a
722
+ // silent "no prompts" — the agent's startup_prompts state stays un-handled.
723
+ if let Some(error) = &outcome.capture_error {
724
+ let _ = event_log.write(
725
+ "provider.startup_prompt_failed",
726
+ serde_json::json!({
727
+ "agent_id": agent_id,
728
+ "target": format!("{target:?}"),
729
+ "error": error,
730
+ }),
731
+ );
732
+ }
733
+ let handled = outcome.handled;
647
734
  if handled.is_empty() {
648
735
  continue;
649
736
  }
@@ -791,7 +878,21 @@ impl Coordinator {
791
878
  .into_iter()
792
879
  .filter_map(runtime_approval_key)
793
880
  .collect::<Vec<_>>();
794
- self.transport.send_keys(&target, &keys)?;
881
+ // A-6 / Python approvals/runtime_prompts.py:21-43: prompts are handled
882
+ // per-agent with run_cmd(check=False) — one agent's tmux failure must
883
+ // not abort the whole tick for the rest.
884
+ if let Err(error) = self.transport.send_keys(&target, &keys) {
885
+ event_log.write(
886
+ "runtime_approval.send_keys_failed",
887
+ serde_json::json!({
888
+ "agent_id": agent_id,
889
+ "target": format!("{target:?}"),
890
+ "tool": prompt.tool,
891
+ "error": error.to_string(),
892
+ }),
893
+ )?;
894
+ continue;
895
+ }
795
896
  let after = self
796
897
  .transport
797
898
  .capture(&target, crate::transport::CaptureRange::Tail(80))
@@ -992,12 +1093,10 @@ impl Coordinator {
992
1093
  /// `message_store_schema_health`(`lifecycle.py:197`)。DB 列兼容门:区分 pre-init 必需列缺失
993
1094
  /// (拒启)vs migratable 列缺失(可迁移)。`advanced repair-state --schema` 用其 action hint。
994
1095
  pub fn schema_health(&self) -> SchemaHealth {
995
- SchemaHealth {
996
- ok: true,
997
- schema_version: crate::db::schema::SCHEMA_VERSION,
998
- error: None,
999
- action: None,
1000
- }
1096
+ // A-8: the gate must inspect the REAL team.db (Python lifecycle.py:197+
1097
+ // message_store_schema_health); a hardcoded ok:true left the card §89
1098
+ // restart_incompatible door permanently dead.
1099
+ super::health::message_store_schema_health(&self.workspace)
1001
1100
  }
1002
1101
 
1003
1102
  fn record_step(&self, step: &'static str) {
@@ -1092,27 +1191,27 @@ impl TurnStateClassifier for ProviderTurnClassifier {
1092
1191
  }
1093
1192
  }
1094
1193
 
1095
- fn increment_coordinator_tick_iteration_count(state: &mut Value) {
1096
- let Some(state_obj) = state.as_object_mut() else {
1097
- return;
1098
- };
1099
- let coordinator = state_obj
1100
- .entry("coordinator".to_string())
1101
- .or_insert_with(|| serde_json::json!({}));
1102
- if !coordinator.is_object() {
1103
- *coordinator = serde_json::json!({});
1104
- }
1105
- let Some(coord_obj) = coordinator.as_object_mut() else {
1106
- return;
1107
- };
1108
- let next = coord_obj
1109
- .get("coordinator_tick_iteration_count")
1110
- .and_then(Value::as_u64)
1194
+ /// P3 (C-P3-1, N1): the tick counter is a transient diagnostic, NOT source-of-truth
1195
+ /// state keeping it in state.json made EVERY tick dirty and defeated both save
1196
+ /// short-circuits. It lives in its own metadata file; old state files still carrying
1197
+ /// `coordinator.coordinator_tick_iteration_count` load fine (read-compat, C-P3-3) —
1198
+ /// new versions simply stop writing it.
1199
+ fn increment_coordinator_tick_iteration_count(workspace: &WorkspacePath) {
1200
+ let path =
1201
+ crate::model::paths::runtime_dir(workspace.as_path()).join("coordinator_tick.json");
1202
+ let next = std::fs::read_to_string(&path)
1203
+ .ok()
1204
+ .and_then(|text| serde_json::from_str::<Value>(&text).ok())
1205
+ .and_then(|value| {
1206
+ value
1207
+ .get("coordinator_tick_iteration_count")
1208
+ .and_then(Value::as_u64)
1209
+ })
1111
1210
  .unwrap_or(0)
1112
1211
  .saturating_add(1);
1113
- coord_obj.insert(
1114
- "coordinator_tick_iteration_count".to_string(),
1115
- serde_json::json!(next),
1212
+ let _ = std::fs::write(
1213
+ &path,
1214
+ serde_json::json!({"coordinator_tick_iteration_count": next}).to_string(),
1116
1215
  );
1117
1216
  }
1118
1217
 
@@ -1143,6 +1242,7 @@ fn provider_wire(provider: crate::model::enums::Provider) -> &'static str {
1143
1242
  crate::model::enums::Provider::Claude => "claude",
1144
1243
  crate::model::enums::Provider::ClaudeCode => "claude_code",
1145
1244
  crate::model::enums::Provider::Codex => "codex",
1245
+ crate::model::enums::Provider::Copilot => "copilot",
1146
1246
  crate::model::enums::Provider::GeminiCli => "gemini_cli",
1147
1247
  crate::model::enums::Provider::Fake => "fake",
1148
1248
  }
@@ -1430,6 +1530,7 @@ fn provider_command_matches(provider: crate::model::enums::Provider, command: &s
1430
1530
  lower.contains("claude")
1431
1531
  }
1432
1532
  crate::model::enums::Provider::Codex => lower.contains("codex"),
1533
+ crate::model::enums::Provider::Copilot => lower.contains("copilot"),
1433
1534
  crate::model::enums::Provider::GeminiCli => lower.contains("gemini"),
1434
1535
  crate::model::enums::Provider::Fake => lower.contains("fake"),
1435
1536
  }
@@ -1559,6 +1660,35 @@ fn abnormal_last_check_key(state: &Value, agent_id: &str) -> Option<String> {
1559
1660
  abnormal_watch_str(state, agent_id, "last_check_key")
1560
1661
  }
1561
1662
 
1663
+ /// P1: Python `_TAIL_BYTES` parity (idle_takeover_wiring.py:13) — RS must not read less.
1664
+ const ABNORMAL_TAIL_BYTES: u64 = 131_072;
1665
+
1666
+ /// P1: bounded tail read; a partial first line is harmless (the consumer only parses
1667
+ /// the latest complete JSONL record) and lossy UTF-8 keeps a mid-codepoint seek safe.
1668
+ fn read_tail_text(path: &Path, max_bytes: u64) -> std::io::Result<String> {
1669
+ use std::io::{Read, Seek, SeekFrom};
1670
+ let mut file = std::fs::File::open(path)?;
1671
+ let len = file.metadata()?.len();
1672
+ if len > max_bytes {
1673
+ file.seek(SeekFrom::Start(len - max_bytes))?;
1674
+ }
1675
+ let mut bytes = Vec::new();
1676
+ file.read_to_end(&mut bytes)?;
1677
+ Ok(String::from_utf8_lossy(&bytes).into_owned())
1678
+ }
1679
+
1680
+ /// P1: the previous tick's `(size, mtime_ns)` pair from the abnormal watch payload.
1681
+ fn abnormal_watch_stored_metadata(state: &Value, agent_id: &str) -> Option<(u64, u64)> {
1682
+ let watch = state
1683
+ .get("coordinator")?
1684
+ .get("abnormal_exit_watch")?
1685
+ .get(agent_id)?;
1686
+ Some((
1687
+ watch.get("size")?.as_u64()?,
1688
+ watch.get("mtime_ns")?.as_u64()?,
1689
+ ))
1690
+ }
1691
+
1562
1692
  fn abnormal_watch_str(state: &Value, agent_id: &str, field: &str) -> Option<String> {
1563
1693
  state
1564
1694
  .get("coordinator")
@@ -1791,6 +1921,7 @@ fn parse_provider(raw: &str) -> Option<crate::model::enums::Provider> {
1791
1921
  "claude" => Some(crate::model::enums::Provider::Claude),
1792
1922
  "claude_code" => Some(crate::model::enums::Provider::ClaudeCode),
1793
1923
  "codex" => Some(crate::model::enums::Provider::Codex),
1924
+ "copilot" => Some(crate::model::enums::Provider::Copilot),
1794
1925
  "gemini_cli" => Some(crate::model::enums::Provider::GeminiCli),
1795
1926
  "fake" => Some(crate::model::enums::Provider::Fake),
1796
1927
  _ => None,
@@ -2081,6 +2212,27 @@ fn clear_awaiting_human_confirm(agent: &mut Value) {
2081
2212
  }
2082
2213
  }
2083
2214
 
2215
+ /// Python approvals/status.py:68-72 — sha256 the scrollback, compare to the stored
2216
+ /// `last_output_hash`; only a CHANGED digest counts as advanced output (and stores
2217
+ /// the new digest).
2218
+ fn scrollback_digest_advanced(agent: &mut Value, text: &str) -> bool {
2219
+ use sha2::Digest;
2220
+ let mut hasher = sha2::Sha256::new();
2221
+ hasher.update(text.as_bytes());
2222
+ let digest = format!("{:x}", hasher.finalize());
2223
+ let unchanged = agent
2224
+ .get("last_output_hash")
2225
+ .and_then(Value::as_str)
2226
+ .is_some_and(|stored| stored == digest);
2227
+ if unchanged {
2228
+ return false;
2229
+ }
2230
+ if let Some(obj) = agent.as_object_mut() {
2231
+ obj.insert("last_output_hash".to_string(), serde_json::json!(digest));
2232
+ }
2233
+ true
2234
+ }
2235
+
2084
2236
  fn write_activity(
2085
2237
  agent: &mut Value,
2086
2238
  activity: &crate::messaging::AgentActivity,
@@ -14,6 +14,7 @@ pub(crate) fn provider_wire(provider: Provider) -> &'static str {
14
14
  Provider::Claude => "claude",
15
15
  Provider::ClaudeCode => "claude_code",
16
16
  Provider::Codex => "codex",
17
+ Provider::Copilot => "copilot",
17
18
  Provider::GeminiCli => "gemini_cli",
18
19
  Provider::Fake => "fake",
19
20
  }
@@ -24,6 +25,7 @@ pub(crate) fn parse_provider(s: &str) -> Option<Provider> {
24
25
  "claude" => Some(Provider::Claude),
25
26
  "claude_code" => Some(Provider::ClaudeCode),
26
27
  "codex" => Some(Provider::Codex),
28
+ "copilot" => Some(Provider::Copilot),
27
29
  "gemini_cli" => Some(Provider::GeminiCli),
28
30
  "fake" => Some(Provider::Fake),
29
31
  _ => None,
@@ -898,6 +898,7 @@ fn provider_wire(provider: Provider) -> &'static str {
898
898
  Provider::Claude => "claude",
899
899
  Provider::ClaudeCode => "claude_code",
900
900
  Provider::Codex => "codex",
901
+ Provider::Copilot => "copilot",
901
902
  Provider::GeminiCli => "gemini_cli",
902
903
  Provider::Fake => "fake",
903
904
  }
@@ -187,6 +187,10 @@ pub fn execute_leader_plan(
187
187
  }
188
188
  }
189
189
 
190
+ /// B5: the deterministic leader-session naming prefix IS the ownership truth source —
191
+ /// shutdown's socket teardown spares sessions carrying it (no separate registry).
192
+ pub const LEADER_SESSION_PREFIX: &str = "team-agent-leader-";
193
+
190
194
  /// `leader_session_name`(card §48;`__init__.py:186`)。确定派生 tmux session 名
191
195
  /// `team-agent-leader-<provider>-<folder>-<sha1[:8]>`(workspace.resolve() 的 sha1 前 8 hex)。
192
196
  pub fn leader_session_name(provider: Provider, workspace: &Path) -> SessionName {
@@ -198,7 +202,7 @@ pub fn leader_session_name(provider: Provider, workspace: &Path) -> SessionName
198
202
  let folder = sanitize_session_folder(folder_raw);
199
203
  let hash = sha1_hex_prefix(resolved.to_string_lossy().as_bytes(), 8);
200
204
  SessionName::new(format!(
201
- "team-agent-leader-{}-{folder}-{hash}",
205
+ "{LEADER_SESSION_PREFIX}{}-{folder}-{hash}",
202
206
  provider_wire(provider)
203
207
  ))
204
208
  }
@@ -309,6 +313,10 @@ fn provider_command_name(provider: Provider) -> &'static str {
309
313
  match provider {
310
314
  Provider::Claude | Provider::ClaudeCode => "claude",
311
315
  Provider::Codex => "codex",
316
+ // §B leader 入口接缝(设计 design.md line 40):`team-agent copilot` 启 leader
317
+ // 即 spawn 真 copilot 命令;B5 session 名前缀 `team-agent-leader-copilot-*`
318
+ // (leader/start.rs:192-204 派生)自动覆盖前缀保护。
319
+ Provider::Copilot => "copilot",
312
320
  Provider::GeminiCli => "gemini",
313
321
  Provider::Fake => "fake",
314
322
  }
@@ -130,7 +130,6 @@ pub fn evaluate_takeover_reminder(
130
130
  nodes: &[IdleNode],
131
131
  arm_state: &Value,
132
132
  ) -> Result<TakeoverReminderResult, LeaderError> {
133
- let _ = arm_state;
134
133
  if nodes.is_empty() {
135
134
  return Ok(TakeoverReminderResult {
136
135
  should_ping: false,
@@ -147,6 +146,24 @@ pub fn evaluate_takeover_reminder(
147
146
  reason: Some(format!("node_{}", turn_state_wire(blocking.state))),
148
147
  });
149
148
  }
149
+ // idle_predicate.py:55-62 (C1): only a real worker turn-open arms the watch — an
150
+ // un-armed monitor must never ping. The facade honors both its own write-side key
151
+ // (`armed`, record_turn_open_after_delivery) and the classify-layer monitor_state
152
+ // key (`opened_worker_turn_since_ack`); debounce/episode tiers stay at the classify
153
+ // layer (provider/classify.rs evaluate_takeover_reminder).
154
+ let armed = arm_state.get("armed").and_then(Value::as_bool) == Some(true)
155
+ || arm_state
156
+ .get("opened_worker_turn_since_ack")
157
+ .and_then(Value::as_bool)
158
+ == Some(true);
159
+ if !armed {
160
+ return Ok(TakeoverReminderResult {
161
+ should_ping: false,
162
+ message: None,
163
+ interrupted_nodes: Vec::new(),
164
+ reason: Some("not_armed_no_worker_turn".to_string()),
165
+ });
166
+ }
150
167
  let interrupted_nodes = nodes
151
168
  .iter()
152
169
  .filter(|n| n.state == TurnState::IdleInterrupted)