@team-agent/installer 0.5.66 → 0.5.69

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 (178) hide show
  1. package/Cargo.lock +8 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/cli/adapters.rs +5 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +156 -0
  6. package/crates/team-agent/src/cli/emit.rs +6 -5
  7. package/crates/team-agent/src/cli/grok_slot.rs +299 -0
  8. package/crates/team-agent/src/cli/leader.rs +22 -7
  9. package/crates/team-agent/src/cli/leaders.rs +125 -1
  10. package/crates/team-agent/src/cli/mod.rs +20 -0
  11. package/crates/team-agent/src/cli/send/presentation.rs +7 -1
  12. package/crates/team-agent/src/cli/spec.rs +3 -0
  13. package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
  14. package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
  15. package/crates/team-agent/src/cli/tests/base.rs +26 -3
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
  17. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
  18. package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
  19. package/crates/team-agent/src/communication_mode/mod.rs +6 -4
  20. package/crates/team-agent/src/compiler.rs +121 -5
  21. package/crates/team-agent/src/coordinator/backoff.rs +55 -0
  22. package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
  23. package/crates/team-agent/src/coordinator/health.rs +173 -0
  24. package/crates/team-agent/src/coordinator/mod.rs +31 -0
  25. package/crates/team-agent/src/coordinator/orphan.rs +31 -0
  26. package/crates/team-agent/src/coordinator/runtime_detectors.rs +31 -0
  27. package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
  29. package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
  30. package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
  31. package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
  32. package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
  33. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
  34. package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
  35. package/crates/team-agent/src/coordinator/tick.rs +133 -22
  36. package/crates/team-agent/src/coordinator/types.rs +49 -0
  37. package/crates/team-agent/src/db/message_store.rs +39 -5
  38. package/crates/team-agent/src/layout/worker_env.rs +20 -3
  39. package/crates/team-agent/src/layout/worker_window_helpers.rs +3 -0
  40. package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
  41. package/crates/team-agent/src/leader/registry.rs +65 -0
  42. package/crates/team-agent/src/leader/start.rs +973 -75
  43. package/crates/team-agent/src/lifecycle/display.rs +56 -0
  44. package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
  45. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +398 -26
  46. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +80 -0
  47. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +60 -0
  48. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
  49. package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
  50. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +548 -0
  51. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
  52. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +208 -456
  53. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
  54. package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
  55. package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
  56. package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
  57. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
  58. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +482 -1
  59. package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
  60. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +830 -0
  61. package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
  62. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
  63. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +87 -3
  64. package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
  65. package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
  66. package/crates/team-agent/src/lifecycle/launch/spawn.rs +127 -25
  67. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +140 -0
  68. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
  69. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
  70. package/crates/team-agent/src/lifecycle/launch.rs +107 -29
  71. package/crates/team-agent/src/lifecycle/lock.rs +42 -1
  72. package/crates/team-agent/src/lifecycle/mod.rs +11 -0
  73. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
  74. package/crates/team-agent/src/lifecycle/profile_launch.rs +99 -0
  75. package/crates/team-agent/src/lifecycle/profile_smoke.rs +52 -1
  76. package/crates/team-agent/src/lifecycle/restart/agent.rs +214 -160
  77. package/crates/team-agent/src/lifecycle/restart/common.rs +513 -37
  78. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
  79. package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
  80. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
  81. package/crates/team-agent/src/lifecycle/restart/remove.rs +233 -6
  82. package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
  83. package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
  84. package/crates/team-agent/src/lifecycle/restart.rs +29 -0
  85. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
  86. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
  87. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
  88. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
  89. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
  90. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
  91. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +464 -0
  92. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +247 -0
  93. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
  94. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
  95. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +787 -0
  96. package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
  97. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
  98. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +485 -0
  99. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
  100. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
  101. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  102. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
  103. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
  104. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
  105. package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
  106. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
  107. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
  108. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
  109. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +128 -0
  110. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
  111. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
  112. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -0
  113. package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
  114. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
  115. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +3 -0
  116. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
  117. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
  118. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
  119. package/crates/team-agent/src/lifecycle/tests.rs +30 -13
  120. package/crates/team-agent/src/lifecycle/types.rs +61 -0
  121. package/crates/team-agent/src/lifecycle/worker_command_context.rs +143 -12
  122. package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
  123. package/crates/team-agent/src/mcp_server/tests.rs +196 -2
  124. package/crates/team-agent/src/messaging/delivery.rs +430 -41
  125. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  126. package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
  127. package/crates/team-agent/src/messaging/results.rs +4 -0
  128. package/crates/team-agent/src/messaging/send.rs +34 -0
  129. package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
  130. package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
  131. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
  132. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  133. package/crates/team-agent/src/messaging/types.rs +4 -1
  134. package/crates/team-agent/src/model/enums.rs +17 -6
  135. package/crates/team-agent/src/model/permissions.rs +3 -0
  136. package/crates/team-agent/src/os_probe.rs +73 -2
  137. package/crates/team-agent/src/provider/adapter.rs +259 -6
  138. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +89 -0
  139. package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
  140. package/crates/team-agent/src/provider/adapters/mod.rs +3 -0
  141. package/crates/team-agent/src/provider/adapters/pi.rs +152 -0
  142. package/crates/team-agent/src/provider/bypass_flags.rs +47 -2
  143. package/crates/team-agent/src/provider/classify.rs +4 -2
  144. package/crates/team-agent/src/provider/faults.rs +2 -1
  145. package/crates/team-agent/src/provider/mod.rs +11 -0
  146. package/crates/team-agent/src/provider/session/capture.rs +158 -20
  147. package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
  148. package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
  149. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
  150. package/crates/team-agent/src/provider/session/context_fork.rs +78 -3
  151. package/crates/team-agent/src/provider/session/mod.rs +18 -0
  152. package/crates/team-agent/src/provider/session/resume.rs +57 -0
  153. package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
  154. package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
  155. package/crates/team-agent/src/provider/session_scan/common.rs +205 -2
  156. package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
  157. package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
  158. package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
  159. package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
  160. package/crates/team-agent/src/provider/session_scan.rs +12 -0
  161. package/crates/team-agent/src/provider/submit_now.rs +94 -0
  162. package/crates/team-agent/src/provider/tests/adapter.rs +384 -0
  163. package/crates/team-agent/src/provider/types.rs +4 -2
  164. package/crates/team-agent/src/provider/wire.rs +29 -1
  165. package/crates/team-agent/src/state/persist.rs +301 -8
  166. package/crates/team-agent/src/state/repository.rs +5 -0
  167. package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
  168. package/crates/team-agent/src/tmux_backend.rs +1116 -75
  169. package/crates/team-agent/src/transport/tests/wire.rs +28 -2
  170. package/crates/team-agent/src/transport.rs +248 -1
  171. package/npm/install.mjs +129 -73
  172. package/package.json +4 -4
  173. package/skills/team-agent/SKILL.md +32 -239
  174. package/skills/team-agent/command-coverage.json +31 -0
  175. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
  176. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
  177. package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
  178. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
@@ -0,0 +1,830 @@
1
+ //! ---
2
+ //! purpose: Pi executable/catalog/adapter 验证、per-seat wrapper 与共享启动计划物化
3
+ //! contract:
4
+ //! provides:
5
+ //! - name: materialize_pi_plan
6
+ //! what: 为 leader 与 TeamMate 生成同一来源的 Pi CommandPlan
7
+ //! - name: write_pi_wrapper
8
+ //! what: 原子写入 0600 isolated lazy MCP wrapper
9
+ //! boundary:
10
+ //! - 不枚举 Pi session;resume 只重验调用方给出的 exact backing
11
+ //! - 不投递消息、不执行 cleanup 或 doctor 判定
12
+ //! - 不读取 credentials 或 provider settings 值
13
+ //! maturity: wired
14
+ //! ---
15
+
16
+ use std::collections::BTreeSet;
17
+ use std::fs::OpenOptions;
18
+ use std::io::Write;
19
+ use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
20
+ use std::path::{Path, PathBuf};
21
+ use std::process::Command;
22
+
23
+ use sha2::{Digest, Sha256};
24
+
25
+ use crate::model::enums::ProviderEffort;
26
+ use crate::provider::adapters::pi::{build_pi_command_argv, PiCommandRequest, PiSessionSelector};
27
+ use crate::provider::{CommandPlan, McpConfig, ProviderError, SessionId};
28
+
29
+ const ADAPTER_NAME: &str = "pi-mcp-adapter";
30
+
31
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
32
+ pub(crate) enum PiExecutableFileType {
33
+ Wrapper,
34
+ Symlink,
35
+ Binary,
36
+ }
37
+
38
+ #[derive(Debug, Clone, PartialEq, Eq)]
39
+ pub(crate) struct PiAdapterIdentity {
40
+ pub package_name: String,
41
+ pub version: String,
42
+ pub extension_entry: String,
43
+ pub package_json: PathBuf,
44
+ pub index_ts: PathBuf,
45
+ pub package_json_sha256: String,
46
+ pub index_ts_sha256: String,
47
+ }
48
+
49
+ #[derive(Debug, Clone, PartialEq, Eq)]
50
+ pub(crate) struct PiExecutableChain {
51
+ pub path_entry: PathBuf,
52
+ pub path_entry_type: PiExecutableFileType,
53
+ pub launch_executable: PathBuf,
54
+ pub real_binary: PathBuf,
55
+ pub pi_version: String,
56
+ pub catalog_sha256: String,
57
+ pub adapter: PiAdapterIdentity,
58
+ }
59
+
60
+ #[derive(Debug, Clone, PartialEq, Eq)]
61
+ pub(crate) struct PiSeatPaths {
62
+ pub runtime_root: PathBuf,
63
+ pub wrapper: PathBuf,
64
+ pub sessions: PathBuf,
65
+ }
66
+
67
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
68
+ pub(crate) enum PiCleanupAction {
69
+ Stop,
70
+ Remove,
71
+ }
72
+
73
+ #[derive(Debug, Clone, PartialEq, Eq)]
74
+ pub(crate) struct PiCleanupPlan {
75
+ pub delete_paths: Vec<PathBuf>,
76
+ pub retain_paths: Vec<PathBuf>,
77
+ pub process_name_scan: Option<String>,
78
+ }
79
+
80
+ /// ---
81
+ /// purpose: 给 stop/remove 生成 Pi 席位 exact cleanup 计划
82
+ /// returns: stop 全保留;remove 只删 wrapper 并保留 session backing
83
+ /// ---
84
+ pub(crate) fn pi_cleanup_plan(action: PiCleanupAction, paths: &PiSeatPaths) -> PiCleanupPlan {
85
+ match action {
86
+ PiCleanupAction::Stop => PiCleanupPlan {
87
+ delete_paths: Vec::new(),
88
+ retain_paths: vec![paths.wrapper.clone(), paths.sessions.clone()],
89
+ process_name_scan: None,
90
+ },
91
+ PiCleanupAction::Remove => PiCleanupPlan {
92
+ delete_paths: vec![paths.wrapper.clone()],
93
+ retain_paths: vec![paths.sessions.clone()],
94
+ process_name_scan: None,
95
+ },
96
+ }
97
+ }
98
+
99
+ /// ---
100
+ /// purpose: 计算一个 Pi 席位唯一的 Team-owned runtime 路径
101
+ /// returns: runtime root、wrapper 与 sessions 路径
102
+ /// ---
103
+ pub(crate) fn pi_seat_paths(workspace: &Path, team_id: &str, agent_id: &str) -> PiSeatPaths {
104
+ let runtime_root = workspace
105
+ .join(".team")
106
+ .join("runtime")
107
+ .join("pi")
108
+ .join(team_id)
109
+ .join(agent_id);
110
+ PiSeatPaths {
111
+ wrapper: runtime_root.join("team-mcp.ts"),
112
+ sessions: runtime_root.join("sessions"),
113
+ runtime_root,
114
+ }
115
+ }
116
+
117
+ /// ---
118
+ /// purpose: 为 Pi fresh 启动生成框架预分配 UUID
119
+ /// returns: 新 SessionId
120
+ /// ---
121
+ pub(crate) fn new_pi_session_id() -> SessionId {
122
+ SessionId::new(crate::provider::adapter::next_session_token())
123
+ }
124
+
125
+ /// ---
126
+ /// purpose: 解析 pi --list-models 未过滤表格为 provider/model exact ids
127
+ /// returns: 保序且无重复的 exact ids
128
+ /// errors: 非 UTF-8、header/row 异常、重复或空 catalog 时返回 ProviderError
129
+ /// ---
130
+ pub(crate) fn parse_pi_list_models_table(bytes: &[u8]) -> Result<Vec<String>, ProviderError> {
131
+ let text = std::str::from_utf8(bytes).map_err(|error| {
132
+ ProviderError::Command(format!("Pi model catalog is not UTF-8: {error}"))
133
+ })?;
134
+ let mut lines = text.lines();
135
+ let header = lines
136
+ .next()
137
+ .ok_or_else(|| ProviderError::Command("Pi model catalog is empty".to_string()))?;
138
+ let header_columns = header.split_whitespace().collect::<Vec<_>>();
139
+ if header_columns.get(0) != Some(&"provider") || header_columns.get(1) != Some(&"model") {
140
+ return Err(ProviderError::Command(
141
+ "Pi model catalog has an unexpected header".to_string(),
142
+ ));
143
+ }
144
+ let mut seen = BTreeSet::new();
145
+ let mut models = Vec::new();
146
+ for (index, line) in lines.enumerate() {
147
+ if line.trim().is_empty() {
148
+ continue;
149
+ }
150
+ let columns = line.split_whitespace().collect::<Vec<_>>();
151
+ if columns.len() < 2 || columns[0].is_empty() || columns[1].is_empty() {
152
+ return Err(ProviderError::Command(format!(
153
+ "Pi model catalog row {} is malformed",
154
+ index + 2
155
+ )));
156
+ }
157
+ let exact = format!("{}/{}", columns[0], columns[1]);
158
+ if !seen.insert(exact.clone()) {
159
+ return Err(ProviderError::Command(format!(
160
+ "Pi model catalog contains duplicate exact id {exact:?}"
161
+ )));
162
+ }
163
+ models.push(exact);
164
+ }
165
+ if models.is_empty() {
166
+ return Err(ProviderError::Command(
167
+ "Pi model catalog contains no models".to_string(),
168
+ ));
169
+ }
170
+ Ok(models)
171
+ }
172
+
173
+ /// ---
174
+ /// purpose: 在动态 catalog 中只接受一个字节精确的 qualified model
175
+ /// returns: exact catalog id
176
+ /// errors: 非 qualified、wildcard、缺失或重复时返回 ProviderError
177
+ /// ---
178
+ pub(crate) fn select_exact_pi_model(
179
+ models: &[String],
180
+ requested: &str,
181
+ ) -> Result<String, ProviderError> {
182
+ if requested.trim() != requested
183
+ || requested.is_empty()
184
+ || requested.contains('*')
185
+ || !requested
186
+ .split_once('/')
187
+ .is_some_and(|(provider, model)| !provider.is_empty() && !model.is_empty())
188
+ {
189
+ return Err(ProviderError::Command(format!(
190
+ "Pi model must be a qualified exact catalog id: {requested:?}"
191
+ )));
192
+ }
193
+ let mut matches = models.iter().filter(|model| model.as_str() == requested);
194
+ let selected = matches.next().ok_or_else(|| {
195
+ ProviderError::Command(format!("Pi model is not in the live catalog: {requested}"))
196
+ })?;
197
+ if matches.next().is_some() {
198
+ return Err(ProviderError::Command(format!(
199
+ "Pi model catalog selection is ambiguous: {requested}"
200
+ )));
201
+ }
202
+ Ok(selected.clone())
203
+ }
204
+
205
+ /// ---
206
+ /// purpose: 对比选定与启动后客观观测到的 Pi model identity
207
+ /// returns: 完全相等且非空时成功
208
+ /// errors: 缺失或不匹配时返回 ProviderError
209
+ /// ---
210
+ pub(crate) fn verify_started_pi_model(selected: &str, observed: &str) -> Result<(), ProviderError> {
211
+ if observed.is_empty() || selected != observed {
212
+ return Err(ProviderError::Command(format!(
213
+ "Pi started model mismatch: selected={selected:?} observed={observed:?}"
214
+ )));
215
+ }
216
+ Ok(())
217
+ }
218
+
219
+ /// ---
220
+ /// purpose: 校验 pi-mcp-adapter 的公共 package/entry 能力
221
+ /// returns: package identity 与可解析的 absolute entry 完整时成功
222
+ /// errors: package 或 entry 缺失时返回 ProviderError;version/digests 仅保留为观测值
223
+ /// ---
224
+ pub(crate) fn validate_pi_adapter_identity(
225
+ identity: &PiAdapterIdentity,
226
+ ) -> Result<(), ProviderError> {
227
+ if identity.package_name != ADAPTER_NAME
228
+ || identity.extension_entry.is_empty()
229
+ || !identity.package_json.is_absolute()
230
+ || !identity.index_ts.is_absolute()
231
+ {
232
+ return Err(ProviderError::Command(
233
+ "Pi MCP adapter package or extension entry is not resolvable".to_string(),
234
+ ));
235
+ }
236
+ let package_root = identity.package_json.parent().ok_or_else(|| {
237
+ ProviderError::Command("Pi MCP adapter package root is not resolvable".to_string())
238
+ })?;
239
+ let extension_entry = Path::new(&identity.extension_entry);
240
+ if extension_entry.is_absolute() {
241
+ return Err(ProviderError::Command(
242
+ "Pi MCP adapter extension entry must be package-relative".to_string(),
243
+ ));
244
+ }
245
+ let declared_entry =
246
+ std::fs::canonicalize(package_root.join(extension_entry)).map_err(|error| {
247
+ ProviderError::Io(format!(
248
+ "Pi MCP adapter extension entry {}: {error}",
249
+ identity.index_ts.display()
250
+ ))
251
+ })?;
252
+ let loaded_entry = std::fs::canonicalize(&identity.index_ts)
253
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", identity.index_ts.display())))?;
254
+ if declared_entry != loaded_entry || !identity.package_json.is_file() {
255
+ return Err(ProviderError::Command(
256
+ "Pi MCP adapter package or extension entry is not loadable".to_string(),
257
+ ));
258
+ }
259
+ std::fs::read(&loaded_entry)
260
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", loaded_entry.display())))?;
261
+ Ok(())
262
+ }
263
+
264
+ /// ---
265
+ /// purpose: 校验 Pi launch/real path、catalog 与 adapter 公共能力
266
+ /// returns: executable chain 与 capability receipts 完整时成功
267
+ /// errors: 缺路径、catalog receipt 或 adapter capability 时返回 ProviderError
268
+ /// ---
269
+ pub(crate) fn validate_pi_executable_chain(chain: &PiExecutableChain) -> Result<(), ProviderError> {
270
+ if !chain.path_entry.is_absolute()
271
+ || !chain.launch_executable.is_absolute()
272
+ || !chain.real_binary.is_absolute()
273
+ || chain.path_entry_type != PiExecutableFileType::Wrapper
274
+ || chain.launch_executable != chain.path_entry
275
+ || chain.real_binary == chain.launch_executable
276
+ || chain.catalog_sha256.len() != 64
277
+ || !chain
278
+ .catalog_sha256
279
+ .bytes()
280
+ .all(|byte| byte.is_ascii_hexdigit())
281
+ || chain.catalog_sha256.bytes().all(|byte| byte == b'0')
282
+ {
283
+ return Err(ProviderError::Command(
284
+ "Pi executable chain does not provide the required public protocol".to_string(),
285
+ ));
286
+ }
287
+ validate_pi_adapter_identity(&chain.adapter)
288
+ }
289
+
290
+ pub(crate) struct PiWrapperRequest<'a> {
291
+ pub destination: &'a Path,
292
+ pub adapter: &'a PiAdapterIdentity,
293
+ pub candidate_executable: &'a Path,
294
+ pub mcp_config: &'a McpConfig,
295
+ pub team_id: &'a str,
296
+ pub agent_id: &'a str,
297
+ pub workspace: &'a Path,
298
+ pub include_tools: &'a [&'a str],
299
+ }
300
+
301
+ fn team_server(
302
+ config: &McpConfig,
303
+ ) -> Result<serde_json::Map<String, serde_json::Value>, ProviderError> {
304
+ let server = config
305
+ .raw
306
+ .get("team_orchestrator")
307
+ .or_else(|| config.raw.pointer("/mcpServers/team_orchestrator"))
308
+ .and_then(serde_json::Value::as_object)
309
+ .cloned()
310
+ .ok_or_else(|| {
311
+ ProviderError::Command("resolved MCP config is missing team_orchestrator".to_string())
312
+ })?;
313
+ Ok(server)
314
+ }
315
+
316
+ fn candidate_from_mcp_config(config: &McpConfig) -> Result<PathBuf, ProviderError> {
317
+ let server = team_server(config)?;
318
+ let command = server
319
+ .get("command")
320
+ .and_then(serde_json::Value::as_str)
321
+ .filter(|value| !value.is_empty())
322
+ .map(PathBuf::from)
323
+ .ok_or_else(|| {
324
+ ProviderError::Command("resolved team_orchestrator MCP command is missing".to_string())
325
+ })?;
326
+ if !command.is_absolute() {
327
+ return Err(ProviderError::Command(
328
+ "resolved team_orchestrator MCP command must be absolute".to_string(),
329
+ ));
330
+ }
331
+ Ok(command)
332
+ }
333
+
334
+ fn render_pi_wrapper(request: &PiWrapperRequest<'_>) -> Result<String, ProviderError> {
335
+ validate_pi_adapter_identity(request.adapter)?;
336
+ let configured_candidate = candidate_from_mcp_config(request.mcp_config)?;
337
+ let expected = std::fs::canonicalize(request.candidate_executable)
338
+ .unwrap_or_else(|_| request.candidate_executable.to_path_buf());
339
+ let configured = std::fs::canonicalize(&configured_candidate).unwrap_or(configured_candidate);
340
+ if configured != expected {
341
+ return Err(ProviderError::Command(format!(
342
+ "Pi wrapper candidate mismatch: expected {} got {}",
343
+ expected.display(),
344
+ configured.display()
345
+ )));
346
+ }
347
+ let mut server = team_server(request.mcp_config)?;
348
+ server.insert(
349
+ "command".to_string(),
350
+ serde_json::Value::String(expected.to_string_lossy().into_owned()),
351
+ );
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
+ server.insert(
362
+ "includeTools".to_string(),
363
+ serde_json::Value::Array(
364
+ request
365
+ .include_tools
366
+ .iter()
367
+ .map(|tool| serde_json::Value::String((*tool).to_string()))
368
+ .collect(),
369
+ ),
370
+ );
371
+ let env = server
372
+ .entry("env".to_string())
373
+ .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
374
+ .as_object_mut()
375
+ .ok_or_else(|| ProviderError::Command("Pi MCP server env must be an object".to_string()))?;
376
+ env.insert(
377
+ "TEAM_AGENT_WORKSPACE".to_string(),
378
+ serde_json::Value::String(request.workspace.to_string_lossy().into_owned()),
379
+ );
380
+ env.insert(
381
+ "TEAM_AGENT_ID".to_string(),
382
+ serde_json::Value::String(request.agent_id.to_string()),
383
+ );
384
+ env.insert(
385
+ "TEAM_AGENT_AGENT_ID".to_string(),
386
+ serde_json::Value::String(request.agent_id.to_string()),
387
+ );
388
+ env.insert(
389
+ "TEAM_AGENT_OWNER_TEAM_ID".to_string(),
390
+ serde_json::Value::String(request.team_id.to_string()),
391
+ );
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}")))?;
401
+ Ok(format!(
402
+ "import {{ createMcpAdapter }} from {import};\n\nexport default createMcpAdapter({{ config: {config} }});\n"
403
+ ))
404
+ }
405
+
406
+ /// ---
407
+ /// purpose: 静态核对 wrapper 的 exact import、candidate 与 lazy isolation 字段
408
+ /// returns: wrapper 保持冻结隔离合同则成功
409
+ /// errors: identity 缺失或出现 mcp-config fallback 时返回 ProviderError
410
+ /// ---
411
+ pub(crate) fn validate_pi_wrapper_source(
412
+ source: &str,
413
+ adapter: &PiAdapterIdentity,
414
+ candidate_executable: &Path,
415
+ ) -> Result<(), ProviderError> {
416
+ let adapter_path = adapter.index_ts.to_string_lossy();
417
+ 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")
422
+ || !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
+ || !source.contains("includeTools")
429
+ || source.contains("--mcp-config")
430
+ {
431
+ return Err(ProviderError::Command(
432
+ "Pi wrapper source does not preserve the isolated adapter/candidate contract"
433
+ .to_string(),
434
+ ));
435
+ }
436
+ Ok(())
437
+ }
438
+
439
+ /// ---
440
+ /// purpose: 在 seat root 原子写入 mode 0600 的 isolated lazy MCP wrapper
441
+ /// returns: 最终 wrapper 路径
442
+ /// errors: identity、序列化、目录、写盘或 rename 失败时返回 ProviderError
443
+ /// ---
444
+ pub(crate) fn write_pi_wrapper(request: PiWrapperRequest<'_>) -> Result<PathBuf, ProviderError> {
445
+ let source = render_pi_wrapper(&request)?;
446
+ validate_pi_wrapper_source(&source, request.adapter, request.candidate_executable)?;
447
+ let parent = request.destination.parent().ok_or_else(|| {
448
+ ProviderError::Io("Pi wrapper destination has no parent directory".to_string())
449
+ })?;
450
+ std::fs::create_dir_all(parent)
451
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", parent.display())))?;
452
+ let temp = parent.join(format!(".team-mcp.ts.{}.tmp", std::process::id()));
453
+ let write_result = (|| {
454
+ let mut file = OpenOptions::new()
455
+ .create_new(true)
456
+ .write(true)
457
+ .mode(0o600)
458
+ .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| {
465
+ ProviderError::Io(format!(
466
+ "rename {} to {}: {error}",
467
+ temp.display(),
468
+ request.destination.display()
469
+ ))
470
+ })?;
471
+ std::fs::set_permissions(request.destination, std::fs::Permissions::from_mode(0o600))
472
+ .map_err(|error| {
473
+ ProviderError::Io(format!("{}: {error}", request.destination.display()))
474
+ })?;
475
+ Ok(request.destination.to_path_buf())
476
+ })();
477
+ if write_result.is_err() {
478
+ let _ = std::fs::remove_file(&temp);
479
+ }
480
+ write_result
481
+ }
482
+
483
+ #[derive(Debug, Clone, PartialEq, Eq)]
484
+ pub(crate) struct PiLeaderArgs {
485
+ pub model: String,
486
+ pub effort: ProviderEffort,
487
+ }
488
+
489
+ /// ---
490
+ /// purpose: 解析 Pi leader 唯一允许的 model/thinking 输入
491
+ /// returns: 显式 qualified model 与 effort
492
+ /// errors: 缺失、重复、未知或 materializer-owned flag 时返回 ProviderError
493
+ /// ---
494
+ pub(crate) fn parse_pi_leader_args(args: &[String]) -> Result<PiLeaderArgs, ProviderError> {
495
+ let args = args.strip_prefix(&["--".to_string()]).unwrap_or(args);
496
+ let mut model = None;
497
+ let mut effort = None;
498
+ let mut index = 0;
499
+ while index < args.len() {
500
+ let flag = args[index].as_str();
501
+ let value = args.get(index + 1).ok_or_else(|| {
502
+ ProviderError::Command(format!("Pi leader argument {flag} requires a value"))
503
+ })?;
504
+ match flag {
505
+ "--model" if model.is_none() => model = Some(value.clone()),
506
+ "--thinking" if effort.is_none() => {
507
+ effort = ProviderEffort::parse(value);
508
+ if effort.is_none() {
509
+ return Err(ProviderError::Command(format!(
510
+ "unknown Pi thinking effort {value:?}"
511
+ )));
512
+ }
513
+ }
514
+ _ => {
515
+ return Err(ProviderError::Command(format!(
516
+ "Pi leader accepts only one --model and one --thinking; got {flag:?}"
517
+ )));
518
+ }
519
+ }
520
+ index += 2;
521
+ }
522
+ let model = model.ok_or_else(|| {
523
+ ProviderError::Command("Pi leader requires an explicit --model".to_string())
524
+ })?;
525
+ if model.contains('*')
526
+ || !model
527
+ .split_once('/')
528
+ .is_some_and(|(provider, name)| !provider.is_empty() && !name.is_empty())
529
+ {
530
+ return Err(ProviderError::Command(
531
+ "Pi leader --model must be a qualified exact id".to_string(),
532
+ ));
533
+ }
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
+ }
541
+
542
+ fn sha256(bytes: &[u8]) -> String {
543
+ format!("{:x}", Sha256::digest(bytes))
544
+ }
545
+
546
+ fn executable_file_type(path: &Path) -> Result<PiExecutableFileType, ProviderError> {
547
+ let metadata = std::fs::symlink_metadata(path)
548
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", path.display())))?;
549
+ if metadata.file_type().is_symlink() {
550
+ return Ok(PiExecutableFileType::Symlink);
551
+ }
552
+ let bytes = std::fs::read(path)
553
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", path.display())))?;
554
+ if bytes.starts_with(b"#!") {
555
+ Ok(PiExecutableFileType::Wrapper)
556
+ } else {
557
+ Ok(PiExecutableFileType::Binary)
558
+ }
559
+ }
560
+
561
+ fn pi_path_entries() -> Result<Vec<PathBuf>, ProviderError> {
562
+ let path = std::env::var_os("PATH")
563
+ .ok_or_else(|| ProviderError::Command("PATH is not set".to_string()))?;
564
+ let mut seen = BTreeSet::new();
565
+ let entries = std::env::split_paths(&path)
566
+ .map(|directory| directory.join("pi"))
567
+ .filter(|candidate| candidate.is_file())
568
+ .filter(|candidate| seen.insert(candidate.clone()))
569
+ .collect::<Vec<_>>();
570
+ if entries.is_empty() {
571
+ return Err(ProviderError::Command(
572
+ "Pi executable not found on PATH".to_string(),
573
+ ));
574
+ }
575
+ Ok(entries)
576
+ }
577
+
578
+ fn command_stdout(executable: &Path, args: &[&str]) -> Result<Vec<u8>, ProviderError> {
579
+ let output = Command::new(executable)
580
+ .args(args)
581
+ .output()
582
+ .map_err(|error| {
583
+ ProviderError::Io(format!(
584
+ "{} {}: {error}",
585
+ executable.display(),
586
+ args.join(" ")
587
+ ))
588
+ })?;
589
+ if !output.status.success() {
590
+ return Err(ProviderError::Command(format!(
591
+ "{} {} exited {}",
592
+ executable.display(),
593
+ args.join(" "),
594
+ output.status
595
+ )));
596
+ }
597
+ Ok(output.stdout)
598
+ }
599
+
600
+ fn discover_pi_adapter(launch_executable: &Path) -> Result<PiAdapterIdentity, ProviderError> {
601
+ let listing = command_stdout(launch_executable, &["list", "--no-approve"])?;
602
+ let listing = std::str::from_utf8(&listing).map_err(|error| {
603
+ ProviderError::Command(format!("Pi package list is not UTF-8: {error}"))
604
+ })?;
605
+ let mut lines = listing.lines();
606
+ let mut package_root = None;
607
+ while let Some(line) = lines.next() {
608
+ if line.trim() == "npm:pi-mcp-adapter" {
609
+ package_root = lines
610
+ .next()
611
+ .map(str::trim)
612
+ .filter(|path| Path::new(path).is_absolute())
613
+ .map(PathBuf::from);
614
+ break;
615
+ }
616
+ }
617
+ let package_root = package_root.ok_or_else(|| {
618
+ ProviderError::Command("Pi package list does not resolve npm:pi-mcp-adapter".to_string())
619
+ })?;
620
+ let package_json = package_root.join("package.json");
621
+ let package_bytes = std::fs::read(&package_json)
622
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", package_json.display())))?;
623
+ let package: serde_json::Value = serde_json::from_slice(&package_bytes)
624
+ .map_err(|error| ProviderError::Command(format!("{}: {error}", package_json.display())))?;
625
+ let extension_entry = package
626
+ .pointer("/pi/extensions")
627
+ .and_then(serde_json::Value::as_array)
628
+ .and_then(|entries| entries.iter().find_map(serde_json::Value::as_str))
629
+ .unwrap_or("")
630
+ .to_string();
631
+ let index_ts = package_root.join(extension_entry.trim_start_matches("./"));
632
+ let index_bytes = std::fs::read(&index_ts)
633
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", index_ts.display())))?;
634
+ let identity = PiAdapterIdentity {
635
+ package_name: package
636
+ .get("name")
637
+ .and_then(serde_json::Value::as_str)
638
+ .unwrap_or("")
639
+ .to_string(),
640
+ version: package
641
+ .get("version")
642
+ .and_then(serde_json::Value::as_str)
643
+ .unwrap_or("")
644
+ .to_string(),
645
+ extension_entry,
646
+ package_json,
647
+ index_ts,
648
+ package_json_sha256: sha256(&package_bytes),
649
+ index_ts_sha256: sha256(&index_bytes),
650
+ };
651
+ validate_pi_adapter_identity(&identity)?;
652
+ Ok(identity)
653
+ }
654
+
655
+ /// ---
656
+ /// purpose: 从当前 PATH 环境测量 Pi launch/real executable、catalog 与 adapter
657
+ /// returns: 已验证的 chain 与同次 live catalog exact ids
658
+ /// errors: 缺失、命令失败或任一公共 protocol capability 缺失时返回 ProviderError
659
+ /// ---
660
+ pub(crate) fn resolve_pi_executable_chain(
661
+ ) -> Result<(PiExecutableChain, Vec<String>), ProviderError> {
662
+ 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;
673
+ let version =
674
+ String::from_utf8(command_stdout(&path_entry, &["--version"])?).map_err(|error| {
675
+ ProviderError::Command(format!("Pi version output is not UTF-8: {error}"))
676
+ })?;
677
+ let version = version.trim().to_string();
678
+ let launch_identity = std::fs::canonicalize(&path_entry)
679
+ .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
+ })?;
691
+ let _real_version =
692
+ String::from_utf8(command_stdout(&real_binary, &["--version"])?).map_err(|error| {
693
+ ProviderError::Command(format!(
694
+ "Pi real binary version output is not UTF-8: {error}"
695
+ ))
696
+ })?;
697
+ let catalog = command_stdout(&path_entry, &["--list-models"])?;
698
+ let models = parse_pi_list_models_table(&catalog)?;
699
+ let adapter = discover_pi_adapter(&path_entry)?;
700
+ let chain = PiExecutableChain {
701
+ path_entry: path_entry.clone(),
702
+ path_entry_type,
703
+ launch_executable: path_entry,
704
+ real_binary,
705
+ pi_version: version,
706
+ catalog_sha256: sha256(&catalog),
707
+ adapter,
708
+ };
709
+ validate_pi_executable_chain(&chain)?;
710
+ Ok((chain, models))
711
+ }
712
+
713
+ pub(crate) struct PiMaterializeRequest<'a> {
714
+ pub workspace: &'a Path,
715
+ pub team_id: &'a str,
716
+ pub agent_id: &'a str,
717
+ pub model: &'a str,
718
+ pub effort: ProviderEffort,
719
+ pub system_prompt: &'a str,
720
+ pub tool_categories: &'a [&'a str],
721
+ pub team_mcp_tools: &'a [&'a str],
722
+ pub mcp_config: &'a McpConfig,
723
+ }
724
+
725
+ /// ---
726
+ /// purpose: 共享物化 Pi leader 与 TeamMate 的 wrapper、UUID 和 CommandPlan
727
+ /// returns: fresh Pi CommandPlan,含 expected UUID 与 seat session root
728
+ /// errors: resolver、exact model、wrapper 或 argv 构造失败时返回 ProviderError
729
+ /// ---
730
+ pub(crate) fn materialize_pi_plan(
731
+ request: PiMaterializeRequest<'_>,
732
+ ) -> Result<CommandPlan, ProviderError> {
733
+ materialize_pi_plan_with_session(request, None)
734
+ }
735
+
736
+ /// ---
737
+ /// purpose: 用上游已验证的 captured id/path 物化 Pi exact-path resume 计划
738
+ /// returns: 与 fresh 共用 resolver/wrapper/argv builder,且不再生成 session id
739
+ /// errors: backing 缺失或越出席位 root 时拒绝;header id/cwd 由 session scanner 在调用前验证
740
+ /// ---
741
+ pub(crate) fn materialize_pi_resume_plan(
742
+ request: PiMaterializeRequest<'_>,
743
+ session_id: &SessionId,
744
+ session_path: &Path,
745
+ spawn_cwd: &Path,
746
+ ) -> Result<CommandPlan, ProviderError> {
747
+ materialize_pi_plan_with_session(request, Some((session_id, session_path, spawn_cwd)))
748
+ }
749
+
750
+ fn materialize_pi_plan_with_session(
751
+ request: PiMaterializeRequest<'_>,
752
+ resume: Option<(&SessionId, &Path, &Path)>,
753
+ ) -> Result<CommandPlan, ProviderError> {
754
+ let (chain, catalog) = resolve_pi_executable_chain()?;
755
+ let model = select_exact_pi_model(&catalog, request.model)?;
756
+ let paths = pi_seat_paths(request.workspace, request.team_id, request.agent_id);
757
+ let resume = if let Some((session_id, session_path, spawn_cwd)) = resume {
758
+ let session_root = std::fs::canonicalize(&paths.sessions).map_err(|error| {
759
+ ProviderError::ResumeUnavailable(format!(
760
+ "Pi session root is unavailable {}: {error}",
761
+ paths.sessions.display()
762
+ ))
763
+ })?;
764
+ let exact_path = std::fs::canonicalize(session_path).map_err(|error| {
765
+ ProviderError::ResumeUnavailable(format!(
766
+ "Pi exact session backing is unavailable {}: {error}",
767
+ session_path.display()
768
+ ))
769
+ })?;
770
+ if !exact_path.starts_with(&session_root) {
771
+ return Err(ProviderError::ResumeUnavailable(format!(
772
+ "Pi exact session backing is outside the recorded seat root: {}",
773
+ exact_path.display()
774
+ )));
775
+ }
776
+ let _ = spawn_cwd;
777
+ Some((session_id, exact_path))
778
+ } else {
779
+ std::fs::create_dir_all(&paths.sessions)
780
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", paths.sessions.display())))?;
781
+ None
782
+ };
783
+ let candidate = candidate_from_mcp_config(request.mcp_config)?;
784
+ let wrapper = write_pi_wrapper(PiWrapperRequest {
785
+ destination: &paths.wrapper,
786
+ adapter: &chain.adapter,
787
+ candidate_executable: &candidate,
788
+ mcp_config: request.mcp_config,
789
+ team_id: request.team_id,
790
+ agent_id: request.agent_id,
791
+ workspace: request.workspace,
792
+ include_tools: request.team_mcp_tools,
793
+ })?;
794
+ let (argv, expected_session_id) = if let Some((_session_id, exact_path)) = resume {
795
+ let argv = build_pi_command_argv(PiCommandRequest {
796
+ executable: &chain.launch_executable,
797
+ extension: &wrapper,
798
+ model: &model,
799
+ effort: request.effort,
800
+ system_prompt: request.system_prompt,
801
+ tool_categories: request.tool_categories,
802
+ session_dir: &paths.sessions,
803
+ session: PiSessionSelector::Resume { path: &exact_path },
804
+ agent_id: request.agent_id,
805
+ })?;
806
+ (argv, None)
807
+ } else {
808
+ let session_id = new_pi_session_id();
809
+ let argv = build_pi_command_argv(PiCommandRequest {
810
+ executable: &chain.launch_executable,
811
+ extension: &wrapper,
812
+ model: &model,
813
+ effort: request.effort,
814
+ system_prompt: request.system_prompt,
815
+ tool_categories: request.tool_categories,
816
+ session_dir: &paths.sessions,
817
+ session: PiSessionSelector::Fresh {
818
+ session_id: session_id.as_str(),
819
+ },
820
+ agent_id: request.agent_id,
821
+ })?;
822
+ (argv, Some(session_id))
823
+ };
824
+ Ok(CommandPlan {
825
+ argv,
826
+ expected_session_id,
827
+ provider_projects_root: Some(paths.sessions),
828
+ managed_mcp_config: false,
829
+ })
830
+ }