@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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +33 -0
- package/README.zh.md +30 -0
- package/crates/team-agent/src/cli/emit.rs +8 -1
- package/crates/team-agent/src/cli/mod.rs +2 -1
- package/crates/team-agent/src/cli/route.rs +207 -0
- package/crates/team-agent/src/cli/spec.rs +3 -0
- package/crates/team-agent/src/cli/status_port/brief.rs +224 -33
- package/crates/team-agent/src/leader/start.rs +5 -1
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +2 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +2 -0
- package/crates/team-agent/src/lifecycle/tests/cli_argv_routing_red.rs +1457 -0
- package/crates/team-agent/src/lifecycle/tests.rs +1 -0
- package/crates/team-agent/src/provider/argv_route.rs +409 -0
- package/crates/team-agent/src/provider/mod.rs +3 -2
- package/crates/team-agent/src/provider/model_catalog.rs +177 -2
- package/crates/team-agent/src/tmux_backend.rs +99 -2
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +32 -0
|
@@ -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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
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
|
-
|
|
597
|
+
fields: [&str; 5],
|
|
572
598
|
endpoint: &str,
|
|
573
599
|
registered: &[RegisteredNode],
|
|
600
|
+
processes: &BTreeMap<u32, ProcessSnapshot>,
|
|
574
601
|
) -> Option<ProbeNode> {
|
|
575
|
-
let
|
|
576
|
-
let session =
|
|
577
|
-
let window =
|
|
578
|
-
let pane =
|
|
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
|
|
591
|
-
let snapshot =
|
|
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
|
|
624
|
-
let
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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", ®istered, |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", ®istered, |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", ®istered, |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", ®istered, |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(®istered[0], Some(&failed)).0.health, "unknown");
|
|
1490
|
+
let live = Some(second);
|
|
1491
|
+
assert_eq!(project_node_with_attach(®istered[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", ®istered,
|
|
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)
|