@team-agent/installer 0.5.69 → 0.5.70

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 CHANGED
@@ -581,7 +581,7 @@ dependencies = [
581
581
 
582
582
  [[package]]
583
583
  name = "team-agent"
584
- version = "0.5.69"
584
+ version = "0.5.70"
585
585
  dependencies = [
586
586
  "anyhow",
587
587
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent", "crates/win-conpty-phase0", "crates/conpty-trans
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.5.69"
12
+ version = "0.5.70"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -392,12 +392,20 @@ fn compile_role_agent_with_mode(
392
392
  let provider = required_string(&meta, role_path, "provider")?;
393
393
  require_explicit_grok_role_model(&meta, role_path, &provider)?;
394
394
  require_explicit_cursor_role_model(&meta, role_path, &provider)?;
395
- require_explicit_pi_role_fields(&meta, role_path, &provider)?;
396
- let model = resolve_model(&meta, team_meta, &provider);
395
+ validate_pi_role_fields(&meta, role_path, &provider)?;
396
+ let is_pi = parse_canonical_provider(&provider) == Some(Provider::Pi);
397
+ let model = if is_pi {
398
+ string_field(&meta, "model")
399
+ .filter(|value| !value.trim().is_empty())
400
+ .map(Value::Str)
401
+ .unwrap_or(Value::Null)
402
+ } else {
403
+ resolve_model(&meta, team_meta, &provider)
404
+ };
397
405
  let auth_mode = string_field(&meta, "auth_mode")
398
406
  .or_else(|| string_field(team_meta, "default_auth_mode"))
399
407
  .unwrap_or_else(|| "subscription".to_string());
400
- if parse_canonical_provider(&provider) == Some(Provider::Pi) && auth_mode != "subscription" {
408
+ if is_pi && auth_mode != "subscription" {
401
409
  return Err(ModelError::Validation(format!(
402
410
  "{}: Pi roles support subscription auth_mode only",
403
411
  role_path.display()
@@ -473,7 +481,11 @@ fn compile_role_agent_with_mode(
473
481
  Some(raw) if !raw.trim().is_empty() => ProviderEffort::parse(raw.trim()),
474
482
  _ => None,
475
483
  };
476
- let resolved_effort = role_effort.or(team_effort);
484
+ let resolved_effort = if is_pi {
485
+ role_effort
486
+ } else {
487
+ role_effort.or(team_effort)
488
+ };
477
489
  if let Some(effort) = resolved_effort {
478
490
  // Reject max + non-Claude at compile time.
479
491
  let provider_str = agent_items
@@ -619,39 +631,23 @@ model: sonnet-4-thinking",
619
631
  )))
620
632
  }
621
633
 
622
- fn require_explicit_pi_role_fields(
623
- meta: &Value,
624
- path: &Path,
625
- provider: &str,
626
- ) -> Result<(), ModelError> {
634
+ fn validate_pi_role_fields(meta: &Value, path: &Path, provider: &str) -> Result<(), ModelError> {
627
635
  if parse_canonical_provider(provider) != Some(Provider::Pi) {
628
636
  return Ok(());
629
637
  }
630
- let model = string_field(meta, "model")
631
- .filter(|value| !value.trim().is_empty())
632
- .ok_or_else(|| {
638
+ if let Some(model) = string_field(meta, "model").filter(|value| !value.trim().is_empty()) {
639
+ let (catalog, name) = model.trim().split_once('/').ok_or_else(|| {
633
640
  ModelError::Validation(format!(
634
- "{}: Pi roles require an explicit qualified exact model",
641
+ "{}: Pi roles require a qualified exact model such as provider/model",
635
642
  path.display()
636
643
  ))
637
644
  })?;
638
- let (catalog, name) = model.trim().split_once('/').ok_or_else(|| {
639
- ModelError::Validation(format!(
640
- "{}: Pi roles require a qualified exact model such as provider/model",
641
- path.display()
642
- ))
643
- })?;
644
- if catalog.is_empty() || name.is_empty() || model.contains('*') {
645
- return Err(ModelError::Validation(format!(
646
- "{}: Pi roles require a qualified exact model",
647
- path.display()
648
- )));
649
- }
650
- if !string_field(meta, "effort").is_some_and(|value| !value.trim().is_empty()) {
651
- return Err(ModelError::Validation(format!(
652
- "{}: Pi roles require explicit effort",
653
- path.display()
654
- )));
645
+ if catalog.is_empty() || name.is_empty() || model.contains('*') {
646
+ return Err(ModelError::Validation(format!(
647
+ "{}: Pi roles require a qualified exact model",
648
+ path.display()
649
+ )));
650
+ }
655
651
  }
656
652
  let tools = required_tools(meta, path)?;
657
653
  if !tools.iter().any(|tool| tool == "mcp_team") {
@@ -660,13 +656,7 @@ fn require_explicit_pi_role_fields(
660
656
  path.display()
661
657
  )));
662
658
  }
663
- match meta.get("dangerously_skip_permissions") {
664
- Some(Value::Bool(true)) => Ok(()),
665
- _ => Err(ModelError::Validation(format!(
666
- "{}: Pi roles require dangerously_skip_permissions: true",
667
- path.display()
668
- ))),
669
- }
659
+ Ok(())
670
660
  }
671
661
 
672
662
  /// 0.5.66 bypass 单源:角色 md 的 `dangerously_skip_permissions` 必填 bool。
@@ -318,7 +318,7 @@ fn leader_start_plan_with_ambient_authority(
318
318
  workspace,
319
319
  team_id: identity.team_id.as_str(),
320
320
  agent_id: "leader",
321
- model: &parsed.model,
321
+ model: parsed.model.as_deref(),
322
322
  effort: parsed.effort,
323
323
  system_prompt: &prompt,
324
324
  tool_categories: &tools,
@@ -5,7 +5,7 @@
5
5
  //! - name: materialize_pi_plan
6
6
  //! what: 为 leader 与 TeamMate 生成同一来源的 Pi CommandPlan
7
7
  //! - name: write_pi_wrapper
8
- //! what: 原子写入 0600 isolated lazy MCP wrapper
8
+ //! what: 原子写入 0600 per-seat runtime MCP registration extension
9
9
  //! boundary:
10
10
  //! - 不枚举 Pi session;resume 只重验调用方给出的 exact backing
11
11
  //! - 不投递消息、不执行 cleanup 或 doctor 判定
@@ -270,7 +270,10 @@ pub(crate) fn validate_pi_executable_chain(chain: &PiExecutableChain) -> Result<
270
270
  if !chain.path_entry.is_absolute()
271
271
  || !chain.launch_executable.is_absolute()
272
272
  || !chain.real_binary.is_absolute()
273
- || chain.path_entry_type != PiExecutableFileType::Wrapper
273
+ || !matches!(
274
+ chain.path_entry_type,
275
+ PiExecutableFileType::Wrapper | PiExecutableFileType::Symlink
276
+ )
274
277
  || chain.launch_executable != chain.path_entry
275
278
  || chain.real_binary == chain.launch_executable
276
279
  || chain.catalog_sha256.len() != 64
@@ -349,15 +352,6 @@ fn render_pi_wrapper(request: &PiWrapperRequest<'_>) -> Result<String, ProviderE
349
352
  "command".to_string(),
350
353
  serde_json::Value::String(expected.to_string_lossy().into_owned()),
351
354
  );
352
- server.insert(
353
- "lifecycle".to_string(),
354
- serde_json::Value::String("lazy".to_string()),
355
- );
356
- server.insert("directTools".to_string(), serde_json::Value::Bool(false));
357
- server.insert(
358
- "toolPrefix".to_string(),
359
- serde_json::Value::String("server".to_string()),
360
- );
361
355
  server.insert(
362
356
  "includeTools".to_string(),
363
357
  serde_json::Value::Array(
@@ -389,55 +383,69 @@ fn render_pi_wrapper(request: &PiWrapperRequest<'_>) -> Result<String, ProviderE
389
383
  "TEAM_AGENT_OWNER_TEAM_ID".to_string(),
390
384
  serde_json::Value::String(request.team_id.to_string()),
391
385
  );
392
- let config = serde_json::json!({
393
- "mcpServers": {
394
- "team_orchestrator": serde_json::Value::Object(server)
395
- }
396
- });
397
- let import = serde_json::to_string(&request.adapter.index_ts)
398
- .map_err(|error| ProviderError::Command(format!("serialize Pi adapter path: {error}")))?;
399
- let config = serde_json::to_string_pretty(&config)
400
- .map_err(|error| ProviderError::Command(format!("serialize Pi MCP config: {error}")))?;
386
+ let definition = serde_json::to_string_pretty(&serde_json::Value::Object(server))
387
+ .map_err(|error| ProviderError::Command(format!("serialize Pi MCP server: {error}")))?;
401
388
  Ok(format!(
402
- "import {{ createMcpAdapter }} from {import};\n\nexport default createMcpAdapter({{ config: {config} }});\n"
389
+ r#"const MCP_RUNTIME_REGISTER_EVENT = "pi-mcp-adapter:runtime-register:v1";
390
+
391
+ export default function teamAgentMcp(pi: any) {{
392
+ let registration: {{ dispose(): Promise<void> }} | undefined;
393
+
394
+ pi.on("session_start", () => {{
395
+ if (registration) return;
396
+ const request: any = {{
397
+ version: 1,
398
+ name: "team_orchestrator",
399
+ definition: {definition},
400
+ }};
401
+ pi.events.emit(MCP_RUNTIME_REGISTER_EVENT, request);
402
+ if (!request.result) throw new Error("pi-mcp-adapter is not enabled");
403
+ if (!request.result.ok) throw request.result.error;
404
+ registration = request.result.registration;
405
+ }});
406
+
407
+ pi.on("session_shutdown", async () => {{
408
+ const current = registration;
409
+ registration = undefined;
410
+ await current?.dispose();
411
+ }});
412
+ }}
413
+ "#
403
414
  ))
404
415
  }
405
416
 
406
417
  /// ---
407
- /// purpose: 静态核对 wrapper 的 exact import、candidate 与 lazy isolation 字段
408
- /// returns: wrapper 保持冻结隔离合同则成功
409
- /// errors: identity 缺失或出现 mcp-config fallback 时返回 ProviderError
418
+ /// purpose: 静态核对 wrapper 只做 per-seat runtime MCP registration
419
+ /// returns: wrapper 保留 direct Pi 配置且冻结 seat identity 时成功
420
+ /// errors: identity、runtime registration 或 candidate 缺失时返回 ProviderError
410
421
  /// ---
411
422
  pub(crate) fn validate_pi_wrapper_source(
412
423
  source: &str,
413
424
  adapter: &PiAdapterIdentity,
414
425
  candidate_executable: &Path,
415
426
  ) -> Result<(), ProviderError> {
416
- let adapter_path = adapter.index_ts.to_string_lossy();
427
+ validate_pi_adapter_identity(adapter)?;
417
428
  let candidate = candidate_executable.to_string_lossy();
418
- if !source.contains(adapter_path.as_ref())
419
- || !source.contains(candidate.as_ref())
420
- || !source.contains("createMcpAdapter")
421
- || !source.contains("mcpServers")
429
+ if !source.contains(candidate.as_ref())
430
+ || !source.contains("pi-mcp-adapter:runtime-register:v1")
431
+ || !source.contains("session_start")
432
+ || !source.contains("session_shutdown")
422
433
  || !source.contains("team_orchestrator")
423
- || !source.contains("lazy")
424
- || !source.contains("directTools")
425
- || !source.contains("false")
426
- || !source.contains("toolPrefix")
427
- || !source.contains("server")
428
434
  || !source.contains("includeTools")
435
+ || source.contains("createMcpAdapter")
436
+ || source.contains("directTools")
437
+ || source.contains("toolPrefix")
429
438
  || source.contains("--mcp-config")
430
439
  {
431
440
  return Err(ProviderError::Command(
432
- "Pi wrapper source does not preserve the isolated adapter/candidate contract"
433
- .to_string(),
441
+ "Pi wrapper source does not preserve direct Pi plus per-seat runtime MCP".to_string(),
434
442
  ));
435
443
  }
436
444
  Ok(())
437
445
  }
438
446
 
439
447
  /// ---
440
- /// purpose: 在 seat root 原子写入 mode 0600 的 isolated lazy MCP wrapper
448
+ /// purpose: 在 seat root 原子写入 mode 0600 的 runtime MCP registration extension
441
449
  /// returns: 最终 wrapper 路径
442
450
  /// errors: identity、序列化、目录、写盘或 rename 失败时返回 ProviderError
443
451
  /// ---
@@ -482,14 +490,14 @@ pub(crate) fn write_pi_wrapper(request: PiWrapperRequest<'_>) -> Result<PathBuf,
482
490
 
483
491
  #[derive(Debug, Clone, PartialEq, Eq)]
484
492
  pub(crate) struct PiLeaderArgs {
485
- pub model: String,
486
- pub effort: ProviderEffort,
493
+ pub model: Option<String>,
494
+ pub effort: Option<ProviderEffort>,
487
495
  }
488
496
 
489
497
  /// ---
490
498
  /// purpose: 解析 Pi leader 唯一允许的 model/thinking 输入
491
- /// returns: 显式 qualified model 与 effort
492
- /// errors: 缺失、重复、未知或 materializer-owned flag 时返回 ProviderError
499
+ /// returns: 仅保留调用方显式给出的 qualified model 与 effort
500
+ /// errors: 重复、未知或 materializer-owned flag 时返回 ProviderError
493
501
  /// ---
494
502
  pub(crate) fn parse_pi_leader_args(args: &[String]) -> Result<PiLeaderArgs, ProviderError> {
495
503
  let args = args.strip_prefix(&["--".to_string()]).unwrap_or(args);
@@ -519,24 +527,17 @@ pub(crate) fn parse_pi_leader_args(args: &[String]) -> Result<PiLeaderArgs, Prov
519
527
  }
520
528
  index += 2;
521
529
  }
522
- let model = model.ok_or_else(|| {
523
- ProviderError::Command("Pi leader requires an explicit --model".to_string())
524
- })?;
525
- if model.contains('*')
526
- || !model
527
- .split_once('/')
528
- .is_some_and(|(provider, name)| !provider.is_empty() && !name.is_empty())
529
- {
530
+ if model.as_ref().is_some_and(|model| {
531
+ model.contains('*')
532
+ || !model
533
+ .split_once('/')
534
+ .is_some_and(|(provider, name)| !provider.is_empty() && !name.is_empty())
535
+ }) {
530
536
  return Err(ProviderError::Command(
531
537
  "Pi leader --model must be a qualified exact id".to_string(),
532
538
  ));
533
539
  }
534
- Ok(PiLeaderArgs {
535
- model,
536
- effort: effort.ok_or_else(|| {
537
- ProviderError::Command("Pi leader requires an explicit --thinking".to_string())
538
- })?,
539
- })
540
+ Ok(PiLeaderArgs { model, effort })
540
541
  }
541
542
 
542
543
  fn sha256(bytes: &[u8]) -> String {
@@ -598,7 +599,7 @@ fn command_stdout(executable: &Path, args: &[&str]) -> Result<Vec<u8>, ProviderE
598
599
  }
599
600
 
600
601
  fn discover_pi_adapter(launch_executable: &Path) -> Result<PiAdapterIdentity, ProviderError> {
601
- let listing = command_stdout(launch_executable, &["list", "--no-approve"])?;
602
+ let listing = command_stdout(launch_executable, &["list"])?;
602
603
  let listing = std::str::from_utf8(&listing).map_err(|error| {
603
604
  ProviderError::Command(format!("Pi package list is not UTF-8: {error}"))
604
605
  })?;
@@ -660,16 +661,16 @@ fn discover_pi_adapter(launch_executable: &Path) -> Result<PiAdapterIdentity, Pr
660
661
  pub(crate) fn resolve_pi_executable_chain(
661
662
  ) -> Result<(PiExecutableChain, Vec<String>), ProviderError> {
662
663
  let path_entries = pi_path_entries()?;
663
- let path_entry = path_entries
664
- .iter()
665
- .find_map(|candidate| {
666
- (executable_file_type(candidate).ok()? == PiExecutableFileType::Wrapper)
667
- .then(|| candidate.clone())
668
- })
669
- .ok_or_else(|| {
670
- ProviderError::Command("Pi PATH has no verified wrapper launch executable".to_string())
671
- })?;
672
- let path_entry_type = PiExecutableFileType::Wrapper;
664
+ let path_entry = path_entries[0].clone();
665
+ let path_entry_type = executable_file_type(&path_entry)?;
666
+ if !matches!(
667
+ path_entry_type,
668
+ PiExecutableFileType::Wrapper | PiExecutableFileType::Symlink
669
+ ) {
670
+ return Err(ProviderError::Command(
671
+ "the direct Pi PATH entry is not a verified wrapper or npm symlink".to_string(),
672
+ ));
673
+ }
673
674
  let version =
674
675
  String::from_utf8(command_stdout(&path_entry, &["--version"])?).map_err(|error| {
675
676
  ProviderError::Command(format!("Pi version output is not UTF-8: {error}"))
@@ -677,17 +678,21 @@ pub(crate) fn resolve_pi_executable_chain(
677
678
  let version = version.trim().to_string();
678
679
  let launch_identity = std::fs::canonicalize(&path_entry)
679
680
  .map_err(|error| ProviderError::Io(format!("{}: {error}", path_entry.display())))?;
680
- let real_binary = path_entries
681
- .iter()
682
- .find_map(|candidate| {
683
- let real = std::fs::canonicalize(candidate).ok()?;
684
- (real != launch_identity).then_some(real)
685
- })
686
- .ok_or_else(|| {
687
- ProviderError::Command(
688
- "Pi PATH wrapper has no independently resolved real executable".to_string(),
689
- )
690
- })?;
681
+ let real_binary = if path_entry_type == PiExecutableFileType::Symlink {
682
+ launch_identity
683
+ } else {
684
+ path_entries
685
+ .iter()
686
+ .find_map(|candidate| {
687
+ let real = std::fs::canonicalize(candidate).ok()?;
688
+ (real != launch_identity).then_some(real)
689
+ })
690
+ .ok_or_else(|| {
691
+ ProviderError::Command(
692
+ "Pi PATH wrapper has no independently resolved real executable".to_string(),
693
+ )
694
+ })?
695
+ };
691
696
  let _real_version =
692
697
  String::from_utf8(command_stdout(&real_binary, &["--version"])?).map_err(|error| {
693
698
  ProviderError::Command(format!(
@@ -714,8 +719,8 @@ pub(crate) struct PiMaterializeRequest<'a> {
714
719
  pub workspace: &'a Path,
715
720
  pub team_id: &'a str,
716
721
  pub agent_id: &'a str,
717
- pub model: &'a str,
718
- pub effort: ProviderEffort,
722
+ pub model: Option<&'a str>,
723
+ pub effort: Option<ProviderEffort>,
719
724
  pub system_prompt: &'a str,
720
725
  pub tool_categories: &'a [&'a str],
721
726
  pub team_mcp_tools: &'a [&'a str],
@@ -752,7 +757,10 @@ fn materialize_pi_plan_with_session(
752
757
  resume: Option<(&SessionId, &Path, &Path)>,
753
758
  ) -> Result<CommandPlan, ProviderError> {
754
759
  let (chain, catalog) = resolve_pi_executable_chain()?;
755
- let model = select_exact_pi_model(&catalog, request.model)?;
760
+ let model = request
761
+ .model
762
+ .map(|model| select_exact_pi_model(&catalog, model))
763
+ .transpose()?;
756
764
  let paths = pi_seat_paths(request.workspace, request.team_id, request.agent_id);
757
765
  let resume = if let Some((session_id, session_path, spawn_cwd)) = resume {
758
766
  let session_root = std::fs::canonicalize(&paths.sessions).map_err(|error| {
@@ -795,7 +803,7 @@ fn materialize_pi_plan_with_session(
795
803
  let argv = build_pi_command_argv(PiCommandRequest {
796
804
  executable: &chain.launch_executable,
797
805
  extension: &wrapper,
798
- model: &model,
806
+ model: model.as_deref(),
799
807
  effort: request.effort,
800
808
  system_prompt: request.system_prompt,
801
809
  tool_categories: request.tool_categories,
@@ -809,7 +817,7 @@ fn materialize_pi_plan_with_session(
809
817
  let argv = build_pi_command_argv(PiCommandRequest {
810
818
  executable: &chain.launch_executable,
811
819
  extension: &wrapper,
812
- model: &model,
820
+ model: model.as_deref(),
813
821
  effort: request.effort,
814
822
  system_prompt: request.system_prompt,
815
823
  tool_categories: request.tool_categories,
@@ -178,23 +178,13 @@ pub(super) fn spawn_agents(
178
178
  effort: agent_effort,
179
179
  };
180
180
  let mut plan = if provider == Provider::Pi {
181
- let model = command_model.ok_or_else(|| {
182
- LifecycleError::RequirementUnmet(
183
- "Pi launch requires an explicit qualified model".to_string(),
184
- )
185
- })?;
186
- let effort = agent_effort.ok_or_else(|| {
187
- LifecycleError::RequirementUnmet(
188
- "Pi launch requires an explicit thinking effort".to_string(),
189
- )
190
- })?;
191
181
  crate::lifecycle::launch::pi_mcp::materialize_pi_plan(
192
182
  crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
193
183
  workspace,
194
184
  team_id: &mcp_team_id,
195
185
  agent_id: agent_id_raw,
196
- model,
197
- effort,
186
+ model: command_model,
187
+ effort: agent_effort,
198
188
  system_prompt: &system_prompt,
199
189
  tool_categories: &resolved_tool_refs,
200
190
  team_mcp_tools: &["send_message", "report_result"],
@@ -392,22 +392,12 @@ pub(super) fn spawn_agent_window(
392
392
  .or(pi_spawn_cwd.as_deref())
393
393
  .unwrap_or(workspace);
394
394
  let mut plan = if provider == Provider::Pi {
395
- let model = command_model.ok_or_else(|| {
396
- LifecycleError::RequirementUnmet(
397
- "Pi restart requires an explicit qualified model".to_string(),
398
- )
399
- })?;
400
- let effort = restart_effort.ok_or_else(|| {
401
- LifecycleError::RequirementUnmet(
402
- "Pi restart requires an explicit thinking effort".to_string(),
403
- )
404
- })?;
405
395
  let request = crate::lifecycle::launch::pi_mcp::PiMaterializeRequest {
406
396
  workspace,
407
397
  team_id: team_id.as_deref().unwrap_or(""),
408
398
  agent_id: agent_id.as_str(),
409
- model,
410
- effort,
399
+ model: command_model,
400
+ effort: restart_effort,
411
401
  system_prompt: &system_prompt,
412
402
  tool_categories: &resolved_tool_refs,
413
403
  team_mcp_tools: &["send_message", "report_result"],
@@ -18,8 +18,8 @@ fn command_for_model(model: &str) -> Vec<String> {
18
18
  build_pi_command_argv(PiCommandRequest {
19
19
  executable: Path::new("/verified/pi"),
20
20
  extension: Path::new("/workspace/.team/runtime/pi/t1/w1/team-mcp.ts"),
21
- model,
22
- effort: ProviderEffort::Medium,
21
+ model: Some(model),
22
+ effort: Some(ProviderEffort::Medium),
23
23
  system_prompt: "worker contract",
24
24
  tool_categories: &["mcp_team"],
25
25
  session_dir: Path::new("/workspace/.team/runtime/pi/t1/w1/sessions"),
@@ -7,8 +7,8 @@ fn request(session: PiSessionSelector<'static>) -> PiCommandRequest<'static> {
7
7
  PiCommandRequest {
8
8
  executable: Path::new("/verified/pi"),
9
9
  extension: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts"),
10
- model: "team-agent/qwen3.8-27b",
11
- effort: ProviderEffort::High,
10
+ model: Some("team-agent/qwen3.8-27b"),
11
+ effort: Some(ProviderEffort::High),
12
12
  system_prompt: "frozen worker prompt",
13
13
  tool_categories: &["mcp_team", "fs_read", "fs_write", "execute_bash"],
14
14
  session_dir: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/sessions"),
@@ -18,7 +18,20 @@ fn request(session: PiSessionSelector<'static>) -> PiCommandRequest<'static> {
18
18
  }
19
19
 
20
20
  #[test]
21
- fn pi_command_is_exact_regular_tui_and_has_no_ambient_flags() {
21
+ fn pi_command_omits_unset_model_and_thinking_flags() {
22
+ let mut request = request(PiSessionSelector::Fresh {
23
+ session_id: "a2320d4e-7b3a-44ae-b3b8-d5de57033b01",
24
+ });
25
+ request.model = None;
26
+ request.effort = None;
27
+ let argv = build_pi_command_argv(request).expect("Pi provider defaults");
28
+
29
+ assert!(!argv.iter().any(|arg| arg == "--model"), "argv={argv:?}");
30
+ assert!(!argv.iter().any(|arg| arg == "--thinking"), "argv={argv:?}");
31
+ }
32
+
33
+ #[test]
34
+ fn pi_command_preserves_direct_pi_defaults_with_only_team_runtime_additions() {
22
35
  let fresh = build_pi_command_argv(request(PiSessionSelector::Fresh {
23
36
  session_id: "da8c3622-2378-4d05-a26c-e826a6ef6d63",
24
37
  }))
@@ -27,23 +40,14 @@ fn pi_command_is_exact_regular_tui_and_has_no_ambient_flags() {
27
40
  fresh,
28
41
  [
29
42
  "/verified/pi",
30
- "--no-extensions",
31
43
  "-e",
32
44
  "/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts",
33
- "--no-approve",
34
- "--no-context-files",
35
- "--no-skills",
36
- "--no-prompt-templates",
37
- "--tui-mode",
38
- "regular",
39
45
  "--model",
40
46
  "team-agent/qwen3.8-27b",
41
47
  "--thinking",
42
48
  "high",
43
- "--system-prompt",
49
+ "--append-system-prompt",
44
50
  "frozen worker prompt",
45
- "--tools",
46
- "bash,edit,mcp,read,write",
47
51
  "--session-dir",
48
52
  "/workspace/.team/runtime/pi/team-a/worker-a/sessions",
49
53
  "--session-id",
@@ -88,6 +92,14 @@ fn pi_command_is_exact_regular_tui_and_has_no_ambient_flags() {
88
92
  "--force",
89
93
  "--trust",
90
94
  "--sandbox",
95
+ "--no-extensions",
96
+ "--no-approve",
97
+ "--no-context-files",
98
+ "--no-skills",
99
+ "--no-prompt-templates",
100
+ "--tui-mode",
101
+ "--tools",
102
+ "--system-prompt",
91
103
  ] {
92
104
  assert!(
93
105
  !argv.iter().any(|arg| arg == forbidden),
@@ -98,23 +110,26 @@ fn pi_command_is_exact_regular_tui_and_has_no_ambient_flags() {
98
110
  }
99
111
 
100
112
  #[test]
101
- fn pi_command_uses_no_context_no_extensions_no_approve() {
113
+ fn pi_command_keeps_direct_plugins_skills_context_auth_and_tools() {
102
114
  let argv = build_pi_command_argv(request(PiSessionSelector::Fresh {
103
115
  session_id: "76f682b1-ecc3-4586-850f-ab2e2bb04cb3",
104
116
  }))
105
117
  .expect("fresh Pi command");
106
118
 
107
- for required in [
119
+ for forbidden in [
108
120
  "--no-context-files",
109
121
  "--no-extensions",
110
122
  "--no-approve",
111
123
  "--no-skills",
112
124
  "--no-prompt-templates",
125
+ "--tools",
126
+ "--system-prompt",
127
+ "--tui-mode",
113
128
  ] {
114
129
  assert_eq!(
115
- argv.iter().filter(|arg| arg.as_str() == required).count(),
116
- 1,
117
- "required isolation flag must occur once: {required}; argv={argv:?}"
130
+ argv.iter().filter(|arg| arg.as_str() == forbidden).count(),
131
+ 0,
132
+ "TeamMate Pi must preserve the direct Pi default instead of adding {forbidden}; argv={argv:?}"
118
133
  );
119
134
  }
120
135
  assert!(argv.windows(2).any(|pair| {
@@ -124,5 +139,7 @@ fn pi_command_uses_no_context_no_extensions_no_approve() {
124
139
  ]
125
140
  }));
126
141
  assert_eq!(argv.iter().filter(|arg| arg.as_str() == "-e").count(), 1);
127
- assert!(!argv.iter().any(|arg| arg == "--approve"));
142
+ assert!(argv
143
+ .windows(2)
144
+ .any(|pair| pair == ["--append-system-prompt", "frozen worker prompt"]));
128
145
  }
@@ -14,6 +14,13 @@ const CATALOG: &[u8] = include_bytes!("fixtures/pi_list_models_g0.stdout.txt");
14
14
  static NEXT_ROLE: AtomicU32 = AtomicU32::new(0);
15
15
 
16
16
  fn compile_pi_role(front_matter: &str) -> Result<CompiledRole, ModelError> {
17
+ compile_pi_role_with_team(front_matter, &Value::Map(Vec::new()))
18
+ }
19
+
20
+ fn compile_pi_role_with_team(
21
+ front_matter: &str,
22
+ team_meta: &Value,
23
+ ) -> Result<CompiledRole, ModelError> {
17
24
  let seq = NEXT_ROLE.fetch_add(1, Ordering::Relaxed);
18
25
  let root = std::env::temp_dir().join(format!(
19
26
  "team-agent-pi-compiler-{}-{seq}",
@@ -26,7 +33,7 @@ fn compile_pi_role(front_matter: &str) -> Result<CompiledRole, ModelError> {
26
33
  format!("---\n{front_matter}\n---\nPi worker contract.\n"),
27
34
  )
28
35
  .expect("write role fixture");
29
- let result = compile_role_agent(&role, &Value::Map(Vec::new()), "/workspace");
36
+ let result = compile_role_agent(&role, team_meta, "/workspace");
30
37
  std::fs::remove_dir_all(root).expect("remove role fixture root");
31
38
  result
32
39
  }
@@ -48,8 +55,8 @@ fn command(tools: &[&str], effort: ProviderEffort, model: &str) -> Result<Vec<St
48
55
  build_pi_command_argv(PiCommandRequest {
49
56
  executable: Path::new("/verified/pi"),
50
57
  extension: Path::new("/workspace/.team/runtime/pi/t1/pi-worker/team-mcp.ts"),
51
- model,
52
- effort,
58
+ model: Some(model),
59
+ effort: Some(effort),
53
60
  system_prompt: "Pi worker contract.",
54
61
  tool_categories: tools,
55
62
  session_dir: Path::new("/workspace/.team/runtime/pi/t1/pi-worker/sessions"),
@@ -62,19 +69,15 @@ fn command(tools: &[&str], effort: ProviderEffort, model: &str) -> Result<Vec<St
62
69
  }
63
70
 
64
71
  #[test]
65
- fn pi_role_requires_qualified_model_explicit_effort_and_mcp_team() {
72
+ fn pi_role_requires_mcp_team_and_preserves_provider_defaults() {
66
73
  let positive = compile_pi_role(&valid_role_with(""));
67
74
  assert!(positive.is_ok(), "fully explicit Pi role must compile");
68
75
 
69
- let missing_model = valid_role_with("").replace("model: team-agent/qwen3.8-27b\n", "");
70
- let missing_effort = valid_role_with("").replace("effort: medium\n", "");
71
76
  let missing_mcp = valid_role_with("").replace(" - mcp_team\n", "");
72
77
  let unqualified =
73
78
  valid_role_with("").replace("model: team-agent/qwen3.8-27b", "model: qwen3.8-27b");
74
79
 
75
80
  for (label, role) in [
76
- ("model", missing_model),
77
- ("effort", missing_effort),
78
81
  ("mcp_team", missing_mcp),
79
82
  ("qualified exact model", unqualified),
80
83
  ] {
@@ -85,13 +88,45 @@ fn pi_role_requires_qualified_model_explicit_effort_and_mcp_team() {
85
88
  );
86
89
  }
87
90
 
91
+ let defaults = valid_role_with("")
92
+ .replace("model: team-agent/qwen3.8-27b\n", "")
93
+ .replace("effort: medium\n", "");
94
+ let team_meta = Value::Map(vec![
95
+ (
96
+ "provider_models".to_string(),
97
+ Value::Map(vec![(
98
+ "pi".to_string(),
99
+ Value::Str("team-default/forced-model".to_string()),
100
+ )]),
101
+ ),
102
+ (
103
+ "default_model".to_string(),
104
+ Value::Str("global-default/forced-model".to_string()),
105
+ ),
106
+ (
107
+ "provider_effort".to_string(),
108
+ Value::Str("high".to_string()),
109
+ ),
110
+ ]);
111
+ let compiled = compile_pi_role_with_team(&defaults, &team_meta)
112
+ .expect("Pi model and effort may use provider defaults");
113
+ assert_eq!(
114
+ compiled.agent.get("model"),
115
+ Some(&Value::Null),
116
+ "omitted Pi model must not inherit a synthesized framework default"
117
+ );
118
+ assert!(
119
+ compiled.agent.get("effort").is_none(),
120
+ "omitted Pi effort must not inherit a synthesized framework default"
121
+ );
122
+
88
123
  let models = parse_pi_list_models_table(CATALOG).expect("catalog fixture");
89
124
  assert!(select_exact_pi_model(&models, "team-agent/qwen3.8-27b").is_ok());
90
125
  assert!(select_exact_pi_model(&models, "foo/bar").is_err());
91
126
  }
92
127
 
93
128
  #[test]
94
- fn pi_role_requires_intrinsic_unrestricted_ack_true() {
129
+ fn pi_role_uses_standard_bypass_field_without_fabricating_argv() {
95
130
  let compiled = compile_pi_role(&valid_role_with("")).expect("true acknowledgement");
96
131
  assert_eq!(
97
132
  compiled.agent.get("dangerously_skip_permissions"),
@@ -122,10 +157,16 @@ fn pi_role_requires_intrinsic_unrestricted_ack_true() {
122
157
  "dangerously_skip_permissions: true",
123
158
  "dangerously_skip_permissions: false",
124
159
  );
125
- let error = compile_error(compile_pi_role(&false_ack));
126
- let text = error.to_string();
160
+ let compiled = compile_pi_role(&false_ack).expect("standard false bypass value");
161
+ assert_eq!(
162
+ compiled.agent.get("dangerously_skip_permissions"),
163
+ Some(&Value::Bool(false))
164
+ );
165
+
166
+ let missing_ack = valid_role_with("").replace("dangerously_skip_permissions: true\n", "");
167
+ let text = compile_error(compile_pi_role(&missing_ack)).to_string();
127
168
  assert!(
128
- text.contains("dangerously_skip_permissions") && text.contains("true"),
169
+ text.contains("missing front matter field dangerously_skip_permissions"),
129
170
  "got {text}"
130
171
  );
131
172
  }
@@ -153,7 +194,7 @@ fn pi_max_effort_is_supported_without_model_suffix() {
153
194
  }
154
195
 
155
196
  #[test]
156
- fn pi_tools_allowlist_keeps_mcp_and_rejects_unknown_categories() {
197
+ fn pi_role_tools_validate_team_capabilities_without_replacing_direct_pi_tools() {
157
198
  assert_eq!(pi_tool_mapping("mcp_team"), PiToolMapping::Mcp);
158
199
  assert_eq!(
159
200
  pi_tool_mapping("fs_read"),
@@ -194,14 +235,9 @@ fn pi_tools_allowlist_keeps_mcp_and_rejects_unknown_categories() {
194
235
  ProviderEffort::Medium,
195
236
  "team-agent/qwen3.8-27b",
196
237
  )
197
- .expect("known allowlist");
198
- let tools = argv
199
- .windows(2)
200
- .find(|pair| pair[0] == "--tools")
201
- .map(|pair| pair[1].as_str())
202
- .expect("--tools value");
203
- assert_eq!(
204
- tools, "bash,edit,find,grep,ls,mcp,read,write",
205
- "tools must be sorted and deduplicated"
238
+ .expect("known standard role tools");
239
+ assert!(
240
+ !argv.iter().any(|arg| arg == "--tools"),
241
+ "role tools validate Team Agent capabilities but must not replace direct Pi tools: {argv:?}"
206
242
  );
207
243
  }
@@ -224,12 +224,27 @@ fn pi_leader_and_teammate_body(hermetic: &HermeticTestEnv) {
224
224
  );
225
225
  let leader = parse_pi_leader_args(&leader_argv[2..])
226
226
  .expect("leader exact model and effort must compile into the shared plan input");
227
- assert_eq!(leader.model, "team-agent/qwen3.8-27b");
228
- assert_eq!(leader.effort, ProviderEffort::Max);
227
+ assert_eq!(leader.model.as_deref(), Some("team-agent/qwen3.8-27b"));
228
+ assert_eq!(leader.effort, Some(ProviderEffort::Max));
229
+
230
+ let defaults = parse_pi_leader_args(&[]).expect("leader provider defaults");
231
+ assert_eq!(defaults.model, None);
232
+ assert_eq!(defaults.effort, None);
233
+ assert_eq!(
234
+ parse_pi_leader_args(&["--thinking".to_string(), "medium".to_string()])
235
+ .expect("explicit thinking only")
236
+ .effort,
237
+ Some(ProviderEffort::Medium)
238
+ );
239
+ assert_eq!(
240
+ parse_pi_leader_args(&["--model".to_string(), "team-agent/qwen3.8-27b".to_string(),])
241
+ .expect("explicit model only")
242
+ .model
243
+ .as_deref(),
244
+ Some("team-agent/qwen3.8-27b")
245
+ );
229
246
 
230
247
  for invalid in [
231
- vec!["--thinking".to_string(), "medium".to_string()],
232
- vec!["--model".to_string(), "team-agent/qwen3.8-27b".to_string()],
233
248
  vec![
234
249
  "--model".to_string(),
235
250
  "qwen3.8-27b".to_string(),
@@ -247,7 +262,7 @@ fn pi_leader_and_teammate_body(hermetic: &HermeticTestEnv) {
247
262
  ] {
248
263
  assert!(
249
264
  parse_pi_leader_args(&invalid).is_err(),
250
- "leader input must refuse missing, ambiguous, or materializer-owned fields: {invalid:?}"
265
+ "leader input must refuse ambiguous or materializer-owned fields: {invalid:?}"
251
266
  );
252
267
  }
253
268
 
@@ -267,12 +282,15 @@ fn pi_leader_and_teammate_body(hermetic: &HermeticTestEnv) {
267
282
 
268
283
  std::fs::write(
269
284
  &role,
270
- "---\nname: worker-a\nrole: developer\nprovider: pi\nauth_mode: subscription\neffort: max\ntools:\n - mcp_team\ndangerously_skip_permissions: true\n---\nworker contract\n",
285
+ "---\nname: worker-a\nrole: developer\nprovider: pi\nauth_mode: subscription\ntools:\n - mcp_team\ndangerously_skip_permissions: true\n---\nworker contract\n",
271
286
  )
272
- .expect("write missing-model teammate role");
287
+ .expect("write provider-default teammate role");
288
+ let defaults = compile_role_agent(&role, &Value::Map(Vec::new()), "/workspace")
289
+ .expect("provider: pi teammate may use Pi model and effort defaults");
290
+ assert_eq!(defaults.agent.get("model"), Some(&Value::Null));
273
291
  assert!(
274
- compile_role_agent(&role, &Value::Map(Vec::new()), "/workspace").is_err(),
275
- "provider: pi teammate without an exact model must refuse"
292
+ defaults.agent.get("effort").is_none(),
293
+ "omitted Pi model and effort must remain absent"
276
294
  );
277
295
  std::fs::remove_dir_all(root).expect("remove role fixture");
278
296
 
@@ -3,9 +3,9 @@ use std::path::{Path, PathBuf};
3
3
  use std::sync::atomic::{AtomicU32, Ordering};
4
4
 
5
5
  use crate::lifecycle::launch::pi_mcp::{
6
- parse_pi_list_models_table, validate_pi_adapter_identity, validate_pi_executable_chain,
7
- validate_pi_wrapper_source, write_pi_wrapper, PiAdapterIdentity, PiExecutableChain,
8
- PiExecutableFileType, PiWrapperRequest,
6
+ parse_pi_list_models_table, resolve_pi_executable_chain, validate_pi_adapter_identity,
7
+ validate_pi_executable_chain, validate_pi_wrapper_source, write_pi_wrapper, PiAdapterIdentity,
8
+ PiExecutableChain, PiExecutableFileType, PiWrapperRequest,
9
9
  };
10
10
  use crate::provider::McpConfig;
11
11
 
@@ -18,15 +18,20 @@ const INDEX_DIGEST: &str = "16d260ac25b66346baab6ecef76680324336953bafc7be8cf95b
18
18
  const CATALOG_DIGEST: &str = "726cedb6c3f6fe80a0d7b98918d8ed5063695e01f510a48f46c4bad5daab49fe";
19
19
  static NEXT_ROOT: AtomicU32 = AtomicU32::new(0);
20
20
  const PI_WRAPPER_CHILD: &str = "TEAM_AGENT_TEST_PI_WRAPPER_CHILD";
21
- const PI_WRAPPER_NEGATIVE_CHILD: &str = "TEAM_AGENT_TEST_PI_WRAPPER_NEGATIVE_CHILD";
21
+ const PI_WRAPPER_AMBIENT_CHILD: &str = "TEAM_AGENT_TEST_PI_WRAPPER_AMBIENT_CHILD";
22
+ const PI_SYMLINK_CHILD: &str = "TEAM_AGENT_TEST_PI_SYMLINK_CHILD";
22
23
  const PI_WRAPPER_PARENT_PID: &str = "TEAM_AGENT_TEST_PI_WRAPPER_PARENT_PID";
23
24
  const PI_WRAPPER_TEST: &str = concat!(
24
25
  "lifecycle::tests::pi_executable_mcp_red::",
25
26
  "pi_wrapper_is_atomic_per_seat_and_embeds_exact_candidate"
26
27
  );
27
- const PI_WRAPPER_NEGATIVE_TEST: &str = concat!(
28
+ const PI_WRAPPER_AMBIENT_TEST: &str = concat!(
28
29
  "lifecycle::tests::pi_executable_mcp_red::",
29
- "pi_wrapper_import_failure_has_no_ambient_merge_fallback"
30
+ "pi_wrapper_runtime_registration_does_not_copy_ambient_config"
31
+ );
32
+ const PI_SYMLINK_TEST: &str = concat!(
33
+ "lifecycle::tests::pi_executable_mcp_red::",
34
+ "pi_standard_npm_symlink_is_a_verified_launch_entry"
30
35
  );
31
36
 
32
37
  fn run_process_isolated(marker: &str, test_name: &str, body: impl FnOnce()) {
@@ -165,6 +170,60 @@ fn pi_executable_chain_freezes_wrapper_real_binary_catalog_and_plugin_identity()
165
170
  std::fs::remove_dir_all(root).expect("remove executable chain fixture");
166
171
  }
167
172
 
173
+ #[test]
174
+ fn pi_standard_npm_symlink_is_a_verified_launch_entry() {
175
+ run_process_isolated(PI_SYMLINK_CHILD, PI_SYMLINK_TEST, || {
176
+ assert_isolated_child();
177
+ let root = temp_root("npm-symlink");
178
+ let bin = root.join("bin");
179
+ let later_bin = root.join("later-bin");
180
+ let package_root = root.join("pi-mcp-adapter");
181
+ std::fs::create_dir_all(&bin).expect("create bin");
182
+ std::fs::create_dir_all(&later_bin).expect("create later bin");
183
+ std::fs::create_dir_all(&package_root).expect("create adapter root");
184
+ let real = root.join("cli.js");
185
+ let script = format!(
186
+ "#!/bin/sh\ncase \"$1\" in\n --version) printf '0.84.4\\n' ;;\n --list-models) printf 'provider model\\nteam-agent qwen3.8-27b\\n' ;;\n list) printf 'npm:pi-mcp-adapter\\n{}\\n' ;;\n *) exit 64 ;;\nesac\n",
187
+ package_root.display()
188
+ );
189
+ std::fs::write(&real, &script).expect("write protocol-capable Pi entry");
190
+ std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755))
191
+ .expect("make Pi entry executable");
192
+ std::os::unix::fs::symlink(&real, bin.join("pi")).expect("create npm-style symlink");
193
+ std::fs::write(later_bin.join("pi"), script).expect("write later Pi wrapper");
194
+ std::fs::set_permissions(later_bin.join("pi"), std::fs::Permissions::from_mode(0o755))
195
+ .expect("make later Pi wrapper executable");
196
+ std::fs::write(
197
+ package_root.join("package.json"),
198
+ br#"{"name":"pi-mcp-adapter","version":"2.30.0","pi":{"extensions":["./index.ts"]}}"#,
199
+ )
200
+ .expect("write adapter package");
201
+ std::fs::write(
202
+ package_root.join("index.ts"),
203
+ b"export const createMcpAdapter = () => {};\n",
204
+ )
205
+ .expect("write adapter entry");
206
+ unsafe {
207
+ std::env::set_var(
208
+ "PATH",
209
+ std::env::join_paths([&bin, &later_bin]).expect("join fixture PATH"),
210
+ );
211
+ }
212
+
213
+ let (chain, models) = resolve_pi_executable_chain()
214
+ .expect("the first PATH Pi entry must define direct Pi behavior");
215
+ assert_eq!(chain.path_entry, bin.join("pi"));
216
+ assert_eq!(chain.path_entry_type, PiExecutableFileType::Symlink);
217
+ assert_eq!(chain.launch_executable, bin.join("pi"));
218
+ assert_eq!(
219
+ chain.real_binary,
220
+ std::fs::canonicalize(&real).expect("canonical real Pi entry")
221
+ );
222
+ assert_eq!(models, ["team-agent/qwen3.8-27b"]);
223
+ std::fs::remove_dir_all(root).expect("remove symlink fixture");
224
+ });
225
+ }
226
+
168
227
  #[test]
169
228
  fn pi_adapter_detector_requires_exact_2_30_0_entry_and_digest() {
170
229
  let root = temp_root("adapter-identity");
@@ -234,18 +293,37 @@ fn pi_wrapper_is_atomic_body(hermetic: &HermeticTestEnv) {
234
293
  );
235
294
 
236
295
  let source = std::fs::read_to_string(&written).expect("read wrapper");
237
- assert!(source.contains(adapter.index_ts.to_string_lossy().as_ref()));
238
296
  assert!(source.contains(candidate.to_string_lossy().as_ref()));
239
297
  assert!(source.contains("TEAM_AGENT_ID") && source.contains("worker-a"));
240
298
  assert!(source.contains("TEAM_AGENT_OWNER_TEAM_ID") && source.contains("team-a"));
241
- assert!(source.contains("lifecycle") && source.contains("lazy"));
242
- assert!(source.contains("directTools") && source.contains("false"));
243
- assert!(source.contains("toolPrefix") && source.contains("server"));
299
+ assert!(source.contains("pi-mcp-adapter:runtime-register:v1"));
300
+ assert!(source.contains("session_start") && source.contains("session_shutdown"));
244
301
  assert!(source.contains("includeTools"));
245
302
  assert!(source.contains("send_message") && source.contains("report_result"));
303
+ assert!(!source.contains("createMcpAdapter"));
304
+ assert!(!source.contains("directTools"));
305
+ assert!(!source.contains("toolPrefix"));
306
+ assert!(!source.contains("\"lifecycle\""));
246
307
  assert!(!source.contains("command: \"team-agent\""));
247
308
  assert!(!source.contains("--mcp-config"));
248
309
 
310
+ let other_destination = workspace.join(".team/runtime/pi/team-a/worker-b/team-mcp.ts");
311
+ write_pi_wrapper(PiWrapperRequest {
312
+ destination: &other_destination,
313
+ adapter: &adapter,
314
+ candidate_executable: &candidate,
315
+ mcp_config: &mcp_config(&candidate, &workspace, "worker-b"),
316
+ team_id: "team-a",
317
+ agent_id: "worker-b",
318
+ workspace: &workspace,
319
+ include_tools: &["send_message", "report_result"],
320
+ })
321
+ .expect("write second seat wrapper");
322
+ let other_source = std::fs::read_to_string(&other_destination).expect("read second wrapper");
323
+ assert!(source.contains("worker-a") && !source.contains("worker-b"));
324
+ assert!(other_source.contains("worker-b") && !other_source.contains("worker-a"));
325
+ assert_ne!(written, other_destination);
326
+
249
327
  let entries = std::fs::read_dir(destination.parent().expect("wrapper parent"))
250
328
  .expect("list wrapper parent")
251
329
  .map(|entry| entry.expect("entry").file_name())
@@ -259,8 +337,8 @@ fn pi_wrapper_is_atomic_body(hermetic: &HermeticTestEnv) {
259
337
  }
260
338
 
261
339
  #[test]
262
- fn pi_wrapper_import_failure_has_no_ambient_merge_fallback() {
263
- run_process_isolated(PI_WRAPPER_NEGATIVE_CHILD, PI_WRAPPER_NEGATIVE_TEST, || {
340
+ fn pi_wrapper_runtime_registration_does_not_copy_ambient_config() {
341
+ run_process_isolated(PI_WRAPPER_AMBIENT_CHILD, PI_WRAPPER_AMBIENT_TEST, || {
264
342
  let hermetic = HermeticTestEnv::enter("pi-wrapper-negative-send");
265
343
  assert_isolated_child();
266
344
  pi_wrapper_import_failure_body(&hermetic);
@@ -293,24 +371,13 @@ fn pi_wrapper_import_failure_body(hermetic: &HermeticTestEnv) {
293
371
  workspace: &workspace,
294
372
  include_tools: &["send_message", "report_result"],
295
373
  })
296
- .expect("isolated wrapper ignores ambient config");
374
+ .expect("runtime wrapper leaves ambient config to direct Pi");
297
375
  let source = std::fs::read_to_string(&destination).expect("wrapper source");
298
376
  assert!(!source.contains("ambient-evil"));
299
377
  validate_pi_wrapper_source(&source, &adapter, &candidate)
300
- .expect("exact isolated import and candidate");
301
-
302
- let broken = source.replace(
303
- adapter.index_ts.to_string_lossy().as_ref(),
304
- "/missing/pi-mcp-adapter/index.ts",
305
- );
306
- let error = validate_pi_wrapper_source(&broken, &adapter, &candidate)
307
- .expect_err("broken import must refuse");
308
- let text = error.to_string();
309
- assert!(
310
- text.contains("import") || text.contains("adapter"),
311
- "got {text}"
312
- );
313
- assert!(!broken.contains("--mcp-config"));
378
+ .expect("runtime registration and exact candidate");
379
+ assert!(!source.contains("createMcpAdapter"));
380
+ assert!(!source.contains("--mcp-config"));
314
381
 
315
382
  let missing_proxy_tools = source.replace("\"includeTools\"", "\"missingTools\"");
316
383
  validate_pi_wrapper_source(&missing_proxy_tools, &adapter, &candidate)
@@ -56,8 +56,8 @@ fn resume_argv(path: &Path) -> Result<Vec<String>, ProviderError> {
56
56
  build_pi_command_argv(PiCommandRequest {
57
57
  executable: Path::new("/verified/pi"),
58
58
  extension: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/team-mcp.ts"),
59
- model: "team-agent/qwen3.8-27b",
60
- effort: ProviderEffort::Medium,
59
+ model: Some("team-agent/qwen3.8-27b"),
60
+ effort: Some(ProviderEffort::Medium),
61
61
  system_prompt: "worker contract",
62
62
  tool_categories: &["mcp_team"],
63
63
  session_dir: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/sessions"),
@@ -3,14 +3,13 @@
3
3
  //! contract:
4
4
  //! provides:
5
5
  //! - name: build_pi_command_argv
6
- //! what: 仅从已验证输入构造隔离的 regular-TUI Pi argv
6
+ //! what: 仅从已验证输入构造保留 direct Pi 配置的 Team Agent argv
7
7
  //! boundary:
8
8
  //! - 不发现 executable、catalog、adapter 或 MCP 配置
9
9
  //! - 除 resume exact backing 存在性外不读文件;不写文件、不启动进程
10
10
  //! maturity: wired
11
11
  //! ---
12
12
 
13
- use std::collections::BTreeSet;
14
13
  use std::path::Path;
15
14
 
16
15
  use crate::model::enums::ProviderEffort;
@@ -47,8 +46,8 @@ pub(crate) enum PiSessionSelector<'a> {
47
46
  pub(crate) struct PiCommandRequest<'a> {
48
47
  pub executable: &'a Path,
49
48
  pub extension: &'a Path,
50
- pub model: &'a str,
51
- pub effort: ProviderEffort,
49
+ pub model: Option<&'a str>,
50
+ pub effort: Option<ProviderEffort>,
52
51
  pub system_prompt: &'a str,
53
52
  pub tool_categories: &'a [&'a str],
54
53
  pub session_dir: &'a Path,
@@ -57,7 +56,7 @@ pub(crate) struct PiCommandRequest<'a> {
57
56
  }
58
57
 
59
58
  /// ---
60
- /// purpose: 从已验证 semantic request 构造无 ambient flag 的 Pi argv
59
+ /// purpose: 从已验证 semantic request 构造仅追加 Team Agent MCP/提示/session 的 Pi argv
61
60
  /// returns: exact ordered fresh/resume argv
62
61
  /// errors: 必需字段、mcp_team 或工具 category 非法时返回 ProviderError
63
62
  /// ---
@@ -67,23 +66,21 @@ pub(crate) fn build_pi_command_argv(
67
66
  if request.executable.as_os_str().is_empty()
68
67
  || request.extension.as_os_str().is_empty()
69
68
  || request.session_dir.as_os_str().is_empty()
70
- || request.model.trim().is_empty()
69
+ || request.model.is_some_and(|model| model.trim().is_empty())
71
70
  || request.system_prompt.trim().is_empty()
72
71
  || request.agent_id.trim().is_empty()
73
72
  {
74
73
  return Err(ProviderError::Command(
75
- "Pi command requires executable, extension, model, prompt, session directory, and agent id"
74
+ "Pi command requires executable, extension, prompt, session directory, and agent id"
76
75
  .to_string(),
77
76
  ));
78
77
  }
79
78
 
80
- let mut tools = BTreeSet::new();
79
+ let mut has_mcp = false;
81
80
  for category in request.tool_categories {
82
81
  match pi_tool_mapping(category) {
83
- PiToolMapping::Mcp => {
84
- tools.insert("mcp");
85
- }
86
- PiToolMapping::Builtin(names) => tools.extend(names.iter().copied()),
82
+ PiToolMapping::Mcp => has_mcp = true,
83
+ PiToolMapping::Builtin(_) => {}
87
84
  PiToolMapping::Unsupported => {
88
85
  return Err(ProviderError::Command(format!(
89
86
  "Pi does not support Team Agent tool category {category:?}"
@@ -91,7 +88,7 @@ pub(crate) fn build_pi_command_argv(
91
88
  }
92
89
  }
93
90
  }
94
- if !tools.contains("mcp") {
91
+ if !has_mcp {
95
92
  return Err(ProviderError::Command(
96
93
  "Pi command requires mcp_team".to_string(),
97
94
  ));
@@ -99,26 +96,23 @@ pub(crate) fn build_pi_command_argv(
99
96
 
100
97
  let mut argv = vec![
101
98
  request.executable.to_string_lossy().into_owned(),
102
- "--no-extensions".to_string(),
103
99
  "-e".to_string(),
104
100
  request.extension.to_string_lossy().into_owned(),
105
- "--no-approve".to_string(),
106
- "--no-context-files".to_string(),
107
- "--no-skills".to_string(),
108
- "--no-prompt-templates".to_string(),
109
- "--tui-mode".to_string(),
110
- "regular".to_string(),
111
- "--model".to_string(),
112
- request.model.to_string(),
113
- "--thinking".to_string(),
114
- request.effort.as_str().to_string(),
115
- "--system-prompt".to_string(),
101
+ ];
102
+ if let Some(model) = request.model {
103
+ argv.push("--model".to_string());
104
+ argv.push(model.to_string());
105
+ }
106
+ if let Some(effort) = request.effort {
107
+ argv.push("--thinking".to_string());
108
+ argv.push(effort.as_str().to_string());
109
+ }
110
+ argv.extend([
111
+ "--append-system-prompt".to_string(),
116
112
  request.system_prompt.to_string(),
117
- "--tools".to_string(),
118
- tools.into_iter().collect::<Vec<_>>().join(","),
119
113
  "--session-dir".to_string(),
120
114
  request.session_dir.to_string_lossy().into_owned(),
121
- ];
115
+ ]);
122
116
  // The shared lifecycle materializer always revalidates resume root/path/header.
123
117
  // Keep the pure argv builder usable before a fixture root is materialized, while
124
118
  // refusing a missing exact file once the recorded path's parent exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.69",
3
+ "version": "0.5.70",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.5.69",
24
- "@team-agent/cli-darwin-x64": "0.5.69",
25
- "@team-agent/cli-linux-x64": "0.5.69"
23
+ "@team-agent/cli-darwin-arm64": "0.5.70",
24
+ "@team-agent/cli-darwin-x64": "0.5.70",
25
+ "@team-agent/cli-linux-x64": "0.5.70"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",
@@ -2,7 +2,7 @@
2
2
  name: team-agent
3
3
  description: Use only when the user explicitly asks to start, operate, inspect, shutdown, or restart a Team Agent team. Treat the team-agent CLI as a sealed appliance.
4
4
  requires_team_agent: ">=0.5.0"
5
- last_verified_against: "0.5.66"
5
+ last_verified_against: "0.5.70"
6
6
  ---
7
7
 
8
8
  # Team Agent
@@ -42,6 +42,12 @@ Grok / `cursor_agent` have no JSONL turn-state reader (classify → Unknown).
42
42
 
43
43
  ## Provider Prep
44
44
 
45
+ ### Pi TeamMate roles
46
+
47
+ Use the standard role schema with `provider: pi`, a block-style `tools` list containing `mcp_team`, and the required boolean `dangerously_skip_permissions`. That role boolean is the only bypass setting; `TEAM.md` `dangerous_auto_approve` is compatibility input, not a second user-facing switch.
48
+
49
+ Pi TeamMates keep the same login, model, extensions/plugins, skills, context files, project trust, and tools as a direct `pi` run on that machine. Team Agent only appends the role contract, a per-seat Team MCP registration, and per-seat session storage; do not duplicate those direct Pi settings in the role. `model` and `effort` remain optional compatibility overrides, and explicit values are passed through.
50
+
45
51
  ### Cursor provider notes
46
52
 
47
53
  Frontmatter: `provider: cursor_agent` (not `cursor`; launcher verb is `team-agent cursor`), `auth_mode: subscription`, `name:` required (omit → `missing front matter field name`). Also required: `role:`, `tools:`, `dangerously_skip_permissions:` (bool). Subscription needs no `profile`.