@team-agent/installer 0.5.113 → 0.5.114

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 (46) hide show
  1. package/Cargo.lock +252 -84
  2. package/Cargo.toml +3 -1
  3. package/crates/team-agent/Cargo.toml +3 -0
  4. package/crates/team-agent/src/cli/adapters.rs +28 -15
  5. package/crates/team-agent/src/cli/diagnose.rs +36 -13
  6. package/crates/team-agent/src/cli/emit.rs +55 -20
  7. package/crates/team-agent/src/cli/leader.rs +16 -0
  8. package/crates/team-agent/src/cli/leader_prompt.rs +261 -0
  9. package/crates/team-agent/src/cli/mod.rs +152 -8
  10. package/crates/team-agent/src/cli/models.rs +68 -18
  11. package/crates/team-agent/src/cli/spec.rs +7 -1
  12. package/crates/team-agent/src/cli/status_port/brief.rs +268 -101
  13. package/crates/team-agent/src/cli/tests/base.rs +88 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +2 -0
  15. package/crates/team-agent/src/compiler.rs +66 -18
  16. package/crates/team-agent/src/contract_runtime/backend.rs +646 -0
  17. package/crates/team-agent/src/contract_runtime/config.rs +309 -0
  18. package/crates/team-agent/src/contract_runtime/discovery.rs +237 -0
  19. package/crates/team-agent/src/contract_runtime/forward.rs +229 -0
  20. package/crates/team-agent/src/contract_runtime/framework.rs +261 -0
  21. package/crates/team-agent/src/contract_runtime/mcp.rs +396 -0
  22. package/crates/team-agent/src/contract_runtime/mod.rs +14 -0
  23. package/crates/team-agent/src/contract_runtime/prompt.rs +216 -0
  24. package/crates/team-agent/src/contract_runtime/pump.rs +207 -0
  25. package/crates/team-agent/src/contract_runtime/registry.rs +30 -0
  26. package/crates/team-agent/src/coordinator/tick.rs +26 -8
  27. package/crates/team-agent/src/leader/mod.rs +1 -0
  28. package/crates/team-agent/src/leader/prompt.rs +906 -0
  29. package/crates/team-agent/src/leader/start.rs +260 -16
  30. package/crates/team-agent/src/leader/tests/mod.rs +2 -0
  31. package/crates/team-agent/src/leader/tests/prompt_contract.rs +268 -0
  32. package/crates/team-agent/src/leader/types.rs +6 -0
  33. package/crates/team-agent/src/lib.rs +4 -0
  34. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +472 -388
  35. package/crates/team-agent/src/lifecycle/launch/spawn.rs +38 -9
  36. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +17 -0
  37. package/crates/team-agent/src/lifecycle/launch.rs +39 -28
  38. package/crates/team-agent/src/lifecycle/types.rs +4 -1
  39. package/crates/team-agent/src/lifecycle/worker_command_context.rs +37 -27
  40. package/crates/team-agent/src/main.rs +6 -0
  41. package/crates/team-agent/src/messaging/delivery.rs +50 -1
  42. package/crates/team-agent/src/messaging/send.rs +10 -0
  43. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +131 -0
  44. package/crates/team-agent/src/model/spec.rs +24 -12
  45. package/crates/team-agent/src/state/persist.rs +73 -0
  46. package/package.json +4 -4
@@ -23,7 +23,7 @@ use crate::provider::{get_adapter, Provider};
23
23
  use crate::tmux_backend::TmuxBackend;
24
24
  use crate::transport::{
25
25
  CaptureRange, PaneId, PaneInfo, PaneLiveness, SessionName, SpawnResult, Target, Transport,
26
- WindowName,
26
+ TransportError, WindowName,
27
27
  };
28
28
 
29
29
  use super::helpers::{
@@ -291,6 +291,13 @@ fn leader_start_plan_with_ambient_authority(
291
291
  ),
292
292
  _ => None,
293
293
  };
294
+ let configured_prompt = if mode == LeaderStartMode::AttachExisting {
295
+ None
296
+ } else {
297
+ super::prompt::load().map_err(|error| error.for_provider(provider))?
298
+ };
299
+ let mut pi_routed_argv = None;
300
+ let mut session_capture = None;
294
301
  let mut provider_argv = if provider == Provider::Pi && mode != LeaderStartMode::AttachExisting {
295
302
  let parsed = crate::lifecycle::launch::pi_mcp::parse_pi_leader_args(provider_args)
296
303
  .map_err(|error| LeaderError::Start(error.to_string()))?;
@@ -303,9 +310,23 @@ fn leader_start_plan_with_ambient_authority(
303
310
  "leader",
304
311
  identity.team_id.as_str(),
305
312
  );
306
- let prompt = crate::lifecycle::worker_command_context::compile_pi_leader_system_prompt();
313
+ let contract = crate::lifecycle::worker_command_context::compile_pi_leader_system_prompt();
314
+ let prompt = if let Some(stored) = configured_prompt.as_ref() {
315
+ // Read route once and validate conflicts before creating any Pi MCP artifacts.
316
+ let mut routed = vec!["pi".to_string()];
317
+ crate::provider::argv_route::apply(provider, &mut routed)
318
+ .map_err(|error| LeaderError::Start(error.to_string()))?;
319
+ let combined = super::prompt::compose_pi_contract(
320
+ &contract, &routed, &stored.text, &stored.path,
321
+ )?;
322
+ pi_routed_argv = Some(routed);
323
+ combined
324
+ } else {
325
+ contract
326
+ };
307
327
  let team_mcp_tools = ["send_message", "report_result", "get_team_status"];
308
- crate::lifecycle::launch::pi_mcp::materialize_pi_plan(
328
+ let spawned_at = chrono::Utc::now().to_rfc3339();
329
+ let command = crate::lifecycle::launch::pi_mcp::materialize_pi_plan(
309
330
  crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
310
331
  workspace,
311
332
  team_id: identity.team_id.as_str(),
@@ -322,16 +343,32 @@ fn leader_start_plan_with_ambient_authority(
322
343
  ),
323
344
  },
324
345
  )
325
- .map_err(|error| LeaderError::Start(error.to_string()))?
326
- .argv
346
+ .map_err(|error| LeaderError::Start(error.to_string()))?;
347
+ session_capture = Some(crate::provider::CaptureSessionContext {
348
+ agent_id: "leader".into(),
349
+ spawn_cwd: resolve_workspace_for_hash(workspace),
350
+ pane_id: None,
351
+ pane_pid: None,
352
+ spawned_at: Some(spawned_at),
353
+ expected_session_id: command.expected_session_id,
354
+ provider_projects_root: command.provider_projects_root,
355
+ });
356
+ command.argv
327
357
  } else if provider == Provider::Pi {
328
358
  Vec::new()
329
359
  } else {
330
360
  provider_command_argv(provider, provider_args)
331
361
  };
332
362
  if mode != LeaderStartMode::AttachExisting {
333
- crate::provider::argv_route::apply(provider, &mut provider_argv)
334
- .map_err(|error| LeaderError::Start(error.to_string()))?;
363
+ if let Some(routed) = pi_routed_argv {
364
+ provider_argv.splice(1..1, routed.into_iter().skip(1));
365
+ } else {
366
+ crate::provider::argv_route::apply(provider, &mut provider_argv)
367
+ .map_err(|error| LeaderError::Start(error.to_string()))?;
368
+ if let Some(stored) = configured_prompt.as_ref() {
369
+ super::prompt::inject(provider, &mut provider_argv, &stored.text, &stored.path)?;
370
+ }
371
+ }
335
372
  }
336
373
  let argv = start_argv(
337
374
  mode,
@@ -361,6 +398,10 @@ fn leader_start_plan_with_ambient_authority(
361
398
  session_name: plan_session_name,
362
399
  argv,
363
400
  provider_argv,
401
+ leader_prompt: configured_prompt
402
+ .as_ref()
403
+ .map(|stored| stored.metadata(provider)),
404
+ session_capture,
364
405
  // 0.3.28 Step 2: leader window inside the dedicated leader session is
365
406
  // named after the provider wire (e.g. `claude_code`), never the
366
407
  // literal string `leader`. Occupied names get a per-launch suffix.
@@ -1345,7 +1386,7 @@ fn ensure_managed_leader_pane(
1345
1386
  &env_unset,
1346
1387
  provider_label,
1347
1388
  )
1348
- .map_err(|error| LeaderError::Start(error.to_string()))
1389
+ .map_err(|error| prompt_aware_spawn_error(plan, error))
1349
1390
  } else {
1350
1391
  let env_unset = leader_env_unset_for_provider(plan.provider);
1351
1392
  let provider_label = provider_command_name(plan.provider);
@@ -1359,8 +1400,35 @@ fn ensure_managed_leader_pane(
1359
1400
  &env_unset,
1360
1401
  provider_label,
1361
1402
  )
1362
- .map_err(|error| LeaderError::Start(error.to_string()))
1403
+ .map_err(|error| prompt_aware_spawn_error(plan, error))
1404
+ }
1405
+ }
1406
+
1407
+ fn prompt_aware_spawn_error(plan: &LeaderStartPlan, error: TransportError) -> LeaderError {
1408
+ let Some(metadata) = plan.leader_prompt.as_ref() else {
1409
+ return LeaderError::Start(error.to_string());
1410
+ };
1411
+ // Transport subprocess errors contain the full tmux shell argv. Preserve
1412
+ // real exit/I/O evidence without exposing the process-level prompt payload.
1413
+ let mut safe = super::prompt::PromptError::new(
1414
+ "leader_prompt_carrier_failed",
1415
+ "Leader prompt-bearing transport spawn failed; argv and stderr withheld",
1416
+ Some(&metadata.config_path),
1417
+ )
1418
+ .for_provider(plan.provider);
1419
+ match error {
1420
+ TransportError::Subprocess { code, .. } => safe.exit_code = code,
1421
+ TransportError::Spawn { source, .. } | TransportError::Inject { source, .. }
1422
+ | TransportError::Capture { source } | TransportError::Io(source) => {
1423
+ safe.io_kind = Some(format!("{:?}", source.kind()));
1424
+ }
1425
+ oversized @ TransportError::CommandTooLong { .. } => {
1426
+ safe.error = oversized.to_string();
1427
+ }
1428
+ _ => {}
1363
1429
  }
1430
+ safe.action = "Inspect the owned launcher and transport metadata; do not repeat or change carriers to bypass the failure";
1431
+ LeaderError::Prompt(safe)
1364
1432
  }
1365
1433
 
1366
1434
  struct ManagedAttachWatch<'a> {
@@ -1740,6 +1808,25 @@ fn managed_spawned_pane_in_targets(transport: &dyn Transport, spawned: &SpawnRes
1740
1808
  })
1741
1809
  }
1742
1810
 
1811
+ /// Keep the launcher's native identity with the canonical pane. The backing
1812
+ /// JSONL may not exist until Pi's first turn; receipt observation resolves it by
1813
+ /// exact header, never by filename/mtime or the Team Agent owner UUID.
1814
+ fn bind_plan_session(receiver: &mut serde_json::Value, plan: &LeaderStartPlan) {
1815
+ if let Some(capture) = &plan.session_capture {
1816
+ let native = serde_json::json!({
1817
+ "expected_session_id": capture.expected_session_id,
1818
+ "provider_projects_root": capture.provider_projects_root,
1819
+ "spawn_cwd": capture.spawn_cwd,
1820
+ "spawned_at": capture.spawned_at,
1821
+ "pane_id": receiver.get("pane_id"),
1822
+ "tmux_socket": receiver.get("tmux_socket"),
1823
+ "leader_session_uuid": receiver.get("leader_session_uuid"),
1824
+ "owner_epoch": receiver.get("owner_epoch"),
1825
+ });
1826
+ receiver["native_session"] = native;
1827
+ }
1828
+ }
1829
+
1743
1830
  fn persist_managed_leader_binding(
1744
1831
  plan: &LeaderStartPlan,
1745
1832
  workspace: &Path,
@@ -1855,7 +1942,7 @@ fn persist_managed_leader_binding(
1855
1942
  } else {
1856
1943
  serde_json::json!("managed_launcher")
1857
1944
  };
1858
- let receiver = serde_json::json!({
1945
+ let mut receiver = serde_json::json!({
1859
1946
  "mode": "direct_tmux",
1860
1947
  "status": "attached",
1861
1948
  "provider": provider.clone(),
@@ -1870,6 +1957,15 @@ fn persist_managed_leader_binding(
1870
1957
  "discovery": discovery,
1871
1958
  "bound_panes": bound_panes,
1872
1959
  });
1960
+ if keep_existing_scalar {
1961
+ for key in ["native_session", "rollout_path"] {
1962
+ if let Some(value) = existing_recv.as_ref().and_then(|old| old.get(key)) {
1963
+ receiver[key] = value.clone();
1964
+ }
1965
+ }
1966
+ } else {
1967
+ bind_plan_session(&mut receiver, plan);
1968
+ }
1873
1969
  let owner_uuid = if keep_existing_scalar {
1874
1970
  keep_field(
1875
1971
  existing_owner.as_ref(),
@@ -2028,6 +2124,7 @@ fn persist_exec_provider_leader_binding(
2028
2124
  }
2029
2125
  obj.insert("tmux_socket".to_string(), serde_json::json!(socket));
2030
2126
  }
2127
+ bind_plan_session(&mut receiver, plan);
2031
2128
  let owner = serde_json::json!({
2032
2129
  "pane_id": pane,
2033
2130
  "provider": provider.clone(),
@@ -2421,6 +2518,11 @@ fn run_leader_argv(
2421
2518
  diagnostics_path.display()
2422
2519
  ))
2423
2520
  })?;
2521
+ // Captured transport diagnostics can echo the shell argv carrying the
2522
+ // prompt. Do not tee those bytes before redaction. Native ExecProvider
2523
+ // business streams and inherited TTY stdout keep their existing behavior.
2524
+ let withhold_transport_output = plan.leader_prompt.is_some()
2525
+ && plan.mode != LeaderStartMode::ExecProvider;
2424
2526
  let stdout_reader = child.stdout.take().map(|mut child_stdout| {
2425
2527
  std::thread::spawn(move || {
2426
2528
  let mut captured = Vec::new();
@@ -2432,7 +2534,9 @@ fn run_leader_argv(
2432
2534
  if read == 0 {
2433
2535
  break;
2434
2536
  }
2435
- let _ = std::io::stdout().write_all(&buf[..read]);
2537
+ if !withhold_transport_output {
2538
+ let _ = std::io::stdout().write_all(&buf[..read]);
2539
+ }
2436
2540
  captured.extend_from_slice(&buf[..read]);
2437
2541
  }
2438
2542
  captured
@@ -2449,7 +2553,9 @@ fn run_leader_argv(
2449
2553
  if read == 0 {
2450
2554
  break;
2451
2555
  }
2452
- let _ = std::io::stderr().write_all(&buf[..read]);
2556
+ if !withhold_transport_output {
2557
+ let _ = std::io::stderr().write_all(&buf[..read]);
2558
+ }
2453
2559
  captured.extend_from_slice(&buf[..read]);
2454
2560
  }
2455
2561
  captured
@@ -2487,8 +2593,8 @@ fn run_leader_argv(
2487
2593
  .code()
2488
2594
  .map(|code| code.to_string())
2489
2595
  .unwrap_or_else(|| "signal".to_string()),
2490
- crate::redaction::redact_external_text(&stdout),
2491
- crate::redaction::redact_external_text(&stderr),
2596
+ prompt_aware_diagnostic_text(plan, &stdout),
2597
+ prompt_aware_diagnostic_text(plan, &stderr),
2492
2598
  ),
2493
2599
  )
2494
2600
  .map_err(|error| {
@@ -2497,6 +2603,11 @@ fn run_leader_argv(
2497
2603
  diagnostics_path.display()
2498
2604
  ))
2499
2605
  })?;
2606
+ let stderr = if plan.leader_prompt.is_some() {
2607
+ prompt_aware_diagnostic_text(plan, &stderr)
2608
+ } else {
2609
+ stderr
2610
+ };
2500
2611
  Ok(LeaderProcessExit {
2501
2612
  status,
2502
2613
  stderr,
@@ -2591,6 +2702,14 @@ fn launcher_diagnostics_path(workspace: &Path) -> PathBuf {
2591
2702
  ))
2592
2703
  }
2593
2704
 
2705
+ fn prompt_aware_diagnostic_text(plan: &LeaderStartPlan, text: &str) -> String {
2706
+ if plan.leader_prompt.is_some() {
2707
+ "[withheld: Leader prompt-bearing launch]".to_string()
2708
+ } else {
2709
+ crate::redaction::redact_external_text(text)
2710
+ }
2711
+ }
2712
+
2594
2713
  fn write_launcher_diagnostics_header(
2595
2714
  path: &Path,
2596
2715
  plan: &LeaderStartPlan,
@@ -2599,13 +2718,22 @@ fn write_launcher_diagnostics_header(
2599
2718
  if let Some(parent) = path.parent() {
2600
2719
  std::fs::create_dir_all(parent)?;
2601
2720
  }
2721
+ let metadata = plan
2722
+ .leader_prompt
2723
+ .as_ref()
2724
+ .map(serde_json::to_string)
2725
+ .transpose()
2726
+ .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?
2727
+ .map(|metadata| format!("leader_prompt={metadata}\n"))
2728
+ .unwrap_or_default();
2602
2729
  std::fs::write(
2603
2730
  path,
2604
2731
  format!(
2605
- "startup_stage=prepare\nmode={:?}\nprovider={}\nargv={}\n",
2732
+ "startup_stage=prepare\nmode={:?}\nprovider={}\nargv={}\n{}",
2606
2733
  plan.mode,
2607
2734
  provider_wire(plan.provider),
2608
- crate::redaction::redact_external_text(&argv.join(" ")),
2735
+ prompt_aware_diagnostic_text(plan, &argv.join(" ")),
2736
+ metadata,
2609
2737
  ),
2610
2738
  )
2611
2739
  }
@@ -3345,6 +3473,8 @@ mod tests {
3345
3473
  session_name: None,
3346
3474
  argv: vec!["sh".to_string(), "-c".to_string(), command],
3347
3475
  provider_argv: vec!["codex".to_string()],
3476
+ leader_prompt: None,
3477
+ session_capture: None,
3348
3478
  leader_window: None,
3349
3479
  is_external_leader: true,
3350
3480
  leader_env: BTreeMap::new(),
@@ -3402,6 +3532,8 @@ mod tests {
3402
3532
  session_name: None,
3403
3533
  argv: vec!["sh".to_string(), "-c".to_string(), command],
3404
3534
  provider_argv: vec!["fake".to_string()],
3535
+ leader_prompt: None,
3536
+ session_capture: None,
3405
3537
  leader_window: None,
3406
3538
  is_external_leader: false,
3407
3539
  leader_env: BTreeMap::new(),
@@ -3931,6 +4063,8 @@ mod tests {
3931
4063
  session_name: Some(SessionName::new("team-agent-leader-claude_code-demo")),
3932
4064
  argv: vec!["tmux".to_string(), "attach-session".to_string()],
3933
4065
  provider_argv: vec!["claude".to_string()],
4066
+ leader_prompt: None,
4067
+ session_capture: None,
3934
4068
  leader_window: Some(WindowName::new("claude_code")),
3935
4069
  is_external_leader: false,
3936
4070
  leader_env: BTreeMap::new(),
@@ -3939,6 +4073,74 @@ mod tests {
3939
4073
  }
3940
4074
  }
3941
4075
 
4076
+ #[cfg(unix)]
4077
+ #[test]
4078
+ fn leader_prompt_transport_capture_hides_argv_echo_without_muting_native_exec() {
4079
+ const TEST: &str = "leader::start::tests::leader_prompt_transport_capture_hides_argv_echo_without_muting_native_exec";
4080
+ const KEY: &str = "TEAM_AGENT_TEST_PROMPT_CAPTURE_CASE";
4081
+ const BODY: &str = "SENSITIVE_PROMPT_309_CAPTURE";
4082
+ if let Ok(case) = std::env::var(KEY) {
4083
+ let root = std::env::temp_dir().join(format!("ta-prompt-capture-{}-{case}", std::process::id()));
4084
+ std::fs::create_dir_all(&root).unwrap();
4085
+ let mut plan = persist_test_plan(&root);
4086
+ plan.mode = if case == "exec_configured" { LeaderStartMode::ExecProvider } else { LeaderStartMode::NewTmuxSession };
4087
+ if case != "transport_absent" {
4088
+ plan.leader_prompt = Some(crate::leader::prompt::PromptMetadata {
4089
+ config_path: root.join("leader-prompt.txt"), carrier: "--append-system-prompt", bytes: BODY.len(),
4090
+ });
4091
+ }
4092
+ // Controlled launcher echoes its received argv and fails; no native Agent/Team.
4093
+ let argv = vec!["/bin/sh".to_string(), "-c".to_string(),
4094
+ "printf '%s\\n' \"$1\"; printf '%s\\n' \"$1\" >&2; exit 23".to_string(), "launcher".to_string(), BODY.to_string()];
4095
+ let outcome = super::run_leader_argv(&argv, &BTreeMap::new(), &plan, &root, None).unwrap();
4096
+ assert_eq!(outcome.status.code(), Some(23));
4097
+ if plan.leader_prompt.is_some() { assert!(!outcome.stderr.contains(BODY)); }
4098
+ std::fs::remove_dir_all(root).unwrap();
4099
+ return;
4100
+ }
4101
+ for case in ["transport_configured", "transport_absent", "exec_configured"] {
4102
+ let output = std::process::Command::new(std::env::current_exe().unwrap())
4103
+ .args(["--exact", TEST, "--nocapture", "--test-threads=1"])
4104
+ .env(KEY, case).env_remove("TMUX").env_remove("TMUX_PANE")
4105
+ .output().unwrap();
4106
+ assert!(output.status.success(), "controlled child failed: {:?} {}", output.status, String::from_utf8_lossy(&output.stderr));
4107
+ for bytes in [&output.stdout, &output.stderr] {
4108
+ assert_eq!(String::from_utf8_lossy(bytes).contains(BODY), case != "transport_configured", "capture policy differs for {case}");
4109
+ }
4110
+ }
4111
+ }
4112
+
4113
+ #[test]
4114
+ fn leader_prompt_diagnostics_hide_payload_but_keep_unconfigured_baseline_and_native_cause() {
4115
+ let root = std::env::temp_dir().join(format!("ta-prompt-diagnostics-{}", std::process::id()));
4116
+ std::fs::create_dir_all(&root).unwrap();
4117
+ let path = root.join("launcher.log");
4118
+ let mut plan = persist_test_plan(&root);
4119
+ let plain = vec!["tmux".to_string(), "attach-session".to_string()];
4120
+ super::write_launcher_diagnostics_header(&path, &plan, &plain).unwrap();
4121
+ assert_eq!(std::fs::read_to_string(&path).unwrap(),
4122
+ "startup_stage=prepare\nmode=ManagedTmuxClient\nprovider=claude_code\nargv=tmux attach-session\n");
4123
+ const BODY: &str = "LEADER_ONLY_SENSITIVE_309";
4124
+ plan.leader_prompt = Some(crate::leader::prompt::PromptMetadata {
4125
+ config_path: root.join("leader-prompt.txt"), carrier: "--append-system-prompt", bytes: BODY.len(),
4126
+ });
4127
+ let shell = vec!["tmux".to_string(), "new-session".to_string(),
4128
+ format!("claude --append-system-prompt '{BODY}'")];
4129
+ super::write_launcher_diagnostics_header(&path, &plan, &shell).unwrap();
4130
+ let header = std::fs::read_to_string(&path).unwrap();
4131
+ assert!(!header.contains(BODY));
4132
+ assert!(header.contains("leader_prompt={") && header.contains("--append-system-prompt"));
4133
+ assert!(!super::prompt_aware_diagnostic_text(&plan, BODY).contains(BODY));
4134
+ let error = super::prompt_aware_spawn_error(&plan, TransportError::Subprocess {
4135
+ argv: shell, code: Some(7), stderr: BODY.to_string(),
4136
+ });
4137
+ let crate::leader::LeaderError::Prompt(error) = error else { panic!("typed carrier failure required") };
4138
+ assert_eq!(error.reason, "leader_prompt_carrier_failed");
4139
+ assert_eq!(error.exit_code, Some(7));
4140
+ assert!(!error.report().to_string().contains(BODY));
4141
+ std::fs::remove_dir_all(root).unwrap();
4142
+ }
4143
+
3942
4144
  #[test]
3943
4145
  fn multi_leader_window_keeps_base_when_free() {
3944
4146
  let window =
@@ -4020,6 +4222,48 @@ mod tests {
4020
4222
  let _ = std::fs::remove_dir_all(&workspace);
4021
4223
  }
4022
4224
 
4225
+ #[test]
4226
+ fn pi_managed_leader_persists_native_identity_with_the_canonical_pane() {
4227
+ let workspace = std::env::temp_dir().join(format!(
4228
+ "ta-pi-leader-native-binding-{}", std::process::id()
4229
+ ));
4230
+ std::fs::create_dir_all(workspace.join(".team/runtime")).unwrap();
4231
+ let mut plan = persist_test_plan(&workspace);
4232
+ plan.provider = Provider::Pi;
4233
+ plan.session_capture = Some(crate::provider::CaptureSessionContext {
4234
+ agent_id: "leader".into(),
4235
+ spawn_cwd: workspace.clone(),
4236
+ pane_id: None,
4237
+ pane_pid: None,
4238
+ spawned_at: Some("2026-10-10T03:59:07Z".into()),
4239
+ expected_session_id: Some(crate::provider::SessionId::new("native-first")),
4240
+ provider_projects_root: Some(workspace.join("pi-sessions")),
4241
+ });
4242
+ let mut pane = SpawnResult {
4243
+ pane_id: PaneId::new("%0"),
4244
+ session: SessionName::new("team-agent-leader-pi-demo"),
4245
+ window: WindowName::new("pi"),
4246
+ child_pid: Some(1),
4247
+ };
4248
+ persist_managed_leader_binding(&plan, &workspace, &pane).unwrap();
4249
+ let first = crate::state::persist::load_runtime_state(&workspace).unwrap();
4250
+ let receiver = &first["teams"]["current"]["leader_receiver"];
4251
+ let native = &receiver["native_session"];
4252
+ assert_eq!(native["expected_session_id"], "native-first");
4253
+ assert_ne!(native["expected_session_id"], receiver["leader_session_uuid"]);
4254
+ assert_eq!(native["provider_projects_root"], serde_json::json!(workspace.join("pi-sessions")));
4255
+ for key in ["pane_id", "tmux_socket", "leader_session_uuid", "owner_epoch"] {
4256
+ assert_eq!(native[key], receiver[key]);
4257
+ }
4258
+ pane.pane_id = PaneId::new("%1");
4259
+ plan.session_capture.as_mut().unwrap().expected_session_id =
4260
+ Some(crate::provider::SessionId::new("native-second"));
4261
+ persist_managed_leader_binding(&plan, &workspace, &pane).unwrap();
4262
+ let second = crate::state::persist::load_runtime_state(&workspace).unwrap();
4263
+ assert_eq!(second["teams"]["current"]["leader_receiver"]["native_session"], *native);
4264
+ let _ = std::fs::remove_dir_all(workspace);
4265
+ }
4266
+
4023
4267
  #[test]
4024
4268
  fn multi_leader_persist_keeps_first_owner_uuid() {
4025
4269
  let workspace = std::env::temp_dir().join(format!(
@@ -1,6 +1,8 @@
1
1
  #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2
2
  use super::*;
3
3
 
4
+ mod prompt_contract;
5
+
4
6
  // ── helpers ────────────────────────────────────────────────────────────
5
7
  /// derive 一个真 LeaderSessionUuid(骨架无公开裸构造器,仅 `derive`)。
6
8
  fn uuid(fp: &str, ws: &str, user: &str, team: &str) -> LeaderSessionUuid {