@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/compiler.rs +27 -37
- package/crates/team-agent/src/leader/start.rs +1 -1
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +90 -82
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +2 -12
- package/crates/team-agent/src/lifecycle/restart/common.rs +2 -12
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +36 -19
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +58 -22
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +27 -9
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +94 -27
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +2 -2
- package/crates/team-agent/src/provider/adapters/pi.rs +22 -28
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +7 -1
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
|
|
396
|
-
let
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
641
|
+
"{}: Pi roles require a qualified exact model such as provider/model",
|
|
635
642
|
path.display()
|
|
636
643
|
))
|
|
637
644
|
})?;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
||
|
|
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
|
|
393
|
-
"
|
|
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
|
-
"
|
|
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
|
|
408
|
-
/// returns: wrapper
|
|
409
|
-
/// errors: identity
|
|
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
|
-
|
|
427
|
+
validate_pi_adapter_identity(adapter)?;
|
|
417
428
|
let candidate = candidate_executable.to_string_lossy();
|
|
418
|
-
if !source.contains(
|
|
419
|
-
|| !source.contains(
|
|
420
|
-
|| !source.contains("
|
|
421
|
-
|| !source.contains("
|
|
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
|
|
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 的
|
|
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:
|
|
492
|
-
/// errors:
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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"
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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 =
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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:
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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() ==
|
|
116
|
-
|
|
117
|
-
"
|
|
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!(
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
126
|
-
|
|
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("
|
|
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
|
|
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
|
|
198
|
-
|
|
199
|
-
.
|
|
200
|
-
|
|
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
|
|
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\
|
|
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
|
|
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
|
-
|
|
275
|
-
"
|
|
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,
|
|
7
|
-
validate_pi_wrapper_source, write_pi_wrapper, PiAdapterIdentity,
|
|
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
|
|
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
|
|
28
|
+
const PI_WRAPPER_AMBIENT_TEST: &str = concat!(
|
|
28
29
|
"lifecycle::tests::pi_executable_mcp_red::",
|
|
29
|
-
"
|
|
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("
|
|
242
|
-
assert!(source.contains("
|
|
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
|
|
263
|
-
run_process_isolated(
|
|
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("
|
|
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("
|
|
301
|
-
|
|
302
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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 !
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
"--
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
"--
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.5.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.5.
|
|
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.
|
|
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`.
|