@team-agent/installer 0.5.68 → 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.
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/diagnose.rs +74 -0
- package/crates/team-agent/src/cli/emit.rs +5 -4
- package/crates/team-agent/src/cli/leader.rs +1 -0
- package/crates/team-agent/src/cli/spec.rs +1 -0
- package/crates/team-agent/src/compiler.rs +62 -5
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +1 -0
- package/crates/team-agent/src/layout/worker_window_helpers.rs +1 -0
- package/crates/team-agent/src/leader/start.rs +53 -12
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +67 -15
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +18 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +260 -44
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +5 -1
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +10 -59
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +830 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +9 -3
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +70 -28
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +1 -0
- package/crates/team-agent/src/lifecycle/launch.rs +10 -2
- package/crates/team-agent/src/lifecycle/profile_launch.rs +3 -2
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +1 -0
- package/crates/team-agent/src/lifecycle/restart/agent.rs +90 -160
- package/crates/team-agent/src/lifecycle/restart/common.rs +146 -28
- package/crates/team-agent/src/lifecycle/restart/remove.rs +37 -1
- package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +195 -18
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +30 -12
- package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +5 -2
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +35 -48
- package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
- package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
- package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +128 -0
- package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
- package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
- package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -0
- package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
- package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
- package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +1 -0
- package/crates/team-agent/src/lifecycle/tests.rs +7 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +61 -4
- package/crates/team-agent/src/messaging/delivery.rs +36 -12
- package/crates/team-agent/src/model/enums.rs +3 -1
- package/crates/team-agent/src/model/permissions.rs +1 -1
- package/crates/team-agent/src/provider/adapter.rs +29 -3
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +5 -2
- package/crates/team-agent/src/provider/adapters/mod.rs +1 -0
- package/crates/team-agent/src/provider/adapters/pi.rs +152 -0
- package/crates/team-agent/src/provider/bypass_flags.rs +1 -0
- package/crates/team-agent/src/provider/classify.rs +2 -2
- package/crates/team-agent/src/provider/faults.rs +1 -1
- package/crates/team-agent/src/provider/session/context_fork.rs +2 -1
- package/crates/team-agent/src/provider/session_scan/common.rs +1 -0
- package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
- package/crates/team-agent/src/provider/session_scan.rs +4 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +80 -0
- package/crates/team-agent/src/provider/wire.rs +7 -1
- package/crates/team-agent/src/tmux_backend.rs +52 -0
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +2 -4
|
@@ -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
|
+
}
|