@team-agent/installer 0.5.108 → 0.5.110

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.
@@ -5,7 +5,7 @@
5
5
  //! status brief contract.
6
6
 
7
7
  use serde_json::{json, Map, Value};
8
- use std::collections::BTreeMap;
8
+ use std::collections::{BTreeMap, BTreeSet};
9
9
  use std::path::Path;
10
10
  use std::process::Command;
11
11
 
@@ -548,11 +548,37 @@ fn sample_native_endpoint(
548
548
  registered: &[RegisteredNode],
549
549
  ) -> Option<ProbeResult> {
550
550
  let output = run_native_tmux_list_panes(endpoint)?;
551
- let nodes = output
552
- .lines()
553
- .filter_map(|line| parse_native_pane(line, endpoint, registered))
554
- .collect::<Vec<_>>();
555
- Some(ProbeResult { nodes })
551
+ Some(sample_native_panes(&output, endpoint, registered, process_snapshots))
552
+ }
553
+
554
+ fn sample_native_panes(
555
+ output: &str,
556
+ endpoint: &str,
557
+ registered: &[RegisteredNode],
558
+ snapshots: impl FnOnce(&BTreeSet<u32>) -> BTreeMap<u32, ProcessSnapshot>,
559
+ ) -> ProbeResult {
560
+ let panes = output.lines().filter_map(native_pane_fields).collect::<Vec<_>>();
561
+ let pids = panes.iter().filter_map(|fields| native_pane_pid(fields[3])).collect::<BTreeSet<_>>();
562
+ let processes = if pids.is_empty() { BTreeMap::new() } else { snapshots(&pids) };
563
+ let nodes = panes.into_iter()
564
+ .filter_map(|fields| parse_native_pane(fields, endpoint, registered, &processes))
565
+ .collect();
566
+ ProbeResult { nodes }
567
+ }
568
+
569
+ fn native_pane_fields(line: &str) -> Option<[&str; 5]> {
570
+ let mut fields = line.split('\t');
571
+ let session = fields.next()?;
572
+ let window = fields.next()?;
573
+ let pane = fields.next()?;
574
+ let pid = fields.next().unwrap_or_default();
575
+ let command = fields.next().unwrap_or_default();
576
+ if session.is_empty() || window.is_empty() || pane.is_empty() { return None; }
577
+ Some([session, window, pane, pid, command])
578
+ }
579
+
580
+ fn native_pane_pid(pid: &str) -> Option<u32> {
581
+ pid.parse::<u32>().ok().filter(|pid| *pid > 0 && *pid <= i32::MAX as u32)
556
582
  }
557
583
 
558
584
  fn run_native_tmux_list_panes(endpoint: &str) -> Option<String> {
@@ -568,27 +594,23 @@ fn run_native_tmux_list_panes(endpoint: &str) -> Option<String> {
568
594
  }
569
595
 
570
596
  fn parse_native_pane(
571
- line: &str,
597
+ fields: [&str; 5],
572
598
  endpoint: &str,
573
599
  registered: &[RegisteredNode],
600
+ processes: &BTreeMap<u32, ProcessSnapshot>,
574
601
  ) -> Option<ProbeNode> {
575
- let mut fields = line.split('\t');
576
- let session = fields.next()?.to_string();
577
- let window = fields.next()?.to_string();
578
- let pane = fields.next()?.to_string();
579
- let pid = fields.next().filter(|value| !value.is_empty());
580
- let command = fields.next().unwrap_or_default();
581
- if session.is_empty() || window.is_empty() || pane.is_empty() {
582
- return None;
583
- }
602
+ let [session, window, pane, pid, command] = fields;
603
+ let session = session.to_string();
604
+ let window = window.to_string();
605
+ let pane = pane.to_string();
584
606
  let matched = registered.iter().find(|node| {
585
607
  node.endpoint.as_deref() == Some(endpoint)
586
608
  && node.session.as_deref() == Some(session.as_str())
587
609
  && node.window.as_deref().is_none_or(|value| value == window)
588
610
  && node.pane.as_deref() == Some(pane.as_str())
589
611
  });
590
- if let Some(pid) = pid {
591
- let snapshot = process_snapshot(pid)?;
612
+ if !pid.is_empty() {
613
+ let snapshot = processes.get(&native_pane_pid(pid)?)?;
592
614
  if snapshot.zombie {
593
615
  return None;
594
616
  }
@@ -620,22 +642,35 @@ struct ProcessSnapshot {
620
642
  zombie: bool,
621
643
  }
622
644
 
623
- fn process_snapshot(pid: &str) -> Option<ProcessSnapshot> {
624
- let output = Command::new("ps")
625
- .args(["-o", "pid=,ppid=,stat=,comm=", "-p", pid])
626
- .output()
627
- .ok()?;
628
- if !output.status.success() || String::from_utf8_lossy(&output.stdout).trim().is_empty() {
629
- return None;
645
+ fn process_snapshot_command(pids: &BTreeSet<u32>) -> Command {
646
+ let pids = pids.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
647
+ let mut command = Command::new("ps");
648
+ command.args(["-o", "pid=,ppid=,stat=,comm=", "-p", &pids]);
649
+ command
650
+ }
651
+
652
+ fn process_snapshots(pids: &BTreeSet<u32>) -> BTreeMap<u32, ProcessSnapshot> {
653
+ if pids.is_empty() { return BTreeMap::new(); }
654
+ let Ok(output) = process_snapshot_command(pids).output() else { return BTreeMap::new(); };
655
+ if !output.status.success() { return BTreeMap::new(); }
656
+ parse_process_snapshots(&output.stdout, pids)
657
+ }
658
+
659
+ fn parse_process_snapshots(bytes: &[u8], requested: &BTreeSet<u32>) -> BTreeMap<u32, ProcessSnapshot> {
660
+ let mut snapshots = BTreeMap::<u32, ProcessSnapshot>::new();
661
+ let output = String::from_utf8_lossy(bytes);
662
+ for line in output.lines() {
663
+ let mut fields = line.split_whitespace();
664
+ let Some(pid) = fields.next().and_then(native_pane_pid) else { continue; };
665
+ if !requested.contains(&pid) { continue; }
666
+ let Some(_ppid) = fields.next() else { continue; };
667
+ let Some(stat) = fields.next() else { continue; };
668
+ let zombie = stat.contains('Z');
669
+ snapshots.entry(pid)
670
+ .and_modify(|snapshot| snapshot.zombie |= zombie)
671
+ .or_insert(ProcessSnapshot { zombie });
630
672
  }
631
- let output_text = String::from_utf8_lossy(&output.stdout);
632
- let stat = output_text
633
- .split_whitespace()
634
- .nth(2)
635
- .unwrap_or_default();
636
- Some(ProcessSnapshot {
637
- zombie: stat.contains('Z'),
638
- })
673
+ snapshots
639
674
  }
640
675
 
641
676
  #[cfg(test)]
@@ -1335,6 +1370,162 @@ impl BriefNode {
1335
1370
  }
1336
1371
  }
1337
1372
 
1373
+ #[cfg(test)]
1374
+ mod native_ps_batch_regressions {
1375
+ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1376
+ use super::*;
1377
+
1378
+ fn registered(endpoint: &str, pane: &str) -> RegisteredNode {
1379
+ RegisteredNode {
1380
+ name: format!("{endpoint}-{pane}"),
1381
+ provider: "pi".into(),
1382
+ model: Some("fixture-model".into()),
1383
+ effort: Some("high".into()),
1384
+ endpoint: Some(endpoint.into()),
1385
+ session: Some("hp".into()),
1386
+ window: Some("w".into()),
1387
+ pane: Some(pane.into()),
1388
+ lifecycle: None,
1389
+ }
1390
+ }
1391
+
1392
+ #[test]
1393
+ fn eight_panes_use_one_endpoint_query() {
1394
+ let registered = (0..8).map(|i| registered("e1", &format!("%{i}"))).collect::<Vec<_>>();
1395
+ let panes = (0..8).map(|i| format!("hp\tw\t%{i}\t{}\tpi\n", 100 + i)).collect::<String>();
1396
+ let rows = (0..8).map(|i| format!("{} 1 S pi\n", 100 + i)).collect::<String>();
1397
+ let mut calls = Vec::new();
1398
+ let result = sample_native_panes(&panes, "e1", &registered, |pids| {
1399
+ calls.push(pids.iter().copied().collect::<Vec<_>>());
1400
+ parse_process_snapshots(rows.as_bytes(), pids)
1401
+ });
1402
+ assert_eq!(calls, vec![(100..108).collect::<Vec<_>>()]);
1403
+ assert_eq!(result.nodes.len(), 8);
1404
+ assert!(result.nodes.iter().all(|node| node.provider == "pi" && node.activity == "working" && node.health == "normal"));
1405
+ }
1406
+
1407
+ #[test]
1408
+ fn pid_deduplication_and_command_preserve_the_privacy_whitelist() {
1409
+ let mut calls = Vec::new();
1410
+ let result = sample_native_panes("hp\tw\t%1\t11\tsh\nhp\tw\t%2\t011\tsh\n", "e1", &[], |pids| {
1411
+ calls.push(pids.iter().copied().collect::<Vec<_>>());
1412
+ parse_process_snapshots(b"11 1 S sh\n", pids)
1413
+ });
1414
+ assert_eq!(calls, vec![vec![11]]);
1415
+ assert_eq!(result.nodes.len(), 2);
1416
+ let pids = BTreeSet::from([22, 11]);
1417
+ let command = process_snapshot_command(&pids);
1418
+ assert_eq!(command.get_program(), "ps");
1419
+ assert_eq!(
1420
+ command.get_args().map(|arg| arg.to_string_lossy().into_owned()).collect::<Vec<_>>(),
1421
+ vec!["-o", "pid=,ppid=,stat=,comm=", "-p", "11,22"]
1422
+ );
1423
+ let command = process_snapshot_command(&BTreeSet::from([11]));
1424
+ assert_eq!(command.get_args().last().unwrap(), "11");
1425
+ }
1426
+
1427
+ #[cfg(unix)]
1428
+ #[test]
1429
+ fn native_ps_partial_selection_preserves_a_live_pid() {
1430
+ let pid = std::process::id();
1431
+ let requested = BTreeSet::from([pid, i32::MAX as u32]);
1432
+ let command = process_snapshot_command(&requested);
1433
+ // Absolute ps avoids unrelated PATH-fixture mutation in parallel unit tests.
1434
+ let output = Command::new("/bin/ps").args(command.get_args()).output().unwrap();
1435
+ assert!(output.status.success(), "partial PID selection must not poison the live row");
1436
+ let snapshots = parse_process_snapshots(&output.stdout, &requested);
1437
+ assert!(!snapshots.get(&pid).unwrap().zombie);
1438
+ assert!(!snapshots.contains_key(&(i32::MAX as u32)));
1439
+ }
1440
+
1441
+ #[test]
1442
+ fn single_pane_keeps_its_native_health_and_activity() {
1443
+ let registered = vec![registered("e1", "%1")];
1444
+ let result = sample_native_panes("hp\tw\t%1\t11\tsh\n", "e1", &registered, |pids| {
1445
+ assert_eq!(pids, &BTreeSet::from([11]));
1446
+ parse_process_snapshots(b"11 1 S sh\n", pids)
1447
+ });
1448
+ assert_eq!(result.nodes.len(), 1);
1449
+ assert_eq!(result.nodes[0].provider, "pi");
1450
+ assert_eq!(result.nodes[0].activity, "idle");
1451
+ assert_eq!(result.nodes[0].health, "normal");
1452
+ }
1453
+
1454
+ #[test]
1455
+ fn missing_rows_and_zombies_omit_only_their_panes() {
1456
+ let panes = "hp\tw\t%1\t11\tsh\nhp\tw\t%2\t22\tpi\nhp\tw\t%3\t33\tpi\nhp\tw\t%4\t\tsh\n";
1457
+ let result = sample_native_panes(panes, "e1", &[], |pids| {
1458
+ parse_process_snapshots(b"22 1 Z+ dead\n11 1 S sh\n999 1 S unrelated\n33 1\n", pids)
1459
+ });
1460
+ assert_eq!(result.nodes.iter().map(|node| node.pane.as_str()).collect::<Vec<_>>(), vec!["%1", "%4"]);
1461
+ assert!(result.nodes.iter().all(|node| node.activity == "idle" && node.health == "normal"));
1462
+ }
1463
+
1464
+ #[test]
1465
+ fn empty_or_invalid_pid_sets_do_not_run_ps() {
1466
+ let panes = "hp\tw\t%1\t\tsh\nhp\tw\t%2\t0\tsh\nhp\tw\t%3\t1,2\tsh\nhp\tw\t%4\t4294967295\tsh\n\tw\t%5\t99\tsh\n";
1467
+ let result = sample_native_panes(panes, "e1", &[], |_| panic!("no valid PID means no ps query"));
1468
+ assert_eq!(result.nodes.len(), 1);
1469
+ assert_eq!(result.nodes[0].pane, "%1");
1470
+ assert_eq!(result.nodes[0].health, "normal");
1471
+ }
1472
+
1473
+ #[test]
1474
+ fn snapshot_failure_and_endpoint_results_are_not_shared() {
1475
+ let registered = vec![registered("e1", "%1"), registered("e2", "%1")];
1476
+ let mut calls = Vec::new();
1477
+ let first = sample_native_panes("hp\tw\t%1\t88\tsh\n", "e1", &registered, |pids| {
1478
+ calls.push(("e1", pids.iter().copied().collect::<Vec<_>>()));
1479
+ BTreeMap::new()
1480
+ });
1481
+ let second = sample_native_panes("hp\tw\t%1\t88\tsh\n", "e2", &registered, |pids| {
1482
+ calls.push(("e2", pids.iter().copied().collect::<Vec<_>>()));
1483
+ parse_process_snapshots(b"88 1 S sh\n", pids)
1484
+ });
1485
+ assert_eq!(calls, vec![("e1", vec![88]), ("e2", vec![88])]);
1486
+ assert!(first.nodes.is_empty());
1487
+ assert_eq!(second.nodes.len(), 1);
1488
+ let failed = Some(first);
1489
+ assert_eq!(project_node_with_attach(&registered[0], Some(&failed)).0.health, "unknown");
1490
+ let live = Some(second);
1491
+ assert_eq!(project_node_with_attach(&registered[1], Some(&live)).0.health, "normal");
1492
+ }
1493
+
1494
+ #[test]
1495
+ fn duplicate_process_rows_cannot_hide_a_zombie() {
1496
+ let requested = BTreeSet::from([11]);
1497
+ for rows in [b"11 1 Z dead\n11 1 S sh\n".as_slice(), b"11 1 S sh\n11 1 Z dead\n".as_slice()] {
1498
+ assert!(parse_process_snapshots(rows, &requested).get(&11).unwrap().zombie);
1499
+ }
1500
+ assert!(parse_process_snapshots(b"not a process row\n", &requested).is_empty());
1501
+ }
1502
+
1503
+ #[test]
1504
+ fn json_and_human_keep_nine_fields_without_ps_details() {
1505
+ let registered = vec![registered("e1", "%1"), registered("e1", "%2")];
1506
+ let probe = Some(sample_native_panes(
1507
+ "hp\tw\t%1\t11\tsh\nhp\tw\t%2\t22\tpi\n", "e1", &registered,
1508
+ |pids| parse_process_snapshots(b"22 1 R PRIVATE_COMMAND_DETAIL\n11 1 S PRIVATE_COMMAND_DETAIL\n", pids),
1509
+ ));
1510
+ let projected = registered.iter().map(|node| project_node_with_attach(node, Some(&probe))).collect::<Vec<_>>();
1511
+ let keys = BTreeSet::from(["name", "provider", "model", "effort", "runtime_status", "activity", "health", "session_name", "tmux_command"]);
1512
+ for (node, _) in &projected {
1513
+ let json = node.clone().into_json();
1514
+ assert_eq!(json.as_object().unwrap().keys().map(String::as_str).collect::<BTreeSet<_>>(), keys);
1515
+ assert_eq!(json["model"], "fixture-model");
1516
+ assert_eq!(json["effort"], "high");
1517
+ assert_eq!(json["runtime_status"], "running");
1518
+ assert_eq!(json["health"], "normal");
1519
+ assert!(!json.to_string().contains("PRIVATE_COMMAND_DETAIL"));
1520
+ }
1521
+ let human = format_multi_node_brief(projected);
1522
+ let mut lines = human.lines().skip_while(|line| !line.starts_with("NAME\t"));
1523
+ assert_eq!(lines.next().unwrap().split('\t').count(), 9);
1524
+ assert!(lines.all(|line| line.split('\t').count() == 9));
1525
+ assert!(!human.contains("PRIVATE_COMMAND_DETAIL"));
1526
+ }
1527
+ }
1528
+
1338
1529
  #[cfg(test)]
1339
1530
  mod tests {
1340
1531
  #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
@@ -291,7 +291,7 @@ fn leader_start_plan_with_ambient_authority(
291
291
  ),
292
292
  _ => None,
293
293
  };
294
- let provider_argv = if provider == Provider::Pi && mode != LeaderStartMode::AttachExisting {
294
+ let mut provider_argv = if provider == Provider::Pi && mode != LeaderStartMode::AttachExisting {
295
295
  let parsed = crate::lifecycle::launch::pi_mcp::parse_pi_leader_args(provider_args)
296
296
  .map_err(|error| LeaderError::Start(error.to_string()))?;
297
297
  let mcp_config = adapter
@@ -329,6 +329,10 @@ fn leader_start_plan_with_ambient_authority(
329
329
  } else {
330
330
  provider_command_argv(provider, provider_args)
331
331
  };
332
+ if mode != LeaderStartMode::AttachExisting {
333
+ crate::provider::argv_route::apply(provider, &mut provider_argv)
334
+ .map_err(|error| LeaderError::Start(error.to_string()))?;
335
+ }
332
336
  let argv = start_argv(
333
337
  mode,
334
338
  provider,
@@ -330,6 +330,8 @@ pub(super) fn spawn_agents(
330
330
  ensure_grok_login_and_folder_trust(workspace)?;
331
331
  apply_grok_mcp_overlay(workspace, &mcp_config)?;
332
332
  }
333
+ crate::provider::argv_route::apply(provider, &mut plan.argv)
334
+ .map_err(|error| LifecycleError::Provider(error.to_string()))?;
333
335
  let spawn_epoch = u64::try_from(started.len()).unwrap_or(u64::MAX);
334
336
  let spawned_at = spawn_timestamp_for_agent(u32::try_from(spawn_epoch).unwrap_or(u32::MAX));
335
337
  // E6 层1 实证3 + 诊断留痕:落最终 worker argv(spawn 前的真实形态)。
@@ -516,6 +516,8 @@ pub(super) fn spawn_agent_window(
516
516
  // Keep the launcher-known typed provider independent from inherited
517
517
  // LEADER_* identity. The tmux wrapper binds pane/socket at invocation time.
518
518
  crate::layout::worker_env::inject_current_caller_provider(&mut env, provider);
519
+ crate::provider::argv_route::apply(provider, &mut plan.argv)
520
+ .map_err(|error| LifecycleError::Provider(error.to_string()))?;
519
521
 
520
522
  // 0.4.6 Stage 2: write actual spawn plan event BEFORE invoking the
521
523
  // transport spawn. Mirrors `launch.rs:359-380` (the reference impl)