@team-agent/installer 0.5.71 → 0.5.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +107 -9
  4. package/crates/team-agent/src/cli/emit.rs +35 -5
  5. package/crates/team-agent/src/cli/mod.rs +78 -7
  6. package/crates/team-agent/src/cli/models.rs +443 -0
  7. package/crates/team-agent/src/cli/spec.rs +4 -1
  8. package/crates/team-agent/src/cli/types.rs +8 -0
  9. package/crates/team-agent/src/compiler.rs +157 -0
  10. package/crates/team-agent/src/leader/owner_bind.rs +21 -0
  11. package/crates/team-agent/src/leader/provider_attribution.rs +25 -0
  12. package/crates/team-agent/src/leader/registry.rs +25 -1
  13. package/crates/team-agent/src/leader/start.rs +41 -9
  14. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +169 -10
  15. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +119 -9
  16. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +859 -12
  17. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -0
  18. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -0
  19. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +168 -0
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +209 -0
  21. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +1 -1
  22. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +71 -1
  23. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +3 -1
  24. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +396 -3
  25. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +3 -1
  26. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  27. package/crates/team-agent/src/provider/adapters/pi.rs +12 -6
  28. package/npm/check-skill-links.mjs +88 -0
  29. package/package.json +5 -4
  30. package/skills/team-agent/SKILL.md +15 -49
  31. package/skills/team-agent/command-coverage.json +8 -1
  32. package/skills/team-agent/references/team-agent-operator.md +665 -0
@@ -2,13 +2,19 @@ use std::os::unix::fs::PermissionsExt;
2
2
  use std::path::{Path, PathBuf};
3
3
  use std::sync::atomic::{AtomicU32, Ordering};
4
4
 
5
+ use crate::lifecycle::launch::launch_with_transport_in_workspace;
5
6
  use crate::lifecycle::launch::pi_mcp::{
7
+ PiAdapterIdentity, PiExecutableChain, PiExecutableFileType, PiMaterializeRequest,
8
+ PiSessionScope, PiWrapperRequest, materialize_pi_plan, materialize_pi_resume_plan,
6
9
  parse_pi_list_models_table, pi_seat_paths, resolve_pi_executable_chain,
7
10
  validate_pi_adapter_identity, validate_pi_executable_chain, validate_pi_wrapper_source,
8
- write_pi_wrapper, write_pi_wrapper_with_publish, PiAdapterIdentity, PiExecutableChain,
9
- PiExecutableFileType, PiWrapperRequest,
11
+ write_pi_wrapper, write_pi_wrapper_with_publish,
10
12
  };
11
- use crate::provider::McpConfig;
13
+ use crate::lifecycle::start_agent_with_transport;
14
+ use crate::model::enums::ProviderEffort;
15
+ use crate::provider::session_scan::{CaptureSessionContext, scan_session_candidates_once};
16
+ use crate::provider::{McpConfig, Provider, SessionId};
17
+ use crate::transport::test_support::OfflineTransport;
12
18
 
13
19
  #[path = "../../../tests/support/hermetic.rs"]
14
20
  mod hermetic_guard;
@@ -34,6 +40,11 @@ const PI_SYMLINK_TEST: &str = concat!(
34
40
  "lifecycle::tests::pi_executable_mcp_red::",
35
41
  "pi_standard_npm_symlink_is_a_verified_launch_entry"
36
42
  );
43
+ const PI_MATERIALIZER_CHILD: &str = "TEAM_AGENT_TEST_PI_MATERIALIZER_CHILD";
44
+ const PI_MATERIALIZER_TEST: &str = concat!(
45
+ "lifecycle::tests::pi_executable_mcp_red::",
46
+ "pi_materializer_and_worker_routes_use_the_recorded_scope"
47
+ );
37
48
 
38
49
  fn run_process_isolated(marker: &str, test_name: &str, body: impl FnOnce()) {
39
50
  if std::env::var_os(marker).is_some() {
@@ -504,3 +515,385 @@ fn pi_wrapper_import_failure_body(hermetic: &HermeticTestEnv) {
504
515
 
505
516
  std::fs::remove_dir_all(root).expect("remove wrapper fixture");
506
517
  }
518
+
519
+ #[test]
520
+ fn pi_materializer_and_worker_routes_use_the_recorded_scope() {
521
+ run_process_isolated(PI_MATERIALIZER_CHILD, PI_MATERIALIZER_TEST, || {
522
+ let hermetic = HermeticTestEnv::enter("pi-materializer-scope");
523
+ assert_isolated_child();
524
+ pi_materializer_and_worker_routes_body(&hermetic);
525
+ });
526
+ }
527
+
528
+ fn pi_materializer_and_worker_routes_body(hermetic: &HermeticTestEnv) {
529
+ let fixture_root = hermetic.root().join("materializer-fixture");
530
+ let bin = fixture_root.join("bin");
531
+ let adapter_root = fixture_root.join("pi-mcp-adapter");
532
+ let candidate = fixture_root.join("candidate/team-agent");
533
+ let real = fixture_root.join("pi-real");
534
+ std::fs::create_dir_all(&bin).expect("create Pi fixture bin");
535
+ std::fs::create_dir_all(&adapter_root).expect("create Pi adapter fixture");
536
+ std::fs::create_dir_all(candidate.parent().expect("candidate parent"))
537
+ .expect("create candidate parent");
538
+ std::fs::write(&candidate, b"candidate").expect("write candidate fixture");
539
+ std::fs::write(
540
+ adapter_root.join("package.json"),
541
+ br#"{"name":"pi-mcp-adapter","version":"2.30.0","pi":{"extensions":["./index.ts"]}}"#,
542
+ )
543
+ .expect("write adapter package");
544
+ std::fs::write(
545
+ adapter_root.join("index.ts"),
546
+ b"export const createMcpAdapter = () => {};\n",
547
+ )
548
+ .expect("write adapter entry");
549
+ let pi_script = format!(
550
+ "#!/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 0 ;;\nesac\n",
551
+ adapter_root.display()
552
+ );
553
+ std::fs::write(&real, &pi_script).expect("write Pi real fixture");
554
+ std::fs::set_permissions(&real, std::fs::Permissions::from_mode(0o755))
555
+ .expect("make Pi real fixture executable");
556
+ std::os::unix::fs::symlink(&real, bin.join("pi")).expect("create Pi PATH symlink");
557
+ let _path = hermetic.with_env(
558
+ "PATH",
559
+ std::env::join_paths([&bin])
560
+ .expect("join Pi fixture PATH")
561
+ .to_str()
562
+ .expect("Pi fixture PATH is UTF-8"),
563
+ );
564
+
565
+ let native_workspace = hermetic.workspace("native");
566
+ let native_paths = pi_seat_paths(&native_workspace, "team-a", "leader");
567
+ let native_config = mcp_config(&candidate, &native_workspace, "leader");
568
+ let native = materialize_pi_plan(PiMaterializeRequest {
569
+ workspace: &native_workspace,
570
+ team_id: "team-a",
571
+ agent_id: "leader",
572
+ model: None,
573
+ effort: None,
574
+ system_prompt: "native leader fixture",
575
+ tool_categories: &["mcp_team"],
576
+ team_mcp_tools: &["send_message"],
577
+ mcp_config: &native_config,
578
+ session_scope: PiSessionScope::NativeDefault,
579
+ })
580
+ .expect("NativeDefault materializer");
581
+ assert!(
582
+ native.provider_projects_root.is_none(),
583
+ "NativeDefault must not expose a provider capture root: {native:?}"
584
+ );
585
+ assert!(
586
+ !native_paths.sessions.exists(),
587
+ "NativeDefault must not create the Team Agent seat session root"
588
+ );
589
+ for forbidden in ["--session-dir", "--cwd", "--workspace", "--session"] {
590
+ assert!(
591
+ !native.argv.iter().any(|arg| arg == forbidden),
592
+ "NativeDefault fresh argv must omit {forbidden}: {:?}",
593
+ native.argv
594
+ );
595
+ }
596
+
597
+ let isolated_workspace = hermetic.workspace("isolated");
598
+ let isolated_paths = pi_seat_paths(&isolated_workspace, "team-a", "worker-a");
599
+ let isolated_config = mcp_config(&candidate, &isolated_workspace, "worker-a");
600
+ let isolated = materialize_pi_plan(PiMaterializeRequest {
601
+ workspace: &isolated_workspace,
602
+ team_id: "team-a",
603
+ agent_id: "worker-a",
604
+ model: Some("team-agent/qwen3.8-27b"),
605
+ effort: Some(ProviderEffort::Medium),
606
+ system_prompt: "isolated worker fixture",
607
+ tool_categories: &["mcp_team"],
608
+ team_mcp_tools: &["send_message"],
609
+ mcp_config: &isolated_config,
610
+ session_scope: PiSessionScope::Isolated,
611
+ })
612
+ .expect("Isolated materializer");
613
+ assert_eq!(
614
+ isolated.provider_projects_root.as_deref(),
615
+ Some(isolated_paths.sessions.as_path())
616
+ );
617
+ assert_session_pair(&isolated.argv, &isolated_paths.sessions);
618
+ assert!(!isolated.argv.iter().any(|arg| arg == "--cwd"));
619
+ assert!(!isolated.argv.iter().any(|arg| arg == "--workspace"));
620
+
621
+ let session_id = SessionId::new("pi-materializer-resume");
622
+ let exact = isolated_paths.sessions.join("2026/09/exact.jsonl");
623
+ std::fs::create_dir_all(exact.parent().expect("exact session parent"))
624
+ .expect("create exact session parent");
625
+ std::fs::write(&exact, b"{}\n").expect("write exact session fixture");
626
+ let resumed = materialize_pi_resume_plan(
627
+ PiMaterializeRequest {
628
+ workspace: &isolated_workspace,
629
+ team_id: "team-a",
630
+ agent_id: "worker-a",
631
+ model: None,
632
+ effort: None,
633
+ system_prompt: "isolated resume fixture",
634
+ tool_categories: &["mcp_team"],
635
+ team_mcp_tools: &["send_message"],
636
+ mcp_config: &isolated_config,
637
+ session_scope: PiSessionScope::Isolated,
638
+ },
639
+ &session_id,
640
+ &exact,
641
+ &isolated_workspace,
642
+ )
643
+ .expect("Isolated exact-path resume materializer");
644
+ assert_eq!(
645
+ resumed.provider_projects_root.as_deref(),
646
+ Some(isolated_paths.sessions.as_path())
647
+ );
648
+ assert_session_pair(&resumed.argv, &isolated_paths.sessions);
649
+ assert!(
650
+ resumed
651
+ .argv
652
+ .windows(2)
653
+ .any(|pair| { pair == ["--session", exact.to_string_lossy().as_ref()] })
654
+ );
655
+
656
+ let native_resume = materialize_pi_resume_plan(
657
+ PiMaterializeRequest {
658
+ workspace: &native_workspace,
659
+ team_id: "team-a",
660
+ agent_id: "leader",
661
+ model: None,
662
+ effort: None,
663
+ system_prompt: "native resume fixture",
664
+ tool_categories: &["mcp_team"],
665
+ team_mcp_tools: &["send_message"],
666
+ mcp_config: &native_config,
667
+ session_scope: PiSessionScope::NativeDefault,
668
+ },
669
+ &session_id,
670
+ &exact,
671
+ &native_workspace,
672
+ );
673
+ assert!(
674
+ matches!(
675
+ native_resume,
676
+ Err(crate::provider::ProviderError::ResumeUnavailable(_))
677
+ ),
678
+ "NativeDefault resume must fail closed before emitting a native HOME path: {native_resume:?}"
679
+ );
680
+
681
+ let home_candidate = hermetic.home().join(".pi/agent/sessions/home.jsonl");
682
+ std::fs::create_dir_all(home_candidate.parent().expect("home session parent"))
683
+ .expect("create home session parent");
684
+ std::fs::write(&home_candidate, b"{}\n").expect("write home candidate");
685
+ let other_paths = pi_seat_paths(&isolated_workspace, "team-a", "worker-b");
686
+ let other_candidate = other_paths.sessions.join("other.jsonl");
687
+ std::fs::create_dir_all(other_candidate.parent().expect("other session parent"))
688
+ .expect("create other session parent");
689
+ std::fs::write(&other_candidate, b"{}\n").expect("write other seat candidate");
690
+ for (label, outside) in [("HOME", &home_candidate), ("other seat", &other_candidate)] {
691
+ let refused = materialize_pi_resume_plan(
692
+ PiMaterializeRequest {
693
+ workspace: &isolated_workspace,
694
+ team_id: "team-a",
695
+ agent_id: "worker-a",
696
+ model: None,
697
+ effort: None,
698
+ system_prompt: "isolated negative resume fixture",
699
+ tool_categories: &["mcp_team"],
700
+ team_mcp_tools: &["send_message"],
701
+ mcp_config: &isolated_config,
702
+ session_scope: PiSessionScope::Isolated,
703
+ },
704
+ &session_id,
705
+ &outside,
706
+ &isolated_workspace,
707
+ );
708
+ assert!(
709
+ matches!(
710
+ refused,
711
+ Err(crate::provider::ProviderError::ResumeUnavailable(_))
712
+ ),
713
+ "{label} candidate must be refused by the exact seat-root check: {refused:?}"
714
+ );
715
+ }
716
+
717
+ let worker_workspace = hermetic.workspace("worker-spawn");
718
+ let spec_path = worker_workspace
719
+ .join(".team")
720
+ .join("team-a")
721
+ .join("team.spec.yaml");
722
+ std::fs::create_dir_all(spec_path.parent().expect("worker Pi spec parent"))
723
+ .expect("create worker Pi spec parent");
724
+ let spec = crate::model::yaml::loads(
725
+ "team:\n name: team-a\nruntime:\n session_name: team-workers\n display_backend: tmux_attach\nagents:\n - id: worker-a\n role: Worker A\n provider: pi\n model: team-agent/qwen3.8-27b\n auth_mode: subscription\n dangerously_skip_permissions: false\n tools:\n - mcp_team\n - id: worker-b\n role: Worker B\n provider: pi\n model: team-agent/qwen3.8-27b\n auth_mode: subscription\n dangerously_skip_permissions: false\n tools:\n - mcp_team\n",
726
+ )
727
+ .expect("parse worker Pi spec");
728
+ std::fs::write(&spec_path, crate::model::yaml::dumps(&spec)).expect("write worker Pi spec");
729
+ let transport = OfflineTransport::new();
730
+ let started = launch_with_transport_in_workspace(
731
+ &worker_workspace,
732
+ &spec_path,
733
+ false,
734
+ true,
735
+ true,
736
+ &transport,
737
+ )
738
+ .expect("worker fresh spawn construction")
739
+ .started;
740
+ assert_eq!(started.len(), 2, "both Pi worker seats must be spawned");
741
+ for worker in ["worker-a", "worker-b"] {
742
+ let paths = pi_seat_paths(&worker_workspace, "team-a", worker);
743
+ let started_agent = started
744
+ .iter()
745
+ .find(|entry| entry.agent_id.as_str() == worker)
746
+ .expect("started worker entry");
747
+ assert_eq!(
748
+ started_agent.provider_projects_root.as_deref(),
749
+ Some(paths.sessions.as_path()),
750
+ "fresh worker must expose its exact seat capture root"
751
+ );
752
+ let record = transport
753
+ .spawn_records()
754
+ .into_iter()
755
+ .find(|(_, argv)| {
756
+ argv.iter()
757
+ .any(|arg| arg.as_str() == paths.wrapper.to_string_lossy().as_ref())
758
+ })
759
+ .expect("transport spawn record for worker seat");
760
+ assert_session_pair(&record.1, &paths.sessions);
761
+ assert!(
762
+ !record
763
+ .1
764
+ .iter()
765
+ .any(|arg| arg.as_str() == home_candidate.to_string_lossy().as_ref())
766
+ );
767
+ assert!(
768
+ !record
769
+ .1
770
+ .iter()
771
+ .any(|arg| arg.as_str() == other_candidate.to_string_lossy().as_ref())
772
+ );
773
+
774
+ let pending_session_id = started_agent
775
+ .pending_session_id
776
+ .clone()
777
+ .expect("fresh worker carries a pending Pi session id");
778
+ write_pi_session_header(
779
+ &home_candidate,
780
+ pending_session_id.as_str(),
781
+ &worker_workspace,
782
+ "2099-01-01T00:00:00Z",
783
+ );
784
+ let other_worker = if worker == "worker-a" {
785
+ "worker-b"
786
+ } else {
787
+ "worker-a"
788
+ };
789
+ let cross_seat = pi_seat_paths(&worker_workspace, "team-a", other_worker)
790
+ .sessions
791
+ .join("foreign.jsonl");
792
+ write_pi_session_header(
793
+ &cross_seat,
794
+ pending_session_id.as_str(),
795
+ &worker_workspace,
796
+ "2099-01-01T00:00:00Z",
797
+ );
798
+ let candidates = scan_session_candidates_once(
799
+ Provider::Pi,
800
+ &CaptureSessionContext {
801
+ agent_id: worker.to_string(),
802
+ spawn_cwd: worker_workspace.clone(),
803
+ pane_id: None,
804
+ pane_pid: None,
805
+ spawned_at: Some(started_agent.spawned_at.clone()),
806
+ expected_session_id: Some(pending_session_id),
807
+ provider_projects_root: Some(paths.sessions.clone()),
808
+ },
809
+ )
810
+ .expect("scan the recorded worker seat only");
811
+ assert!(
812
+ candidates.is_empty(),
813
+ "HOME and another worker seat must not become capture candidates: {candidates:?}"
814
+ );
815
+ }
816
+
817
+ let restart_workspace = hermetic.workspace("worker-restart");
818
+ let restart_paths = pi_seat_paths(&restart_workspace, "team-a", "worker-a");
819
+ let restart_exact = restart_paths.sessions.join("2026/09/restart.jsonl");
820
+ write_pi_session_header(
821
+ &restart_exact,
822
+ "pi-restart-session",
823
+ &restart_workspace,
824
+ "2026-09-01T00:00:01Z",
825
+ );
826
+ crate::state::persist::save_runtime_state(
827
+ &restart_workspace,
828
+ &serde_json::json!({
829
+ "session_name": "team-workers",
830
+ "active_team_key": "team-a",
831
+ "agents": {
832
+ "worker-a": {
833
+ "id": "worker-a",
834
+ "status": "running",
835
+ "provider": "pi",
836
+ "role": "Worker A",
837
+ "model": "team-agent/qwen3.8-27b",
838
+ "auth_mode": "subscription",
839
+ "tools": ["mcp_team"],
840
+ "session_id": "pi-restart-session",
841
+ "rollout_path": restart_exact.to_string_lossy(),
842
+ "spawn_cwd": restart_workspace.to_string_lossy(),
843
+ "first_send_at": "2026-09-01T00:00:00Z"
844
+ }
845
+ }
846
+ }),
847
+ )
848
+ .expect("write restart state");
849
+ let restart_transport = OfflineTransport::new();
850
+ let _ = start_agent_with_transport(
851
+ &restart_workspace,
852
+ &crate::model::ids::AgentId::new("worker-a"),
853
+ false,
854
+ false,
855
+ false,
856
+ None,
857
+ &restart_transport,
858
+ );
859
+ let restart_record = restart_transport
860
+ .spawn_records()
861
+ .into_iter()
862
+ .next()
863
+ .expect("restart must reach the worker spawn construction");
864
+ assert_session_pair(&restart_record.1, &restart_paths.sessions);
865
+ assert!(
866
+ restart_record
867
+ .1
868
+ .windows(2)
869
+ .any(|pair| { pair == ["--session", restart_exact.to_string_lossy().as_ref()] })
870
+ );
871
+ assert!(
872
+ !restart_record
873
+ .1
874
+ .iter()
875
+ .any(|arg| arg.as_str() == home_candidate.to_string_lossy().as_ref())
876
+ );
877
+ }
878
+
879
+ fn assert_session_pair(argv: &[String], session_root: &Path) {
880
+ let expected = session_root.to_string_lossy();
881
+ assert!(
882
+ argv.windows(2)
883
+ .any(|pair| pair[0] == "--session-dir" && pair[1].as_str() == expected.as_ref()),
884
+ "argv must contain exact --session-dir pair for {}: {argv:?}",
885
+ session_root.display()
886
+ );
887
+ }
888
+
889
+ fn write_pi_session_header(path: &Path, id: &str, cwd: &Path, timestamp: &str) {
890
+ std::fs::create_dir_all(path.parent().expect("session parent")).expect("create session parent");
891
+ let header = serde_json::json!({
892
+ "type": "session",
893
+ "version": 3,
894
+ "id": id,
895
+ "cwd": cwd,
896
+ "timestamp": timestamp,
897
+ });
898
+ std::fs::write(path, format!("{header}\n")).expect("write Pi session header");
899
+ }
@@ -60,7 +60,9 @@ fn resume_argv(path: &Path) -> Result<Vec<String>, ProviderError> {
60
60
  effort: Some(ProviderEffort::Medium),
61
61
  system_prompt: "worker contract",
62
62
  tool_categories: &["mcp_team"],
63
- session_dir: Path::new("/workspace/.team/runtime/pi/team-a/worker-a/sessions"),
63
+ session_dir: Some(Path::new(
64
+ "/workspace/.team/runtime/pi/team-a/worker-a/sessions",
65
+ )),
64
66
  session: PiSessionSelector::Resume { path },
65
67
  agent_id: "worker-a",
66
68
  })
@@ -495,6 +495,14 @@ pub enum LifecycleError {
495
495
  /// 编译 spec / role doc 失败(`compile_team`/`compile_role_doc_agent`)。
496
496
  #[error("spec compile failed: {0}")]
497
497
  Compile(String),
498
+ /// Pi model/schema admission failed before lifecycle reservation or writes.
499
+ #[error("pi model preflight failed: {requested}")]
500
+ PiModelPreflight {
501
+ requested: String,
502
+ candidates: Vec<String>,
503
+ action: String,
504
+ not_ready: bool,
505
+ },
498
506
  /// provider 命令构造 / resume 不可用(`ResumeUnavailable`)。
499
507
  #[error("provider error: {0}")]
500
508
  Provider(String),
@@ -50,7 +50,8 @@ pub(crate) struct PiCommandRequest<'a> {
50
50
  pub effort: Option<ProviderEffort>,
51
51
  pub system_prompt: &'a str,
52
52
  pub tool_categories: &'a [&'a str],
53
- pub session_dir: &'a Path,
53
+ /// Team-owned session root for isolated seats; `None` preserves Pi's native default.
54
+ pub session_dir: Option<&'a Path>,
54
55
  pub session: PiSessionSelector<'a>,
55
56
  pub agent_id: &'a str,
56
57
  }
@@ -65,14 +66,15 @@ pub(crate) fn build_pi_command_argv(
65
66
  ) -> Result<Vec<String>, ProviderError> {
66
67
  if request.executable.as_os_str().is_empty()
67
68
  || request.extension.as_os_str().is_empty()
68
- || request.session_dir.as_os_str().is_empty()
69
+ || request
70
+ .session_dir
71
+ .is_some_and(|session_dir| session_dir.as_os_str().is_empty())
69
72
  || request.model.is_some_and(|model| model.trim().is_empty())
70
73
  || request.system_prompt.trim().is_empty()
71
74
  || request.agent_id.trim().is_empty()
72
75
  {
73
76
  return Err(ProviderError::Command(
74
- "Pi command requires executable, extension, prompt, session directory, and agent id"
75
- .to_string(),
77
+ "Pi command requires executable, extension, prompt, and agent id".to_string(),
76
78
  ));
77
79
  }
78
80
 
@@ -110,9 +112,13 @@ pub(crate) fn build_pi_command_argv(
110
112
  argv.extend([
111
113
  "--append-system-prompt".to_string(),
112
114
  request.system_prompt.to_string(),
113
- "--session-dir".to_string(),
114
- request.session_dir.to_string_lossy().into_owned(),
115
115
  ]);
116
+ if let Some(session_dir) = request.session_dir {
117
+ argv.extend([
118
+ "--session-dir".to_string(),
119
+ session_dir.to_string_lossy().into_owned(),
120
+ ]);
121
+ }
116
122
  // The shared lifecycle materializer always revalidates resume root/path/header.
117
123
  // Keep the pure argv builder usable before a fixture root is materialized, while
118
124
  // refusing a missing exact file once the recorded path's parent exists.
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ /** Resolve local Markdown links and optionally validate package/installed inventories. */
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+
6
+ const args = process.argv.slice(2);
7
+ const value = (name) => { const i = args.indexOf(name); return i < 0 ? null : args[i + 1]; };
8
+ const rootArg = value("--root") ?? "skills/team-agent";
9
+ const root = path.resolve(rootArg);
10
+ const installedArg = value("--installed");
11
+ const inventoryArg = value("--inventory");
12
+ const markdownLink = /!?(?:\[[^\]]*\])\(([^)]+)\)/g;
13
+ const files = [];
14
+ const fail = (message) => { throw new Error(message); };
15
+ const real = (file) => { try { return fs.realpathSync.native(file); } catch (error) { fail(`cannot resolve ${file}: ${error.message}`); } };
16
+ const rootReal = real(root);
17
+ const inside = (candidate, base = rootReal) => candidate === base || candidate.startsWith(`${base}${path.sep}`);
18
+ const lexicalInside = (candidate, base = root) => candidate === base || candidate.startsWith(`${base}${path.sep}`);
19
+
20
+ function walk(dir, boundary = rootReal) {
21
+ let entries;
22
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
23
+ catch (error) { fail(`cannot read ${dir}: ${error.message}`); }
24
+ for (const entry of entries) {
25
+ const file = path.join(dir, entry.name);
26
+ const fileReal = real(file);
27
+ if (!inside(fileReal, boundary)) fail(`${file}: realpath escapes tree`);
28
+ if (entry.isDirectory()) walk(file, boundary);
29
+ else if (entry.isFile() && file.endsWith(".md")) files.push(file);
30
+ }
31
+ }
32
+ function normalizeFragment(fragment) {
33
+ return decodeURIComponent(fragment).toLowerCase().trim().replace(/<[^>]+>/g, "")
34
+ .replace(/[`*_~]/g, "").replace(/[^\p{L}\p{N} -]/gu, "").replace(/\s+/g, "-");
35
+ }
36
+ function anchors(text) {
37
+ const found = new Set();
38
+ const used = new Map();
39
+ const add = (value) => {
40
+ const base = normalizeFragment(value); if (!base) return;
41
+ const n = used.get(base) ?? 0; used.set(base, n + 1); found.add(n ? `${base}-${n}` : base);
42
+ };
43
+ for (const m of text.matchAll(/^(?: {0,3})#{1,6}\s+(.+?)\s*#*\s*$/gm) ) add(m[1]);
44
+ for (const m of text.matchAll(/^(?: {0,3})([^\n#]+)\n(?:=+|-+)\s*$/gm)) add(m[1]);
45
+ for (const m of text.matchAll(/<(?:a|[^>]+)\s+[^>]*(?:id|name)=["']([^"']+)["'][^>]*>/gi)) add(m[1]);
46
+ return found;
47
+ }
48
+ function checkTree(dir, baseRoot, baseReal) {
49
+ files.length = 0; walk(dir, baseReal);
50
+ const errors = [];
51
+ for (const file of [...files].sort()) {
52
+ const text = fs.readFileSync(file, "utf8");
53
+ const known = anchors(text);
54
+ for (const match of text.matchAll(markdownLink)) {
55
+ const href = match[1].trim();
56
+ if (!href || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(href)) continue;
57
+ const hash = href.indexOf("#");
58
+ const rawPath = hash < 0 ? href : href.slice(0, hash);
59
+ const fragment = hash < 0 ? "" : href.slice(hash + 1).split("?", 1)[0];
60
+ const target = rawPath ? path.resolve(path.dirname(file), rawPath) : file;
61
+ if (!lexicalInside(target, baseRoot)) { errors.push(`${file}: lexical target escapes tree: ${href}`); continue; }
62
+ const targetReal = real(target);
63
+ if (!inside(targetReal, baseReal)) { errors.push(`${file}: realpath target escapes tree: ${href}`); continue; }
64
+ if (!fs.statSync(targetReal).isFile()) { errors.push(`${file}: target is not a file: ${href}`); continue; }
65
+ if (fragment && targetReal.endsWith(".md")) {
66
+ const targetAnchors = targetReal === real(file) ? known : anchors(fs.readFileSync(targetReal, "utf8"));
67
+ if (!targetAnchors.has(normalizeFragment(fragment))) errors.push(`${file}: missing fragment: ${href}`);
68
+ }
69
+ }
70
+ }
71
+ if (errors.length) fail(errors.sort().join("\n"));
72
+ return [...files].sort().length;
73
+ }
74
+ const count = checkTree(root, root, rootReal);
75
+ if (inventoryArg) {
76
+ const inventory = path.resolve(inventoryArg);
77
+ for (const relative of fs.readFileSync(inventory, "utf8").split(/\r?\n/).filter(Boolean).sort()) {
78
+ if (path.isAbsolute(relative)) fail(`inventory path must be relative: ${relative}`);
79
+ const candidate = path.resolve(root, relative);
80
+ if (!lexicalInside(candidate)) fail(`inventory path escapes tree: ${relative}`);
81
+ const candidateReal = real(candidate);
82
+ if (!inside(candidateReal)) fail(`inventory realpath escapes tree: ${relative}`);
83
+ }
84
+ }
85
+ if (installedArg) {
86
+ const installed = path.resolve(installedArg); console.log(`checked ${checkTree(installed, installed, real(installed))} Markdown files in ${installed}`);
87
+ }
88
+ console.log(`checked ${count} Markdown files in ${root}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.5.71",
3
+ "version": "0.5.73",
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.71",
24
- "@team-agent/cli-darwin-x64": "0.5.71",
25
- "@team-agent/cli-linux-x64": "0.5.71"
23
+ "@team-agent/cli-darwin-arm64": "0.5.73",
24
+ "@team-agent/cli-darwin-x64": "0.5.73",
25
+ "@team-agent/cli-linux-x64": "0.5.73"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",
@@ -33,6 +33,7 @@
33
33
  "files": [
34
34
  "npm/bincheck.mjs",
35
35
  "npm/install.mjs",
36
+ "npm/check-skill-links.mjs",
36
37
  "skills",
37
38
  "templates",
38
39
  "examples",