@team-agent/installer 0.5.68 → 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.
Files changed (63) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/diagnose.rs +74 -0
  4. package/crates/team-agent/src/cli/emit.rs +5 -4
  5. package/crates/team-agent/src/cli/leader.rs +1 -0
  6. package/crates/team-agent/src/cli/spec.rs +1 -0
  7. package/crates/team-agent/src/compiler.rs +54 -7
  8. package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
  9. package/crates/team-agent/src/layout/worker_window_helpers.rs +1 -0
  10. package/crates/team-agent/src/leader/start.rs +53 -12
  11. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +67 -15
  12. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +18 -0
  13. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +1 -0
  14. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +260 -44
  15. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
  16. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +5 -1
  17. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +10 -59
  18. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +838 -0
  19. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +9 -3
  20. package/crates/team-agent/src/lifecycle/launch/spawn.rs +60 -28
  21. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +1 -0
  22. package/crates/team-agent/src/lifecycle/launch.rs +10 -2
  23. package/crates/team-agent/src/lifecycle/profile_launch.rs +3 -2
  24. package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -0
  25. package/crates/team-agent/src/lifecycle/restart/agent.rs +90 -160
  26. package/crates/team-agent/src/lifecycle/restart/common.rs +136 -28
  27. package/crates/team-agent/src/lifecycle/restart/remove.rs +37 -1
  28. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
  29. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +195 -18
  30. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +30 -12
  31. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
  32. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +5 -2
  33. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +35 -48
  34. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  35. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
  36. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +145 -0
  37. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +243 -0
  38. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +856 -0
  39. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +387 -0
  40. package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
  41. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
  42. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1 -0
  43. package/crates/team-agent/src/lifecycle/tests.rs +7 -0
  44. package/crates/team-agent/src/lifecycle/worker_command_context.rs +61 -4
  45. package/crates/team-agent/src/messaging/delivery.rs +36 -12
  46. package/crates/team-agent/src/model/enums.rs +3 -1
  47. package/crates/team-agent/src/model/permissions.rs +1 -1
  48. package/crates/team-agent/src/provider/adapter.rs +29 -3
  49. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +5 -2
  50. package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
  51. package/crates/team-agent/src/provider/adapters/pi.rs +146 -0
  52. package/crates/team-agent/src/provider/bypass_flags.rs +1 -0
  53. package/crates/team-agent/src/provider/classify.rs +2 -2
  54. package/crates/team-agent/src/provider/faults.rs +1 -1
  55. package/crates/team-agent/src/provider/session/context_fork.rs +2 -1
  56. package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
  57. package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
  58. package/crates/team-agent/src/provider/session_scan.rs +4 -0
  59. package/crates/team-agent/src/provider/tests/adapter.rs +80 -0
  60. package/crates/team-agent/src/provider/wire.rs +7 -1
  61. package/crates/team-agent/src/tmux_backend.rs +52 -0
  62. package/package.json +4 -4
  63. package/skills/team-agent/SKILL.md +9 -5
@@ -0,0 +1,838 @@
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 per-seat runtime MCP registration extension
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
+ || !matches!(
274
+ chain.path_entry_type,
275
+ PiExecutableFileType::Wrapper | PiExecutableFileType::Symlink
276
+ )
277
+ || chain.launch_executable != chain.path_entry
278
+ || chain.real_binary == chain.launch_executable
279
+ || chain.catalog_sha256.len() != 64
280
+ || !chain
281
+ .catalog_sha256
282
+ .bytes()
283
+ .all(|byte| byte.is_ascii_hexdigit())
284
+ || chain.catalog_sha256.bytes().all(|byte| byte == b'0')
285
+ {
286
+ return Err(ProviderError::Command(
287
+ "Pi executable chain does not provide the required public protocol".to_string(),
288
+ ));
289
+ }
290
+ validate_pi_adapter_identity(&chain.adapter)
291
+ }
292
+
293
+ pub(crate) struct PiWrapperRequest<'a> {
294
+ pub destination: &'a Path,
295
+ pub adapter: &'a PiAdapterIdentity,
296
+ pub candidate_executable: &'a Path,
297
+ pub mcp_config: &'a McpConfig,
298
+ pub team_id: &'a str,
299
+ pub agent_id: &'a str,
300
+ pub workspace: &'a Path,
301
+ pub include_tools: &'a [&'a str],
302
+ }
303
+
304
+ fn team_server(
305
+ config: &McpConfig,
306
+ ) -> Result<serde_json::Map<String, serde_json::Value>, ProviderError> {
307
+ let server = config
308
+ .raw
309
+ .get("team_orchestrator")
310
+ .or_else(|| config.raw.pointer("/mcpServers/team_orchestrator"))
311
+ .and_then(serde_json::Value::as_object)
312
+ .cloned()
313
+ .ok_or_else(|| {
314
+ ProviderError::Command("resolved MCP config is missing team_orchestrator".to_string())
315
+ })?;
316
+ Ok(server)
317
+ }
318
+
319
+ fn candidate_from_mcp_config(config: &McpConfig) -> Result<PathBuf, ProviderError> {
320
+ let server = team_server(config)?;
321
+ let command = server
322
+ .get("command")
323
+ .and_then(serde_json::Value::as_str)
324
+ .filter(|value| !value.is_empty())
325
+ .map(PathBuf::from)
326
+ .ok_or_else(|| {
327
+ ProviderError::Command("resolved team_orchestrator MCP command is missing".to_string())
328
+ })?;
329
+ if !command.is_absolute() {
330
+ return Err(ProviderError::Command(
331
+ "resolved team_orchestrator MCP command must be absolute".to_string(),
332
+ ));
333
+ }
334
+ Ok(command)
335
+ }
336
+
337
+ fn render_pi_wrapper(request: &PiWrapperRequest<'_>) -> Result<String, ProviderError> {
338
+ validate_pi_adapter_identity(request.adapter)?;
339
+ let configured_candidate = candidate_from_mcp_config(request.mcp_config)?;
340
+ let expected = std::fs::canonicalize(request.candidate_executable)
341
+ .unwrap_or_else(|_| request.candidate_executable.to_path_buf());
342
+ let configured = std::fs::canonicalize(&configured_candidate).unwrap_or(configured_candidate);
343
+ if configured != expected {
344
+ return Err(ProviderError::Command(format!(
345
+ "Pi wrapper candidate mismatch: expected {} got {}",
346
+ expected.display(),
347
+ configured.display()
348
+ )));
349
+ }
350
+ let mut server = team_server(request.mcp_config)?;
351
+ server.insert(
352
+ "command".to_string(),
353
+ serde_json::Value::String(expected.to_string_lossy().into_owned()),
354
+ );
355
+ server.insert(
356
+ "includeTools".to_string(),
357
+ serde_json::Value::Array(
358
+ request
359
+ .include_tools
360
+ .iter()
361
+ .map(|tool| serde_json::Value::String((*tool).to_string()))
362
+ .collect(),
363
+ ),
364
+ );
365
+ let env = server
366
+ .entry("env".to_string())
367
+ .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()))
368
+ .as_object_mut()
369
+ .ok_or_else(|| ProviderError::Command("Pi MCP server env must be an object".to_string()))?;
370
+ env.insert(
371
+ "TEAM_AGENT_WORKSPACE".to_string(),
372
+ serde_json::Value::String(request.workspace.to_string_lossy().into_owned()),
373
+ );
374
+ env.insert(
375
+ "TEAM_AGENT_ID".to_string(),
376
+ serde_json::Value::String(request.agent_id.to_string()),
377
+ );
378
+ env.insert(
379
+ "TEAM_AGENT_AGENT_ID".to_string(),
380
+ serde_json::Value::String(request.agent_id.to_string()),
381
+ );
382
+ env.insert(
383
+ "TEAM_AGENT_OWNER_TEAM_ID".to_string(),
384
+ serde_json::Value::String(request.team_id.to_string()),
385
+ );
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}")))?;
388
+ Ok(format!(
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
+ "#
414
+ ))
415
+ }
416
+
417
+ /// ---
418
+ /// purpose: 静态核对 wrapper 只做 per-seat runtime MCP registration
419
+ /// returns: wrapper 保留 direct Pi 配置且冻结 seat identity 时成功
420
+ /// errors: identity、runtime registration 或 candidate 缺失时返回 ProviderError
421
+ /// ---
422
+ pub(crate) fn validate_pi_wrapper_source(
423
+ source: &str,
424
+ adapter: &PiAdapterIdentity,
425
+ candidate_executable: &Path,
426
+ ) -> Result<(), ProviderError> {
427
+ validate_pi_adapter_identity(adapter)?;
428
+ let candidate = candidate_executable.to_string_lossy();
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")
433
+ || !source.contains("team_orchestrator")
434
+ || !source.contains("includeTools")
435
+ || source.contains("createMcpAdapter")
436
+ || source.contains("directTools")
437
+ || source.contains("toolPrefix")
438
+ || source.contains("--mcp-config")
439
+ {
440
+ return Err(ProviderError::Command(
441
+ "Pi wrapper source does not preserve direct Pi plus per-seat runtime MCP".to_string(),
442
+ ));
443
+ }
444
+ Ok(())
445
+ }
446
+
447
+ /// ---
448
+ /// purpose: 在 seat root 原子写入 mode 0600 的 runtime MCP registration extension
449
+ /// returns: 最终 wrapper 路径
450
+ /// errors: identity、序列化、目录、写盘或 rename 失败时返回 ProviderError
451
+ /// ---
452
+ pub(crate) fn write_pi_wrapper(request: PiWrapperRequest<'_>) -> Result<PathBuf, ProviderError> {
453
+ let source = render_pi_wrapper(&request)?;
454
+ validate_pi_wrapper_source(&source, request.adapter, request.candidate_executable)?;
455
+ let parent = request.destination.parent().ok_or_else(|| {
456
+ ProviderError::Io("Pi wrapper destination has no parent directory".to_string())
457
+ })?;
458
+ std::fs::create_dir_all(parent)
459
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", parent.display())))?;
460
+ let temp = parent.join(format!(".team-mcp.ts.{}.tmp", std::process::id()));
461
+ let write_result = (|| {
462
+ let mut file = OpenOptions::new()
463
+ .create_new(true)
464
+ .write(true)
465
+ .mode(0o600)
466
+ .open(&temp)
467
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", temp.display())))?;
468
+ file.write_all(source.as_bytes())
469
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", temp.display())))?;
470
+ file.sync_all()
471
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", temp.display())))?;
472
+ std::fs::rename(&temp, request.destination).map_err(|error| {
473
+ ProviderError::Io(format!(
474
+ "rename {} to {}: {error}",
475
+ temp.display(),
476
+ request.destination.display()
477
+ ))
478
+ })?;
479
+ std::fs::set_permissions(request.destination, std::fs::Permissions::from_mode(0o600))
480
+ .map_err(|error| {
481
+ ProviderError::Io(format!("{}: {error}", request.destination.display()))
482
+ })?;
483
+ Ok(request.destination.to_path_buf())
484
+ })();
485
+ if write_result.is_err() {
486
+ let _ = std::fs::remove_file(&temp);
487
+ }
488
+ write_result
489
+ }
490
+
491
+ #[derive(Debug, Clone, PartialEq, Eq)]
492
+ pub(crate) struct PiLeaderArgs {
493
+ pub model: Option<String>,
494
+ pub effort: Option<ProviderEffort>,
495
+ }
496
+
497
+ /// ---
498
+ /// purpose: 解析 Pi leader 唯一允许的 model/thinking 输入
499
+ /// returns: 仅保留调用方显式给出的 qualified model 与 effort
500
+ /// errors: 重复、未知或 materializer-owned flag 时返回 ProviderError
501
+ /// ---
502
+ pub(crate) fn parse_pi_leader_args(args: &[String]) -> Result<PiLeaderArgs, ProviderError> {
503
+ let args = args.strip_prefix(&["--".to_string()]).unwrap_or(args);
504
+ let mut model = None;
505
+ let mut effort = None;
506
+ let mut index = 0;
507
+ while index < args.len() {
508
+ let flag = args[index].as_str();
509
+ let value = args.get(index + 1).ok_or_else(|| {
510
+ ProviderError::Command(format!("Pi leader argument {flag} requires a value"))
511
+ })?;
512
+ match flag {
513
+ "--model" if model.is_none() => model = Some(value.clone()),
514
+ "--thinking" if effort.is_none() => {
515
+ effort = ProviderEffort::parse(value);
516
+ if effort.is_none() {
517
+ return Err(ProviderError::Command(format!(
518
+ "unknown Pi thinking effort {value:?}"
519
+ )));
520
+ }
521
+ }
522
+ _ => {
523
+ return Err(ProviderError::Command(format!(
524
+ "Pi leader accepts only one --model and one --thinking; got {flag:?}"
525
+ )));
526
+ }
527
+ }
528
+ index += 2;
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
+ }) {
536
+ return Err(ProviderError::Command(
537
+ "Pi leader --model must be a qualified exact id".to_string(),
538
+ ));
539
+ }
540
+ Ok(PiLeaderArgs { model, effort })
541
+ }
542
+
543
+ fn sha256(bytes: &[u8]) -> String {
544
+ format!("{:x}", Sha256::digest(bytes))
545
+ }
546
+
547
+ fn executable_file_type(path: &Path) -> Result<PiExecutableFileType, ProviderError> {
548
+ let metadata = std::fs::symlink_metadata(path)
549
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", path.display())))?;
550
+ if metadata.file_type().is_symlink() {
551
+ return Ok(PiExecutableFileType::Symlink);
552
+ }
553
+ let bytes = std::fs::read(path)
554
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", path.display())))?;
555
+ if bytes.starts_with(b"#!") {
556
+ Ok(PiExecutableFileType::Wrapper)
557
+ } else {
558
+ Ok(PiExecutableFileType::Binary)
559
+ }
560
+ }
561
+
562
+ fn pi_path_entries() -> Result<Vec<PathBuf>, ProviderError> {
563
+ let path = std::env::var_os("PATH")
564
+ .ok_or_else(|| ProviderError::Command("PATH is not set".to_string()))?;
565
+ let mut seen = BTreeSet::new();
566
+ let entries = std::env::split_paths(&path)
567
+ .map(|directory| directory.join("pi"))
568
+ .filter(|candidate| candidate.is_file())
569
+ .filter(|candidate| seen.insert(candidate.clone()))
570
+ .collect::<Vec<_>>();
571
+ if entries.is_empty() {
572
+ return Err(ProviderError::Command(
573
+ "Pi executable not found on PATH".to_string(),
574
+ ));
575
+ }
576
+ Ok(entries)
577
+ }
578
+
579
+ fn command_stdout(executable: &Path, args: &[&str]) -> Result<Vec<u8>, ProviderError> {
580
+ let output = Command::new(executable)
581
+ .args(args)
582
+ .output()
583
+ .map_err(|error| {
584
+ ProviderError::Io(format!(
585
+ "{} {}: {error}",
586
+ executable.display(),
587
+ args.join(" ")
588
+ ))
589
+ })?;
590
+ if !output.status.success() {
591
+ return Err(ProviderError::Command(format!(
592
+ "{} {} exited {}",
593
+ executable.display(),
594
+ args.join(" "),
595
+ output.status
596
+ )));
597
+ }
598
+ Ok(output.stdout)
599
+ }
600
+
601
+ fn discover_pi_adapter(launch_executable: &Path) -> Result<PiAdapterIdentity, ProviderError> {
602
+ let listing = command_stdout(launch_executable, &["list"])?;
603
+ let listing = std::str::from_utf8(&listing).map_err(|error| {
604
+ ProviderError::Command(format!("Pi package list is not UTF-8: {error}"))
605
+ })?;
606
+ let mut lines = listing.lines();
607
+ let mut package_root = None;
608
+ while let Some(line) = lines.next() {
609
+ if line.trim() == "npm:pi-mcp-adapter" {
610
+ package_root = lines
611
+ .next()
612
+ .map(str::trim)
613
+ .filter(|path| Path::new(path).is_absolute())
614
+ .map(PathBuf::from);
615
+ break;
616
+ }
617
+ }
618
+ let package_root = package_root.ok_or_else(|| {
619
+ ProviderError::Command("Pi package list does not resolve npm:pi-mcp-adapter".to_string())
620
+ })?;
621
+ let package_json = package_root.join("package.json");
622
+ let package_bytes = std::fs::read(&package_json)
623
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", package_json.display())))?;
624
+ let package: serde_json::Value = serde_json::from_slice(&package_bytes)
625
+ .map_err(|error| ProviderError::Command(format!("{}: {error}", package_json.display())))?;
626
+ let extension_entry = package
627
+ .pointer("/pi/extensions")
628
+ .and_then(serde_json::Value::as_array)
629
+ .and_then(|entries| entries.iter().find_map(serde_json::Value::as_str))
630
+ .unwrap_or("")
631
+ .to_string();
632
+ let index_ts = package_root.join(extension_entry.trim_start_matches("./"));
633
+ let index_bytes = std::fs::read(&index_ts)
634
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", index_ts.display())))?;
635
+ let identity = PiAdapterIdentity {
636
+ package_name: package
637
+ .get("name")
638
+ .and_then(serde_json::Value::as_str)
639
+ .unwrap_or("")
640
+ .to_string(),
641
+ version: package
642
+ .get("version")
643
+ .and_then(serde_json::Value::as_str)
644
+ .unwrap_or("")
645
+ .to_string(),
646
+ extension_entry,
647
+ package_json,
648
+ index_ts,
649
+ package_json_sha256: sha256(&package_bytes),
650
+ index_ts_sha256: sha256(&index_bytes),
651
+ };
652
+ validate_pi_adapter_identity(&identity)?;
653
+ Ok(identity)
654
+ }
655
+
656
+ /// ---
657
+ /// purpose: 从当前 PATH 环境测量 Pi launch/real executable、catalog 与 adapter
658
+ /// returns: 已验证的 chain 与同次 live catalog exact ids
659
+ /// errors: 缺失、命令失败或任一公共 protocol capability 缺失时返回 ProviderError
660
+ /// ---
661
+ pub(crate) fn resolve_pi_executable_chain(
662
+ ) -> Result<(PiExecutableChain, Vec<String>), ProviderError> {
663
+ let path_entries = pi_path_entries()?;
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
+ }
674
+ let version =
675
+ String::from_utf8(command_stdout(&path_entry, &["--version"])?).map_err(|error| {
676
+ ProviderError::Command(format!("Pi version output is not UTF-8: {error}"))
677
+ })?;
678
+ let version = version.trim().to_string();
679
+ let launch_identity = std::fs::canonicalize(&path_entry)
680
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", path_entry.display())))?;
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
+ };
696
+ let _real_version =
697
+ String::from_utf8(command_stdout(&real_binary, &["--version"])?).map_err(|error| {
698
+ ProviderError::Command(format!(
699
+ "Pi real binary version output is not UTF-8: {error}"
700
+ ))
701
+ })?;
702
+ let catalog = command_stdout(&path_entry, &["--list-models"])?;
703
+ let models = parse_pi_list_models_table(&catalog)?;
704
+ let adapter = discover_pi_adapter(&path_entry)?;
705
+ let chain = PiExecutableChain {
706
+ path_entry: path_entry.clone(),
707
+ path_entry_type,
708
+ launch_executable: path_entry,
709
+ real_binary,
710
+ pi_version: version,
711
+ catalog_sha256: sha256(&catalog),
712
+ adapter,
713
+ };
714
+ validate_pi_executable_chain(&chain)?;
715
+ Ok((chain, models))
716
+ }
717
+
718
+ pub(crate) struct PiMaterializeRequest<'a> {
719
+ pub workspace: &'a Path,
720
+ pub team_id: &'a str,
721
+ pub agent_id: &'a str,
722
+ pub model: Option<&'a str>,
723
+ pub effort: Option<ProviderEffort>,
724
+ pub system_prompt: &'a str,
725
+ pub tool_categories: &'a [&'a str],
726
+ pub team_mcp_tools: &'a [&'a str],
727
+ pub mcp_config: &'a McpConfig,
728
+ }
729
+
730
+ /// ---
731
+ /// purpose: 共享物化 Pi leader 与 TeamMate 的 wrapper、UUID 和 CommandPlan
732
+ /// returns: fresh Pi CommandPlan,含 expected UUID 与 seat session root
733
+ /// errors: resolver、exact model、wrapper 或 argv 构造失败时返回 ProviderError
734
+ /// ---
735
+ pub(crate) fn materialize_pi_plan(
736
+ request: PiMaterializeRequest<'_>,
737
+ ) -> Result<CommandPlan, ProviderError> {
738
+ materialize_pi_plan_with_session(request, None)
739
+ }
740
+
741
+ /// ---
742
+ /// purpose: 用上游已验证的 captured id/path 物化 Pi exact-path resume 计划
743
+ /// returns: 与 fresh 共用 resolver/wrapper/argv builder,且不再生成 session id
744
+ /// errors: backing 缺失或越出席位 root 时拒绝;header id/cwd 由 session scanner 在调用前验证
745
+ /// ---
746
+ pub(crate) fn materialize_pi_resume_plan(
747
+ request: PiMaterializeRequest<'_>,
748
+ session_id: &SessionId,
749
+ session_path: &Path,
750
+ spawn_cwd: &Path,
751
+ ) -> Result<CommandPlan, ProviderError> {
752
+ materialize_pi_plan_with_session(request, Some((session_id, session_path, spawn_cwd)))
753
+ }
754
+
755
+ fn materialize_pi_plan_with_session(
756
+ request: PiMaterializeRequest<'_>,
757
+ resume: Option<(&SessionId, &Path, &Path)>,
758
+ ) -> Result<CommandPlan, ProviderError> {
759
+ let (chain, catalog) = resolve_pi_executable_chain()?;
760
+ let model = request
761
+ .model
762
+ .map(|model| select_exact_pi_model(&catalog, model))
763
+ .transpose()?;
764
+ let paths = pi_seat_paths(request.workspace, request.team_id, request.agent_id);
765
+ let resume = if let Some((session_id, session_path, spawn_cwd)) = resume {
766
+ let session_root = std::fs::canonicalize(&paths.sessions).map_err(|error| {
767
+ ProviderError::ResumeUnavailable(format!(
768
+ "Pi session root is unavailable {}: {error}",
769
+ paths.sessions.display()
770
+ ))
771
+ })?;
772
+ let exact_path = std::fs::canonicalize(session_path).map_err(|error| {
773
+ ProviderError::ResumeUnavailable(format!(
774
+ "Pi exact session backing is unavailable {}: {error}",
775
+ session_path.display()
776
+ ))
777
+ })?;
778
+ if !exact_path.starts_with(&session_root) {
779
+ return Err(ProviderError::ResumeUnavailable(format!(
780
+ "Pi exact session backing is outside the recorded seat root: {}",
781
+ exact_path.display()
782
+ )));
783
+ }
784
+ let _ = spawn_cwd;
785
+ Some((session_id, exact_path))
786
+ } else {
787
+ std::fs::create_dir_all(&paths.sessions)
788
+ .map_err(|error| ProviderError::Io(format!("{}: {error}", paths.sessions.display())))?;
789
+ None
790
+ };
791
+ let candidate = candidate_from_mcp_config(request.mcp_config)?;
792
+ let wrapper = write_pi_wrapper(PiWrapperRequest {
793
+ destination: &paths.wrapper,
794
+ adapter: &chain.adapter,
795
+ candidate_executable: &candidate,
796
+ mcp_config: request.mcp_config,
797
+ team_id: request.team_id,
798
+ agent_id: request.agent_id,
799
+ workspace: request.workspace,
800
+ include_tools: request.team_mcp_tools,
801
+ })?;
802
+ let (argv, expected_session_id) = if let Some((_session_id, exact_path)) = resume {
803
+ let argv = build_pi_command_argv(PiCommandRequest {
804
+ executable: &chain.launch_executable,
805
+ extension: &wrapper,
806
+ model: model.as_deref(),
807
+ effort: request.effort,
808
+ system_prompt: request.system_prompt,
809
+ tool_categories: request.tool_categories,
810
+ session_dir: &paths.sessions,
811
+ session: PiSessionSelector::Resume { path: &exact_path },
812
+ agent_id: request.agent_id,
813
+ })?;
814
+ (argv, None)
815
+ } else {
816
+ let session_id = new_pi_session_id();
817
+ let argv = build_pi_command_argv(PiCommandRequest {
818
+ executable: &chain.launch_executable,
819
+ extension: &wrapper,
820
+ model: model.as_deref(),
821
+ effort: request.effort,
822
+ system_prompt: request.system_prompt,
823
+ tool_categories: request.tool_categories,
824
+ session_dir: &paths.sessions,
825
+ session: PiSessionSelector::Fresh {
826
+ session_id: session_id.as_str(),
827
+ },
828
+ agent_id: request.agent_id,
829
+ })?;
830
+ (argv, Some(session_id))
831
+ };
832
+ Ok(CommandPlan {
833
+ argv,
834
+ expected_session_id,
835
+ provider_projects_root: Some(paths.sessions),
836
+ managed_mcp_config: false,
837
+ })
838
+ }