@team-agent/installer 0.5.69 → 0.5.71

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.71"
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.71"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -3393,6 +3393,40 @@ pub mod lifecycle_port {
3393
3393
  Some(crate::cli::QUICK_START_REMINDER)
3394
3394
  );
3395
3395
  }
3396
+
3397
+ #[test]
3398
+ fn spawn_failure_without_session_does_not_claim_a_worker_pane_exists() {
3399
+ let value = restart_value(
3400
+ crate::lifecycle::RestartReport::Failed {
3401
+ session_name: crate::transport::SessionName::new("team-current"),
3402
+ failed_agents: vec![crate::lifecycle::RestartFailedAgent {
3403
+ agent_id: crate::model::ids::AgentId::new("coder"),
3404
+ restart_mode: crate::lifecycle::StartMode::Fresh,
3405
+ decision: crate::lifecycle::ResumeDecision::FreshStart,
3406
+ session_id: None,
3407
+ phase: "spawn".to_string(),
3408
+ error: "provider io error".to_string(),
3409
+ }],
3410
+ next_actions: Vec::new(),
3411
+ attach_commands: Vec::new(),
3412
+ },
3413
+ None,
3414
+ );
3415
+ let action = value
3416
+ .pointer("/failed_agents/0/action")
3417
+ .and_then(Value::as_str)
3418
+ .expect("spawn failure action");
3419
+
3420
+ assert!(
3421
+ action.contains("before a pane was created"),
3422
+ "action={action}"
3423
+ );
3424
+ assert!(
3425
+ !action.contains("inspect worker coder output"),
3426
+ "action={action}"
3427
+ );
3428
+ assert!(!action.contains("restart-agent"), "action={action}");
3429
+ }
3396
3430
  }
3397
3431
 
3398
3432
  fn restart_value(report: crate::lifecycle::RestartReport, team: Option<&str>) -> Value {
@@ -3436,10 +3470,10 @@ pub mod lifecycle_port {
3436
3470
  "session_id": failure.session_id.as_ref().map(|session| session.as_str()),
3437
3471
  "phase": failure.phase,
3438
3472
  "error": failure.error,
3439
- "action": format!(
3440
- "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
3441
- failure.agent_id,
3442
- failure.agent_id
3473
+ "action": crate::lifecycle::restart_failure_action(
3474
+ &failure.agent_id,
3475
+ &failure.phase,
3476
+ failure.session_id.as_ref(),
3443
3477
  ),
3444
3478
  "log": format!(
3445
3479
  ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
@@ -3470,10 +3504,10 @@ pub mod lifecycle_port {
3470
3504
  "session_id": failure.session_id.as_ref().map(|session| session.as_str()),
3471
3505
  "phase": failure.phase,
3472
3506
  "error": failure.error,
3473
- "action": format!(
3474
- "inspect worker {} output, then restart that worker with `team-agent restart-agent {}` or rerun `team-agent restart --allow-fresh`",
3475
- failure.agent_id,
3476
- failure.agent_id
3507
+ "action": crate::lifecycle::restart_failure_action(
3508
+ &failure.agent_id,
3509
+ &failure.phase,
3510
+ failure.session_id.as_ref(),
3477
3511
  ),
3478
3512
  "log": format!(
3479
3513
  ".team/logs/coordinator.log and .team/runtime/state.json agent={}",
@@ -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,66 +383,91 @@ 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
  /// ---
444
452
  pub(crate) fn write_pi_wrapper(request: PiWrapperRequest<'_>) -> Result<PathBuf, ProviderError> {
453
+ write_pi_wrapper_with_publish(request, |from, to| std::fs::rename(from, to))
454
+ }
455
+
456
+ pub(crate) fn write_pi_wrapper_with_publish(
457
+ request: PiWrapperRequest<'_>,
458
+ publish: impl FnOnce(&Path, &Path) -> std::io::Result<()>,
459
+ ) -> Result<PathBuf, ProviderError> {
445
460
  let source = render_pi_wrapper(&request)?;
446
461
  validate_pi_wrapper_source(&source, request.adapter, request.candidate_executable)?;
447
462
  let parent = request.destination.parent().ok_or_else(|| {
448
463
  ProviderError::Io("Pi wrapper destination has no parent directory".to_string())
449
464
  })?;
450
- std::fs::create_dir_all(parent)
451
- .map_err(|error| ProviderError::Io(format!("{}: {error}", parent.display())))?;
465
+ std::fs::create_dir_all(parent).map_err(|error| {
466
+ ProviderError::Io(format!(
467
+ "Pi wrapper atomic write stage=create_parent path={}: {error}",
468
+ parent.display()
469
+ ))
470
+ })?;
452
471
  let temp = parent.join(format!(".team-mcp.ts.{}.tmp", std::process::id()));
453
472
  let write_result = (|| {
454
473
  let mut file = OpenOptions::new()
@@ -456,22 +475,38 @@ pub(crate) fn write_pi_wrapper(request: PiWrapperRequest<'_>) -> Result<PathBuf,
456
475
  .write(true)
457
476
  .mode(0o600)
458
477
  .open(&temp)
459
- .map_err(|error| ProviderError::Io(format!("{}: {error}", temp.display())))?;
460
- file.write_all(source.as_bytes())
461
- .map_err(|error| ProviderError::Io(format!("{}: {error}", temp.display())))?;
462
- file.sync_all()
463
- .map_err(|error| ProviderError::Io(format!("{}: {error}", temp.display())))?;
464
- std::fs::rename(&temp, request.destination).map_err(|error| {
478
+ .map_err(|error| {
479
+ ProviderError::Io(format!(
480
+ "Pi wrapper atomic write stage=open_temp path={}: {error}",
481
+ temp.display()
482
+ ))
483
+ })?;
484
+ file.write_all(source.as_bytes()).map_err(|error| {
465
485
  ProviderError::Io(format!(
466
- "rename {} to {}: {error}",
467
- temp.display(),
468
- request.destination.display()
486
+ "Pi wrapper atomic write stage=write_temp path={}: {error}",
487
+ temp.display()
469
488
  ))
470
489
  })?;
471
- std::fs::set_permissions(request.destination, std::fs::Permissions::from_mode(0o600))
490
+ file.set_permissions(std::fs::Permissions::from_mode(0o600))
472
491
  .map_err(|error| {
473
- ProviderError::Io(format!("{}: {error}", request.destination.display()))
492
+ ProviderError::Io(format!(
493
+ "Pi wrapper atomic write stage=chmod_temp_fd path={}: {error}",
494
+ temp.display()
495
+ ))
474
496
  })?;
497
+ file.sync_all().map_err(|error| {
498
+ ProviderError::Io(format!(
499
+ "Pi wrapper atomic write stage=fsync_temp path={}: {error}",
500
+ temp.display()
501
+ ))
502
+ })?;
503
+ publish(&temp, request.destination).map_err(|error| {
504
+ ProviderError::Io(format!(
505
+ "Pi wrapper atomic write stage=rename_publish from={} to={}: {error}",
506
+ temp.display(),
507
+ request.destination.display()
508
+ ))
509
+ })?;
475
510
  Ok(request.destination.to_path_buf())
476
511
  })();
477
512
  if write_result.is_err() {
@@ -482,14 +517,14 @@ pub(crate) fn write_pi_wrapper(request: PiWrapperRequest<'_>) -> Result<PathBuf,
482
517
 
483
518
  #[derive(Debug, Clone, PartialEq, Eq)]
484
519
  pub(crate) struct PiLeaderArgs {
485
- pub model: String,
486
- pub effort: ProviderEffort,
520
+ pub model: Option<String>,
521
+ pub effort: Option<ProviderEffort>,
487
522
  }
488
523
 
489
524
  /// ---
490
525
  /// purpose: 解析 Pi leader 唯一允许的 model/thinking 输入
491
- /// returns: 显式 qualified model 与 effort
492
- /// errors: 缺失、重复、未知或 materializer-owned flag 时返回 ProviderError
526
+ /// returns: 仅保留调用方显式给出的 qualified model 与 effort
527
+ /// errors: 重复、未知或 materializer-owned flag 时返回 ProviderError
493
528
  /// ---
494
529
  pub(crate) fn parse_pi_leader_args(args: &[String]) -> Result<PiLeaderArgs, ProviderError> {
495
530
  let args = args.strip_prefix(&["--".to_string()]).unwrap_or(args);
@@ -519,24 +554,17 @@ pub(crate) fn parse_pi_leader_args(args: &[String]) -> Result<PiLeaderArgs, Prov
519
554
  }
520
555
  index += 2;
521
556
  }
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
- {
557
+ if model.as_ref().is_some_and(|model| {
558
+ model.contains('*')
559
+ || !model
560
+ .split_once('/')
561
+ .is_some_and(|(provider, name)| !provider.is_empty() && !name.is_empty())
562
+ }) {
530
563
  return Err(ProviderError::Command(
531
564
  "Pi leader --model must be a qualified exact id".to_string(),
532
565
  ));
533
566
  }
534
- Ok(PiLeaderArgs {
535
- model,
536
- effort: effort.ok_or_else(|| {
537
- ProviderError::Command("Pi leader requires an explicit --thinking".to_string())
538
- })?,
539
- })
567
+ Ok(PiLeaderArgs { model, effort })
540
568
  }
541
569
 
542
570
  fn sha256(bytes: &[u8]) -> String {
@@ -598,7 +626,7 @@ fn command_stdout(executable: &Path, args: &[&str]) -> Result<Vec<u8>, ProviderE
598
626
  }
599
627
 
600
628
  fn discover_pi_adapter(launch_executable: &Path) -> Result<PiAdapterIdentity, ProviderError> {
601
- let listing = command_stdout(launch_executable, &["list", "--no-approve"])?;
629
+ let listing = command_stdout(launch_executable, &["list"])?;
602
630
  let listing = std::str::from_utf8(&listing).map_err(|error| {
603
631
  ProviderError::Command(format!("Pi package list is not UTF-8: {error}"))
604
632
  })?;
@@ -660,16 +688,16 @@ fn discover_pi_adapter(launch_executable: &Path) -> Result<PiAdapterIdentity, Pr
660
688
  pub(crate) fn resolve_pi_executable_chain(
661
689
  ) -> Result<(PiExecutableChain, Vec<String>), ProviderError> {
662
690
  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;
691
+ let path_entry = path_entries[0].clone();
692
+ let path_entry_type = executable_file_type(&path_entry)?;
693
+ if !matches!(
694
+ path_entry_type,
695
+ PiExecutableFileType::Wrapper | PiExecutableFileType::Symlink
696
+ ) {
697
+ return Err(ProviderError::Command(
698
+ "the direct Pi PATH entry is not a verified wrapper or npm symlink".to_string(),
699
+ ));
700
+ }
673
701
  let version =
674
702
  String::from_utf8(command_stdout(&path_entry, &["--version"])?).map_err(|error| {
675
703
  ProviderError::Command(format!("Pi version output is not UTF-8: {error}"))
@@ -677,17 +705,21 @@ pub(crate) fn resolve_pi_executable_chain(
677
705
  let version = version.trim().to_string();
678
706
  let launch_identity = std::fs::canonicalize(&path_entry)
679
707
  .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
- })?;
708
+ let real_binary = if path_entry_type == PiExecutableFileType::Symlink {
709
+ launch_identity
710
+ } else {
711
+ path_entries
712
+ .iter()
713
+ .find_map(|candidate| {
714
+ let real = std::fs::canonicalize(candidate).ok()?;
715
+ (real != launch_identity).then_some(real)
716
+ })
717
+ .ok_or_else(|| {
718
+ ProviderError::Command(
719
+ "Pi PATH wrapper has no independently resolved real executable".to_string(),
720
+ )
721
+ })?
722
+ };
691
723
  let _real_version =
692
724
  String::from_utf8(command_stdout(&real_binary, &["--version"])?).map_err(|error| {
693
725
  ProviderError::Command(format!(
@@ -714,8 +746,8 @@ pub(crate) struct PiMaterializeRequest<'a> {
714
746
  pub workspace: &'a Path,
715
747
  pub team_id: &'a str,
716
748
  pub agent_id: &'a str,
717
- pub model: &'a str,
718
- pub effort: ProviderEffort,
749
+ pub model: Option<&'a str>,
750
+ pub effort: Option<ProviderEffort>,
719
751
  pub system_prompt: &'a str,
720
752
  pub tool_categories: &'a [&'a str],
721
753
  pub team_mcp_tools: &'a [&'a str],
@@ -752,7 +784,10 @@ fn materialize_pi_plan_with_session(
752
784
  resume: Option<(&SessionId, &Path, &Path)>,
753
785
  ) -> Result<CommandPlan, ProviderError> {
754
786
  let (chain, catalog) = resolve_pi_executable_chain()?;
755
- let model = select_exact_pi_model(&catalog, request.model)?;
787
+ let model = request
788
+ .model
789
+ .map(|model| select_exact_pi_model(&catalog, model))
790
+ .transpose()?;
756
791
  let paths = pi_seat_paths(request.workspace, request.team_id, request.agent_id);
757
792
  let resume = if let Some((session_id, session_path, spawn_cwd)) = resume {
758
793
  let session_root = std::fs::canonicalize(&paths.sessions).map_err(|error| {
@@ -795,7 +830,7 @@ fn materialize_pi_plan_with_session(
795
830
  let argv = build_pi_command_argv(PiCommandRequest {
796
831
  executable: &chain.launch_executable,
797
832
  extension: &wrapper,
798
- model: &model,
833
+ model: model.as_deref(),
799
834
  effort: request.effort,
800
835
  system_prompt: request.system_prompt,
801
836
  tool_categories: request.tool_categories,
@@ -809,7 +844,7 @@ fn materialize_pi_plan_with_session(
809
844
  let argv = build_pi_command_argv(PiCommandRequest {
810
845
  executable: &chain.launch_executable,
811
846
  extension: &wrapper,
812
- model: &model,
847
+ model: model.as_deref(),
813
848
  effort: request.effort,
814
849
  system_prompt: request.system_prompt,
815
850
  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"],