@team-agent/installer 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +23 -0
  4. package/crates/team-agent/src/cli/leader.rs +2 -5
  5. package/crates/team-agent/src/cli/mod.rs +90 -4
  6. package/crates/team-agent/src/cli/tests/compile.rs +69 -0
  7. package/crates/team-agent/src/cli/tests/main_preserved.rs +68 -0
  8. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +160 -2
  9. package/crates/team-agent/src/compiler.rs +30 -0
  10. package/crates/team-agent/src/coordinator/health.rs +63 -3
  11. package/crates/team-agent/src/coordinator/tick.rs +16 -83
  12. package/crates/team-agent/src/leader/lease.rs +4 -20
  13. package/crates/team-agent/src/leader/mod.rs +3 -1
  14. package/crates/team-agent/src/leader/owner_bind.rs +46 -48
  15. package/crates/team-agent/src/leader/provider_attribution.rs +214 -0
  16. package/crates/team-agent/src/leader/rediscover/tests.rs +3 -3
  17. package/crates/team-agent/src/leader/rediscover.rs +34 -17
  18. package/crates/team-agent/src/lifecycle/launch.rs +158 -12
  19. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +229 -28
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +135 -8
  21. package/crates/team-agent/src/lifecycle/types.rs +28 -0
  22. package/crates/team-agent/src/messaging/delivery.rs +5 -1
  23. package/crates/team-agent/src/messaging/helpers.rs +1 -1
  24. package/crates/team-agent/src/messaging/tests/runtime.rs +14 -0
  25. package/crates/team-agent/src/provider/adapter.rs +6 -3
  26. package/crates/team-agent/src/provider/startup_prompt.rs +173 -0
  27. package/crates/team-agent/src/provider/testdata/copilot-ready-marker.txt +5 -0
  28. package/crates/team-agent/src/provider/testdata/copilot-trust-prompt.txt +19 -0
  29. package/crates/team-agent/src/transport/test_support.rs +22 -7
  30. package/package.json +4 -4
@@ -264,55 +264,6 @@ impl Coordinator {
264
264
  BTreeMap::new()
265
265
  }
266
266
  };
267
- // C-3-4 cr verdict — copilot 一期 classify→None(Unknown);为防 silent,
268
- // tick 每次发现 copilot agent(从 state.agents 直接扫,不依赖 captures —
269
- // 离线/未起 tmux 场景仍能写)就发 `provider.classify.unsupported` 事件
270
- // (字面 reason=`phase1_unknown_pending_sample`,含 provider="copilot" + "classify"
271
- // 串)。二期接 sqlite turns 表后这条删/降级,届时改 reason 区分。
272
- //
273
- // B-4 P4 / 036b 防 dedup-flood:同 (provider, agent_id, reason) 状态跨 tick
274
- // 只发一次(check_key 范式,同 abnormal_check_key tick.rs:603 同精神);状态
275
- // 变了才再发。check_key 落 state.agents.<id>.classify_unsupported.last_key,
276
- // tick-only metadata,不进 #235 owner / receiver 等持久态。
277
- let agents_snapshot: Vec<(String, Option<String>)> =
278
- if let Some(agents) = state.get("agents").and_then(Value::as_object) {
279
- agents
280
- .iter()
281
- .filter_map(|(agent_id, agent)| {
282
- let is_copilot = agent
283
- .get("provider")
284
- .and_then(Value::as_str)
285
- .and_then(parse_provider)
286
- .is_some_and(|p| matches!(p, crate::model::enums::Provider::Copilot));
287
- if !is_copilot {
288
- return None;
289
- }
290
- let last_key = agent
291
- .get("classify_unsupported")
292
- .and_then(|v| v.get("last_key"))
293
- .and_then(Value::as_str)
294
- .map(str::to_string);
295
- Some((agent_id.clone(), last_key))
296
- })
297
- .collect()
298
- } else {
299
- Vec::new()
300
- };
301
- for (agent_id, last_key) in agents_snapshot {
302
- let check_key = format!("copilot|{agent_id}|phase1_unknown_pending_sample");
303
- if last_key.as_deref() == Some(check_key.as_str()) {
304
- continue;
305
- }
306
- let _ = event_log.write(
307
- "provider.classify.unsupported",
308
- serde_json::json!({
309
- "provider": "copilot",
310
- "agent_id": agent_id,
311
- "reason": "phase1_unknown_pending_sample",
312
- }),
313
- );
314
- mark_classify_unsupported(&mut state, &agent_id, &check_key);
315
- }
316
267
  if let Err(error) = self.detect_abnormal_exits(&mut state, &event_log, &pane_snapshot) {
317
268
  let _ = event_log.write(
318
269
  "coordinator.tick.detect_abnormal_failed",
@@ -1644,7 +1595,7 @@ fn agent_process_liveness(
1644
1595
  }
1645
1596
  if let Some(target) = matching_agent_target(agent, session_name, targets) {
1646
1597
  if let Some(command) = target.current_command.as_deref() {
1647
- return command_process_check(agent.provider, command);
1598
+ return pane_command_process_check(agent.provider, target, command);
1648
1599
  }
1649
1600
  if let Some(pid) = target.pane_pid.map(Pid::new) {
1650
1601
  return pid_process_check("pane_pid", pid);
@@ -1731,7 +1682,7 @@ fn pid_process_check(label: &str, pid: Pid) -> ProcessCheck {
1731
1682
  }
1732
1683
 
1733
1684
  fn command_process_check(provider: crate::model::enums::Provider, command: &str) -> ProcessCheck {
1734
- if provider_command_matches(provider, command) {
1685
+ if crate::leader::command_matches_provider(provider, command) {
1735
1686
  process_check(ProcessLiveness::Alive, format!("current_command:{command}"))
1736
1687
  } else {
1737
1688
  process_check(
@@ -1741,16 +1692,20 @@ fn command_process_check(provider: crate::model::enums::Provider, command: &str)
1741
1692
  }
1742
1693
  }
1743
1694
 
1744
- fn provider_command_matches(provider: crate::model::enums::Provider, command: &str) -> bool {
1745
- let lower = command.to_ascii_lowercase();
1746
- match provider {
1747
- crate::model::enums::Provider::Claude | crate::model::enums::Provider::ClaudeCode => {
1748
- lower.contains("claude")
1749
- }
1750
- crate::model::enums::Provider::Codex => lower.contains("codex"),
1751
- crate::model::enums::Provider::Copilot => lower.contains("copilot"),
1752
- crate::model::enums::Provider::GeminiCli => lower.contains("gemini"),
1753
- crate::model::enums::Provider::Fake => lower.contains("fake"),
1695
+ fn pane_command_process_check(
1696
+ provider: crate::model::enums::Provider,
1697
+ pane: &crate::transport::PaneInfo,
1698
+ command: &str,
1699
+ ) -> ProcessCheck {
1700
+ if crate::leader::attribute_pane_provider(pane)
1701
+ .is_some_and(|candidate| crate::leader::provider_matches(candidate, provider))
1702
+ {
1703
+ process_check(ProcessLiveness::Alive, format!("current_command:{command}"))
1704
+ } else {
1705
+ process_check(
1706
+ ProcessLiveness::Dead,
1707
+ format!("provider_not_foreground:{command}"),
1708
+ )
1754
1709
  }
1755
1710
  }
1756
1711
 
@@ -1962,28 +1917,6 @@ fn mark_abnormal_suppressed(state: &mut Value, agent_id: &str, key: &str) {
1962
1917
  }
1963
1918
  }
1964
1919
 
1965
- /// B-4 P4 dedup marker — 把 classify.unsupported check_key 写到
1966
- /// state.agents.<id>.classify_unsupported.last_key,只在状态变了才再发事件。
1967
- /// 同 abnormal_check_key (line 603) 同精神,但落 agents 子 obj(per-agent locality,
1968
- /// 不污染 abnormal_exit_watch 命名空间)。
1969
- fn mark_classify_unsupported(state: &mut Value, agent_id: &str, key: &str) {
1970
- let Some(agents) = state.get_mut("agents").and_then(Value::as_object_mut) else {
1971
- return;
1972
- };
1973
- let Some(agent) = agents.get_mut(agent_id).and_then(Value::as_object_mut) else {
1974
- return;
1975
- };
1976
- let entry = agent
1977
- .entry("classify_unsupported".to_string())
1978
- .or_insert_with(|| serde_json::json!({}));
1979
- if !entry.is_object() {
1980
- *entry = serde_json::json!({});
1981
- }
1982
- if let Some(obj) = entry.as_object_mut() {
1983
- obj.insert("last_key".to_string(), serde_json::json!(key));
1984
- }
1985
- }
1986
-
1987
1920
  fn mark_abnormal_checked(state: &mut Value, agent_id: &str, key: &str) {
1988
1921
  if let Some(watch) = coordinator_child_object(state, "abnormal_exit_watch") {
1989
1922
  let entry = watch
@@ -634,8 +634,7 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
634
634
  if !target.active {
635
635
  return None;
636
636
  }
637
- let command = target.current_command.as_deref().unwrap_or_default();
638
- let provider = leader_command_provider(command)?;
637
+ let provider = super::attribute_pane_provider(target)?;
639
638
  let current_path = target.current_path.as_deref()?;
640
639
  if !crate::state::owner_gate::workspace_paths_match(current_path, workspace) {
641
640
  return None;
@@ -648,21 +647,6 @@ fn claim_target_from_pane_info(workspace: &Path, target: &PaneInfo) -> Option<Le
648
647
  })
649
648
  }
650
649
 
651
- fn leader_command_provider(command: &str) -> Option<Provider> {
652
- let lower = command.to_ascii_lowercase();
653
- if lower.contains("claude") {
654
- Some(Provider::ClaudeCode)
655
- } else if lower.contains("codex") {
656
- Some(Provider::Codex)
657
- } else if lower.contains("copilot") {
658
- Some(Provider::Copilot)
659
- } else if lower.contains("fake") {
660
- Some(Provider::Fake)
661
- } else {
662
- None
663
- }
664
- }
665
-
666
650
  fn target_leader_session_uuid(target: &PaneInfo) -> Option<crate::model::ids::LeaderSessionUuid> {
667
651
  target
668
652
  .leader_env
@@ -1090,13 +1074,13 @@ mod tests {
1090
1074
  use super::*;
1091
1075
 
1092
1076
  #[test]
1093
- fn leader_command_provider_recognizes_copilot() {
1077
+ fn attribute_command_provider_recognizes_copilot() {
1094
1078
  assert_eq!(
1095
- leader_command_provider("copilot --allow-all-tools"),
1079
+ super::super::attribute_command_provider("copilot --allow-all-tools"),
1096
1080
  Some(Provider::Copilot)
1097
1081
  );
1098
1082
  assert_eq!(
1099
- leader_command_provider("/usr/local/bin/copilot"),
1083
+ super::super::attribute_command_provider("/usr/local/bin/copilot"),
1100
1084
  Some(Provider::Copilot)
1101
1085
  );
1102
1086
  }
@@ -13,7 +13,7 @@
13
13
  //! (`build_idle_nodes` / `push_idle_reminder`) / `wake.py`
14
14
  //! (`should_reread` / `on_file_changed` / `take_pending`).
15
15
  //! - `messaging/leader_panes.py` (`_leader_command_looks_usable` /
16
- //! `_leader_command_provider` / `_target_leader_session_uuid`).
16
+ //! provider attribution / `_target_leader_session_uuid`).
17
17
  //!
18
18
  //! 职责(card §职责):拥有「谁是 leader、消息投到哪个 pane」这一身份事实,当成租约(lease)
19
19
  //! 管理。pane id 即权威路由/授权身份,`owner_epoch` 做 CAS/去重,确定派生的
@@ -79,6 +79,7 @@ mod helpers;
79
79
  pub mod inject;
80
80
  pub mod lease;
81
81
  pub mod owner_bind;
82
+ pub mod provider_attribution;
82
83
  pub mod rediscover;
83
84
  pub mod start;
84
85
  pub mod takeover;
@@ -88,6 +89,7 @@ pub mod types;
88
89
  pub use inject::*;
89
90
  pub use lease::*;
90
91
  pub use owner_bind::*;
92
+ pub(crate) use provider_attribution::*;
91
93
  pub use rediscover::*;
92
94
  pub use start::*;
93
95
  pub use takeover::*;
@@ -8,7 +8,7 @@ use serde_json::{json, Value};
8
8
  use crate::model::ids::{LeaderSessionUuid, OwnerEpoch, TeamKey};
9
9
  use crate::provider::Provider;
10
10
  use crate::tmux_backend::TmuxBackend;
11
- use crate::transport::{PaneField, PaneId, Target, Transport};
11
+ use crate::transport::{PaneField, PaneId, PaneInfo, SessionName, Target, Transport};
12
12
 
13
13
  use super::helpers::{get_path_str, now_ts, prefix, resolve_workspace_for_hash};
14
14
  use super::{
@@ -134,8 +134,12 @@ pub fn bind_owner_from_caller_pane(
134
134
  hint: Some(hint.to_string()),
135
135
  });
136
136
  };
137
- let caller_current_command = tmux_pane_current_command(workspace, &pane).unwrap_or_default();
138
- let provider = bind_provider_from_env_or_command(&caller_current_command);
137
+ let caller_info = tmux_pane_info(workspace, &pane);
138
+ let caller_current_command = caller_info
139
+ .as_ref()
140
+ .and_then(|info| info.current_command.clone())
141
+ .unwrap_or_else(|| tmux_pane_current_command(workspace, &pane).unwrap_or_default());
142
+ let provider = bind_provider_from_env_or_pane(&caller_current_command, caller_info);
139
143
  let machine_fingerprint = std::env::var("TEAM_AGENT_MACHINE_FINGERPRINT").unwrap_or_default();
140
144
  let os_user = std::env::var("USER")
141
145
  .or_else(|_| std::env::var("USERNAME"))
@@ -185,47 +189,19 @@ pub fn emit_owner_bound_event(
185
189
  Ok(())
186
190
  }
187
191
 
188
- fn bind_provider_from_env_or_command(command: &str) -> Provider {
189
- std::env::var("TEAM_AGENT_LEADER_PROVIDER")
192
+ fn bind_provider_from_env_or_pane(command: &str, pane: Option<PaneInfo>) -> Provider {
193
+ let env_provider = std::env::var("TEAM_AGENT_LEADER_PROVIDER")
190
194
  .ok()
191
- .and_then(|raw| super::helpers::parse_provider(&raw))
192
- .or_else(|| provider_from_command(command))
195
+ .and_then(|raw| super::helpers::parse_provider(&raw));
196
+ env_provider
197
+ .or_else(|| pane.as_ref().and_then(super::attribute_pane_provider))
198
+ .or_else(|| super::attribute_command_provider(command))
193
199
  // E11 层2:未知命令不再静默默认 codex(会误绑任意 provider + 喂错分类器)。
194
- // 无法识别时回落 Codex 仅作最末兜底,且该路径已被 provider_from_command 的显式 None 收窄
200
+ // 无法识别时回落 Codex 仅作最末兜底,且该路径已被统一归因入口的显式 None 收窄
195
201
  // (调用方理应只在已知 leader 命令上 bind);保留以不改 fn 签名/上游 panic 面。
196
202
  .unwrap_or(Provider::Codex)
197
203
  }
198
204
 
199
- /// E11 层2 + N39:command 名 → wire 串 → `parse_provider`(**单一映射源**,与
200
- /// `owner_bind_provider_wire` 共用 [`command_provider_wire`])。未知命令 → `None`
201
- /// (危险的 `_ => Codex` 默认已删:不静默把任意 provider 误绑成 codex)。
202
- fn provider_from_command(command: &str) -> Option<Provider> {
203
- command_provider_wire(command).and_then(super::helpers::parse_provider)
204
- }
205
-
206
- /// command 名 → provider wire 串(单一真相;copilot/claude/codex/fake)。未知 → `None`。
207
- /// `claude.exe` 归一为 `claude`。
208
- fn command_provider_wire(command: &str) -> Option<&'static str> {
209
- match exact_command_name(command).as_deref() {
210
- Some("claude") | Some("claude.exe") => Some("claude"),
211
- Some("codex") => Some("codex"),
212
- Some("copilot") => Some("copilot"),
213
- Some("fake") => Some("fake"),
214
- _ => None,
215
- }
216
- }
217
-
218
- fn exact_command_name(command: &str) -> Option<String> {
219
- let last = command
220
- .split_whitespace()
221
- .next()
222
- .unwrap_or(command)
223
- .rsplit(['/', '\\'])
224
- .next()?;
225
- let lower = last.to_ascii_lowercase();
226
- if lower.is_empty() { None } else { Some(lower) }
227
- }
228
-
229
205
  pub fn owner_bind_provider_wire(command: &str) -> &'static str {
230
206
  if let Ok(raw) = std::env::var("TEAM_AGENT_LEADER_PROVIDER") {
231
207
  // env 显式 provider:经 parse_provider(单一表,知 copilot)校验后透传其 wire 串;
@@ -234,9 +210,9 @@ pub fn owner_bind_provider_wire(command: &str) -> &'static str {
234
210
  .map(super::helpers::provider_wire)
235
211
  .unwrap_or("");
236
212
  }
237
- // E11 层2 + N39:与 provider_from_command 共用 command_provider_wire 单一映射(含 copilot);
213
+ // E11 层2 + N39:与统一 provider attribution 共用单一映射(含 copilot);
238
214
  // 未知命令 → ""(不绑),不再静默当 codex。
239
- command_provider_wire(command).unwrap_or("")
215
+ super::provider_wire_from_command(command).unwrap_or("")
240
216
  }
241
217
 
242
218
  fn family_a_identity(
@@ -295,6 +271,28 @@ fn tmux_pane_current_command(workspace: &Path, pane: &str) -> Result<String, Lea
295
271
  .map_err(|e| LeaderError::Tmux(e.to_string()))
296
272
  }
297
273
 
274
+ fn tmux_pane_info(workspace: &Path, pane: &str) -> Option<PaneInfo> {
275
+ let target = PaneId::new(pane);
276
+ TmuxBackend::for_workspace(workspace)
277
+ .list_targets()
278
+ .ok()?
279
+ .into_iter()
280
+ .find(|info| info.pane_id == target)
281
+ .or_else(|| tmux_pane_current_command(workspace, pane).ok().map(|command| PaneInfo {
282
+ pane_id: target,
283
+ session: SessionName::new(""),
284
+ window_index: None,
285
+ window_name: None,
286
+ pane_index: None,
287
+ tty: None,
288
+ current_command: (!command.is_empty()).then_some(command),
289
+ current_path: None,
290
+ active: true,
291
+ pane_pid: None,
292
+ leader_env: Default::default(),
293
+ }))
294
+ }
295
+
298
296
  // NOTE: `derive_leader_session_uuid`(`leader_binding.py:146`)已由
299
297
  // `model::ids::LeaderSessionUuid::derive` 字节对齐实现(含 NUL 拒绝 + golden 测试)——
300
298
  // 此 lane REUSE 之,不重声明。
@@ -307,25 +305,25 @@ mod e11_provider_bind_tests {
307
305
  // E11 层2:copilot leader 命令必须绑成 Provider::Copilot(此前缺臂 → _ => Codex 误绑)。
308
306
  #[test]
309
307
  fn copilot_command_binds_copilot_not_codex() {
310
- assert_eq!(provider_from_command("copilot --banner -C /ws"), Some(Provider::Copilot));
311
- assert_eq!(provider_from_command("/opt/homebrew/bin/copilot"), Some(Provider::Copilot));
308
+ assert_eq!(super::super::attribute_command_provider("copilot --banner -C /ws"), Some(Provider::Copilot));
309
+ assert_eq!(super::super::attribute_command_provider("/opt/homebrew/bin/copilot"), Some(Provider::Copilot));
312
310
  assert_eq!(owner_bind_provider_wire("copilot --banner"), "copilot");
313
311
  }
314
312
 
315
313
  #[test]
316
314
  fn known_commands_map_via_single_source() {
317
- assert_eq!(provider_from_command("claude"), Some(Provider::Claude));
318
- assert_eq!(provider_from_command("codex"), Some(Provider::Codex));
319
- assert_eq!(provider_from_command("fake"), Some(Provider::Fake));
315
+ assert_eq!(super::super::attribute_command_provider("claude"), Some(Provider::ClaudeCode));
316
+ assert_eq!(super::super::attribute_command_provider("codex"), Some(Provider::Codex));
317
+ assert_eq!(super::super::attribute_command_provider("fake"), Some(Provider::Fake));
320
318
  assert_eq!(owner_bind_provider_wire("claude"), "claude");
321
319
  assert_eq!(owner_bind_provider_wire("codex"), "codex");
322
320
  }
323
321
 
324
- // E11 层2:未知命令不再静默默认 codex —— provider_from_command → None,wire → ""。
322
+ // E11 层2:未知命令不再静默默认 codex —— attribution → None,wire → ""。
325
323
  #[test]
326
324
  fn unknown_command_is_none_not_silent_codex() {
327
- assert_eq!(provider_from_command("node /some/thing.js"), None);
328
- assert_eq!(provider_from_command("totally-unknown"), None);
325
+ assert_eq!(super::super::attribute_command_provider("node /some/thing.js"), None);
326
+ assert_eq!(super::super::attribute_command_provider("totally-unknown"), None);
329
327
  assert_eq!(owner_bind_provider_wire("totally-unknown"), "");
330
328
  }
331
329
  }
@@ -0,0 +1,214 @@
1
+ //! Positive provider attribution for leader panes.
2
+
3
+ use std::collections::BTreeMap;
4
+ use std::process::Command;
5
+
6
+ use crate::provider::Provider;
7
+ use crate::transport::PaneInfo;
8
+
9
+ use super::helpers::{parse_provider, provider_wire};
10
+
11
+ const PROVIDER_COMMANDS: &[(&str, Provider)] = &[
12
+ ("claude", Provider::ClaudeCode),
13
+ ("claude.exe", Provider::ClaudeCode),
14
+ ("codex", Provider::Codex),
15
+ ("copilot", Provider::Copilot),
16
+ ("gemini", Provider::GeminiCli),
17
+ ("fake", Provider::Fake),
18
+ ];
19
+
20
+ pub(crate) fn attribute_pane_provider(pane: &PaneInfo) -> Option<Provider> {
21
+ attribute_pane_provider_with_process(pane, provider_from_pid_env, provider_from_pid_argv)
22
+ }
23
+
24
+ fn attribute_pane_provider_with_process(
25
+ pane: &PaneInfo,
26
+ provider_from_env_pid: impl Fn(u32) -> Option<Provider>,
27
+ provider_from_argv_pid: impl Fn(u32) -> Option<Provider>,
28
+ ) -> Option<Provider> {
29
+ provider_from_env(&pane.leader_env)
30
+ .or_else(|| pane.pane_pid.and_then(provider_from_env_pid))
31
+ .or_else(|| {
32
+ pane.current_command
33
+ .as_deref()
34
+ .and_then(attribute_command_provider)
35
+ })
36
+ .or_else(|| pane.pane_pid.and_then(provider_from_argv_pid))
37
+ }
38
+
39
+ pub(crate) fn attribute_command_provider(command: &str) -> Option<Provider> {
40
+ provider_from_text(command)
41
+ }
42
+
43
+ pub(crate) fn command_matches_provider(provider: Provider, command: &str) -> bool {
44
+ attribute_command_provider(command)
45
+ .is_some_and(|candidate| provider_matches(candidate, provider))
46
+ }
47
+
48
+ pub(crate) fn provider_wire_from_command(command: &str) -> Option<&'static str> {
49
+ attribute_command_provider(command).map(provider_wire_for_command_attribution)
50
+ }
51
+
52
+ fn provider_from_env(env: &BTreeMap<String, String>) -> Option<Provider> {
53
+ env.get("TEAM_AGENT_LEADER_PROVIDER")
54
+ .filter(|raw| !raw.is_empty())
55
+ .and_then(|raw| parse_provider(raw))
56
+ }
57
+
58
+ fn provider_from_pid_argv(pid: u32) -> Option<Provider> {
59
+ process_command_line(pid)
60
+ .as_deref()
61
+ .and_then(provider_from_text)
62
+ }
63
+
64
+ fn provider_from_pid_env(pid: u32) -> Option<Provider> {
65
+ process_environment(pid)
66
+ .as_deref()
67
+ .and_then(provider_from_env_text)
68
+ }
69
+
70
+ fn provider_from_text(text: &str) -> Option<Provider> {
71
+ let lower = text.to_ascii_lowercase();
72
+ PROVIDER_COMMANDS
73
+ .iter()
74
+ .find_map(|(needle, provider)| lower.contains(needle).then_some(*provider))
75
+ }
76
+
77
+ fn provider_from_env_text(text: &str) -> Option<Provider> {
78
+ text.split(|ch: char| ch == '\0' || ch.is_ascii_whitespace())
79
+ .find_map(|part| part.strip_prefix("TEAM_AGENT_LEADER_PROVIDER="))
80
+ .filter(|raw| !raw.is_empty())
81
+ .and_then(parse_provider)
82
+ }
83
+
84
+ pub(crate) fn provider_matches(candidate: Provider, expected: Provider) -> bool {
85
+ candidate == expected
86
+ || matches!(
87
+ (candidate, expected),
88
+ (Provider::Claude, Provider::ClaudeCode) | (Provider::ClaudeCode, Provider::Claude)
89
+ )
90
+ }
91
+
92
+ fn provider_wire_for_command_attribution(provider: Provider) -> &'static str {
93
+ match provider {
94
+ Provider::Claude | Provider::ClaudeCode => "claude",
95
+ other => provider_wire(other),
96
+ }
97
+ }
98
+
99
+ #[cfg(target_os = "linux")]
100
+ fn process_command_line(pid: u32) -> Option<String> {
101
+ let bytes = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
102
+ let parts = bytes
103
+ .split(|byte| *byte == 0)
104
+ .filter(|part| !part.is_empty())
105
+ .map(|part| String::from_utf8_lossy(part).to_string())
106
+ .collect::<Vec<_>>();
107
+ (!parts.is_empty()).then(|| parts.join(" "))
108
+ }
109
+
110
+ #[cfg(target_os = "linux")]
111
+ fn process_environment(pid: u32) -> Option<String> {
112
+ String::from_utf8(std::fs::read(format!("/proc/{pid}/environ")).ok()?).ok()
113
+ }
114
+
115
+ #[cfg(not(target_os = "linux"))]
116
+ fn process_command_line(pid: u32) -> Option<String> {
117
+ let output = crate::os_probe::bounded_command_output_with_probe(
118
+ Command::new("ps")
119
+ .arg("-p")
120
+ .arg(pid.to_string())
121
+ .args(["-o", "command="]),
122
+ "provider_argv",
123
+ Some(pid),
124
+ )
125
+ .ok()?;
126
+ output
127
+ .status
128
+ .success()
129
+ .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
130
+ .filter(|text| !text.is_empty())
131
+ }
132
+
133
+ #[cfg(not(target_os = "linux"))]
134
+ fn process_environment(pid: u32) -> Option<String> {
135
+ let _ = pid;
136
+ None
137
+ }
138
+
139
+ #[cfg(test)]
140
+ mod tests {
141
+ use super::*;
142
+ use crate::transport::{PaneId, SessionName};
143
+ fn pane(command: &str, pid: Option<u32>) -> PaneInfo {
144
+ PaneInfo {
145
+ pane_id: PaneId::new("%1"),
146
+ session: SessionName::new("team-test"),
147
+ window_index: None,
148
+ window_name: None,
149
+ pane_index: None,
150
+ tty: None,
151
+ current_command: Some(command.to_string()),
152
+ current_path: None,
153
+ active: true,
154
+ pane_pid: pid,
155
+ leader_env: BTreeMap::new(),
156
+ }
157
+ }
158
+
159
+ #[test]
160
+ fn direct_command_form_recognizes_copilot() {
161
+ assert_eq!(
162
+ attribute_pane_provider(&pane("copilot --allow-all-tools", None)),
163
+ Some(Provider::Copilot)
164
+ );
165
+ }
166
+
167
+ #[test]
168
+ fn node_running_non_provider_stays_none() {
169
+ assert_eq!(attribute_pane_provider(&pane("node", None)), None);
170
+ assert_eq!(
171
+ attribute_command_provider("node /tmp/not-a-provider.js"),
172
+ None
173
+ );
174
+ }
175
+
176
+ #[test]
177
+ fn pane_env_provider_wins_over_command() {
178
+ let mut pane = pane("codex", None);
179
+ pane.leader_env.insert(
180
+ "TEAM_AGENT_LEADER_PROVIDER".to_string(),
181
+ "copilot".to_string(),
182
+ );
183
+
184
+ assert_eq!(attribute_pane_provider(&pane), Some(Provider::Copilot));
185
+ }
186
+
187
+ #[test]
188
+ fn node_form_copilot_is_attributed_from_process_argv() {
189
+ let pane = pane("node", Some(4242));
190
+
191
+ assert_eq!(
192
+ attribute_pane_provider_with_process(
193
+ &pane,
194
+ |_| None,
195
+ |pid| (pid == 4242).then_some(Provider::Copilot),
196
+ ),
197
+ Some(Provider::Copilot)
198
+ );
199
+ }
200
+
201
+ #[test]
202
+ fn process_env_provider_wins_for_node_form() {
203
+ let pane = pane("node /opt/homebrew/bin/copilot", Some(4242));
204
+
205
+ assert_eq!(
206
+ attribute_pane_provider_with_process(
207
+ &pane,
208
+ |pid| (pid == 4242).then_some(Provider::Copilot),
209
+ |_| Some(Provider::Codex),
210
+ ),
211
+ Some(Provider::Copilot)
212
+ );
213
+ }
214
+ }
@@ -81,13 +81,13 @@ fn event_named(events: &[Value], name: &str) -> Value {
81
81
  }
82
82
 
83
83
  #[test]
84
- fn leader_command_provider_recognizes_copilot() {
84
+ fn attribute_command_provider_recognizes_copilot() {
85
85
  assert_eq!(
86
- leader_command_provider("copilot --allow-all-tools"),
86
+ super::super::attribute_command_provider("copilot --allow-all-tools"),
87
87
  Some(Provider::Copilot)
88
88
  );
89
89
  assert_eq!(
90
- leader_command_provider("/usr/local/bin/copilot"),
90
+ super::super::attribute_command_provider("/usr/local/bin/copilot"),
91
91
  Some(Provider::Copilot)
92
92
  );
93
93
  }