@team-agent/installer 0.5.71 → 0.5.72
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/adapters.rs +107 -9
- package/crates/team-agent/src/cli/emit.rs +35 -5
- package/crates/team-agent/src/cli/mod.rs +78 -7
- package/crates/team-agent/src/cli/models.rs +443 -0
- package/crates/team-agent/src/cli/spec.rs +4 -1
- package/crates/team-agent/src/cli/types.rs +8 -0
- package/crates/team-agent/src/compiler.rs +157 -0
- package/crates/team-agent/src/leader/owner_bind.rs +21 -0
- package/crates/team-agent/src/leader/provider_attribution.rs +25 -0
- package/crates/team-agent/src/leader/registry.rs +25 -1
- package/crates/team-agent/src/leader/start.rs +33 -9
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +169 -10
- package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +76 -2
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +859 -12
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +168 -0
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +209 -0
- package/crates/team-agent/src/lifecycle/types.rs +8 -0
- package/npm/check-skill-links.mjs +88 -0
- package/package.json +5 -4
- package/skills/team-agent/SKILL.md +15 -49
- package/skills/team-agent/command-coverage.json +8 -1
- package/skills/team-agent/references/team-agent-operator.md +665 -0
|
@@ -220,6 +220,16 @@ fn bind_provider_from_env_or_pane(command: &str, pane: Option<PaneInfo>) -> Prov
|
|
|
220
220
|
.unwrap_or(Provider::Codex)
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
pub(crate) fn strict_owner_bind_provider(
|
|
224
|
+
explicit_provider: Option<&str>,
|
|
225
|
+
observed_command: &str,
|
|
226
|
+
) -> Option<Provider> {
|
|
227
|
+
match explicit_provider.filter(|value| !value.is_empty()) {
|
|
228
|
+
Some(raw) => super::helpers::parse_provider(raw),
|
|
229
|
+
None => super::attribute_command_provider(observed_command),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
223
233
|
pub fn owner_bind_provider_wire(command: &str) -> &'static str {
|
|
224
234
|
if let Ok(raw) = std::env::var("TEAM_AGENT_LEADER_PROVIDER") {
|
|
225
235
|
// env 显式 provider:经 parse_provider(单一表,知 copilot)校验后透传其 wire 串;
|
|
@@ -341,6 +351,17 @@ mod e11_provider_bind_tests {
|
|
|
341
351
|
assert_eq!(owner_bind_provider_wire("copilot --banner"), "copilot");
|
|
342
352
|
}
|
|
343
353
|
|
|
354
|
+
#[test]
|
|
355
|
+
fn strict_provider_never_falls_back_from_unknown_explicit_or_command() {
|
|
356
|
+
assert_eq!(
|
|
357
|
+
strict_owner_bind_provider(Some("pi"), "codex"),
|
|
358
|
+
Some(Provider::Pi)
|
|
359
|
+
);
|
|
360
|
+
assert_eq!(strict_owner_bind_provider(Some("unknown"), "codex"), None);
|
|
361
|
+
assert_eq!(strict_owner_bind_provider(None, "pi"), Some(Provider::Pi));
|
|
362
|
+
assert_eq!(strict_owner_bind_provider(None, "node"), None);
|
|
363
|
+
}
|
|
364
|
+
|
|
344
365
|
#[test]
|
|
345
366
|
fn known_commands_map_via_single_source() {
|
|
346
367
|
assert_eq!(
|
|
@@ -96,6 +96,9 @@ fn provider_from_pid_env(pid: u32) -> Option<Provider> {
|
|
|
96
96
|
|
|
97
97
|
fn provider_from_command_text(text: &str) -> Option<Provider> {
|
|
98
98
|
let lower = text.to_ascii_lowercase();
|
|
99
|
+
if pi_command_text(&lower) {
|
|
100
|
+
return Some(Provider::Pi);
|
|
101
|
+
}
|
|
99
102
|
if cursor_agent_command_text(&lower) {
|
|
100
103
|
return Some(Provider::CursorAgent);
|
|
101
104
|
}
|
|
@@ -104,6 +107,18 @@ fn provider_from_command_text(text: &str) -> Option<Provider> {
|
|
|
104
107
|
.find_map(|(needle, provider)| lower.contains(needle).then_some(*provider))
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
fn pi_command_text(lower: &str) -> bool {
|
|
111
|
+
lower
|
|
112
|
+
.split_whitespace()
|
|
113
|
+
.next()
|
|
114
|
+
.is_some_and(|token| {
|
|
115
|
+
token == "pi"
|
|
116
|
+
|| token == "pi.exe"
|
|
117
|
+
|| token.ends_with("/pi")
|
|
118
|
+
|| token.ends_with("\\pi.exe")
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
107
122
|
/// Cursor 二进制名为 `agent`,不能做 substring contains(会误伤 `team-agent`)。
|
|
108
123
|
/// 认精确 token `cursor-agent` / `…/agent`,且必须带 `--sandbox` 或 `--trust`。
|
|
109
124
|
fn cursor_agent_command_text(lower: &str) -> bool {
|
|
@@ -194,6 +209,16 @@ mod tests {
|
|
|
194
209
|
}
|
|
195
210
|
}
|
|
196
211
|
|
|
212
|
+
#[test]
|
|
213
|
+
fn exact_pi_command_is_attributed_without_substring_guessing() {
|
|
214
|
+
assert_eq!(attribute_command_provider("pi"), Some(Provider::Pi));
|
|
215
|
+
assert_eq!(
|
|
216
|
+
attribute_command_provider("/opt/homebrew/bin/pi --model openai/gpt-5"),
|
|
217
|
+
Some(Provider::Pi)
|
|
218
|
+
);
|
|
219
|
+
assert_eq!(attribute_command_provider("pilot"), None);
|
|
220
|
+
}
|
|
221
|
+
|
|
197
222
|
#[test]
|
|
198
223
|
fn direct_command_form_recognizes_copilot() {
|
|
199
224
|
assert_eq!(
|
|
@@ -243,7 +243,10 @@ pub fn register_binding_from_state_best_effort(
|
|
|
243
243
|
);
|
|
244
244
|
let event_log = crate::event_log::EventLog::new(workspace);
|
|
245
245
|
let write_result = write_entry_best_effort(&entry);
|
|
246
|
-
|
|
246
|
+
// Fresh quick-start is acquisition-only: it must never displace another
|
|
247
|
+
// same-pane row. Its caller performs a validated-LIVE check before this
|
|
248
|
+
// write; manual/restart ownership paths keep existing behavior.
|
|
249
|
+
if write_result.is_some() && source != "quick-start" {
|
|
247
250
|
let _ = mark_displaced_same_pane_entries_unbound(&entry);
|
|
248
251
|
}
|
|
249
252
|
let status = if let Some(path) = &write_result {
|
|
@@ -543,6 +546,27 @@ pub fn list_validated_with_gc() -> Vec<(LeaderRegistryEntry, &'static str, Optio
|
|
|
543
546
|
out
|
|
544
547
|
}
|
|
545
548
|
|
|
549
|
+
/// Refuse a fresh quick-start binding when the caller pane already backs a
|
|
550
|
+
/// canonically LIVE receiver for another workspace/team. This is read-only:
|
|
551
|
+
/// quick-start never displaces or takes over an existing binding.
|
|
552
|
+
#[must_use]
|
|
553
|
+
pub(crate) fn live_same_pane_binding_elsewhere(
|
|
554
|
+
pane_id: &str,
|
|
555
|
+
workspace: &Path,
|
|
556
|
+
team_key: &str,
|
|
557
|
+
) -> bool {
|
|
558
|
+
let workspace = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
|
|
559
|
+
list_validated_no_gc().into_iter().any(|(entry, status, _)| {
|
|
560
|
+
status == "LIVE"
|
|
561
|
+
&& entry
|
|
562
|
+
.channel
|
|
563
|
+
.get("pane_id")
|
|
564
|
+
.and_then(Value::as_str)
|
|
565
|
+
== Some(pane_id)
|
|
566
|
+
&& (entry.workspace != workspace || entry.team_key != team_key)
|
|
567
|
+
})
|
|
568
|
+
}
|
|
569
|
+
|
|
546
570
|
/// Same as `list_validated_with_gc` but preserves all entries. `send
|
|
547
571
|
/// --to-leader` needs to see stale entries so it can refuse with
|
|
548
572
|
/// `registry_stale` (and never silently fall through to
|
|
@@ -138,15 +138,18 @@ pub(crate) fn prepare_leader_start_with_nested_attach(
|
|
|
138
138
|
) -> Result<PreparedLeaderStart, PrepareLeaderStartError> {
|
|
139
139
|
let explicit_external_path = external_leader || attach_existing || attach_session.is_some();
|
|
140
140
|
let state_external_path = workspace_state_uses_external_leader(workspace);
|
|
141
|
-
let (ambient_authority, managed_client_attach_mode,
|
|
141
|
+
let (ambient_authority, managed_client_attach_mode, managed_exec_provider) =
|
|
142
142
|
if explicit_external_path || state_external_path {
|
|
143
143
|
(ambient_pane_authority_preflight(workspace)?, None, false)
|
|
144
144
|
} else if workspace_state_is_managed_provider_reentry(workspace) {
|
|
145
145
|
(ambient_pane_authority_preflight(workspace)?, None, true)
|
|
146
146
|
} else {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
managed_launcher_ambient_route(
|
|
148
|
+
provider,
|
|
149
|
+
provider_args,
|
|
150
|
+
workspace,
|
|
151
|
+
allow_nested_attach,
|
|
152
|
+
)?
|
|
150
153
|
};
|
|
151
154
|
let plan = leader_start_plan_with_ambient_authority(
|
|
152
155
|
provider,
|
|
@@ -158,7 +161,7 @@ pub(crate) fn prepare_leader_start_with_nested_attach(
|
|
|
158
161
|
external_leader,
|
|
159
162
|
std::env::var_os("TMUX").is_some(),
|
|
160
163
|
managed_client_attach_mode,
|
|
161
|
-
|
|
164
|
+
managed_exec_provider,
|
|
162
165
|
)?;
|
|
163
166
|
Ok(PreparedLeaderStart {
|
|
164
167
|
plan,
|
|
@@ -206,7 +209,7 @@ fn leader_start_plan_with_ambient_authority(
|
|
|
206
209
|
external_leader: bool,
|
|
207
210
|
in_tmux: bool,
|
|
208
211
|
managed_client_attach_mode: Option<ManagedClientAttachMode>,
|
|
209
|
-
|
|
212
|
+
managed_exec_provider: bool,
|
|
210
213
|
) -> Result<LeaderStartPlan, LeaderError> {
|
|
211
214
|
if attach_session.is_some() && !confirm_attach {
|
|
212
215
|
return Err(LeaderError::Start(
|
|
@@ -264,7 +267,7 @@ fn leader_start_plan_with_ambient_authority(
|
|
|
264
267
|
} else {
|
|
265
268
|
false
|
|
266
269
|
};
|
|
267
|
-
let mode = if
|
|
270
|
+
let mode = if managed_exec_provider {
|
|
268
271
|
LeaderStartMode::ExecProvider
|
|
269
272
|
} else if !external_path {
|
|
270
273
|
LeaderStartMode::ManagedTmuxClient
|
|
@@ -555,17 +558,24 @@ fn ambient_pane_authority_preflight(
|
|
|
555
558
|
}
|
|
556
559
|
|
|
557
560
|
fn managed_launcher_ambient_route(
|
|
561
|
+
provider: Provider,
|
|
562
|
+
provider_args: &[String],
|
|
558
563
|
workspace: &Path,
|
|
559
564
|
allow_nested_attach: bool,
|
|
560
565
|
) -> Result<
|
|
561
566
|
(
|
|
562
567
|
Option<VerifiedAmbientPaneAuthority>,
|
|
563
568
|
Option<ManagedClientAttachMode>,
|
|
569
|
+
bool,
|
|
564
570
|
),
|
|
565
571
|
PrepareLeaderStartError,
|
|
566
572
|
> {
|
|
567
573
|
let Some(tmux) = std::env::var_os("TMUX") else {
|
|
568
|
-
return Ok((
|
|
574
|
+
return Ok((
|
|
575
|
+
None,
|
|
576
|
+
Some(ManagedClientAttachMode::AttachSession),
|
|
577
|
+
false,
|
|
578
|
+
));
|
|
569
579
|
};
|
|
570
580
|
let tmux = tmux.into_string().map_err(|_| {
|
|
571
581
|
PrepareLeaderStartError::PaneAuthorityRefused(ambient_tmux_endpoint_refusal(
|
|
@@ -575,6 +585,15 @@ fn managed_launcher_ambient_route(
|
|
|
575
585
|
})?;
|
|
576
586
|
let observed_endpoint = validated_ambient_tmux_endpoint(workspace, &tmux)
|
|
577
587
|
.map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
|
|
588
|
+
if provider == Provider::Pi && provider_args.is_empty() && !allow_nested_attach {
|
|
589
|
+
// Pi's zero-argument managed entry already owns this verified pane as its
|
|
590
|
+
// user-facing terminal. Replacing the current process is safe on either
|
|
591
|
+
// the workspace server or another server; switching/attaching would move
|
|
592
|
+
// the user away from the positively verified workspace pane.
|
|
593
|
+
let authority = verified_ambient_pane_authority(workspace, &tmux)
|
|
594
|
+
.map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
|
|
595
|
+
return Ok((Some(authority), None, true));
|
|
596
|
+
}
|
|
578
597
|
let target_socket_name = TmuxBackend::for_workspace(workspace)
|
|
579
598
|
.tmux_endpoint()
|
|
580
599
|
.ok_or_else(|| LeaderError::Start("workspace tmux endpoint missing".to_string()))?;
|
|
@@ -589,7 +608,11 @@ fn managed_launcher_ambient_route(
|
|
|
589
608
|
if same_server {
|
|
590
609
|
let authority = verified_ambient_pane_authority(workspace, &tmux)
|
|
591
610
|
.map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
|
|
592
|
-
return Ok((
|
|
611
|
+
return Ok((
|
|
612
|
+
Some(authority),
|
|
613
|
+
Some(ManagedClientAttachMode::SwitchClient),
|
|
614
|
+
false,
|
|
615
|
+
));
|
|
593
616
|
}
|
|
594
617
|
if !allow_nested_attach {
|
|
595
618
|
return Err(LeaderError::Validation(format!(
|
|
@@ -603,6 +626,7 @@ fn managed_launcher_ambient_route(
|
|
|
603
626
|
Ok((
|
|
604
627
|
Some(authority),
|
|
605
628
|
Some(ManagedClientAttachMode::AttachSession),
|
|
629
|
+
false,
|
|
606
630
|
))
|
|
607
631
|
}
|
|
608
632
|
|
|
@@ -93,6 +93,51 @@ impl Drop for AgentReservation {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
fn discover_pi_model_candidates(requested: &str) -> Result<Vec<String>, ()> {
|
|
97
|
+
crate::lifecycle::launch::pi_mcp::pi_model_candidates(requested).map_err(|_| ())
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn preflight_role_with_pi_model(
|
|
101
|
+
run_workspace: &Path,
|
|
102
|
+
team_dir: &Path,
|
|
103
|
+
agent_id: &AgentId,
|
|
104
|
+
role_file_path: &Path,
|
|
105
|
+
discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
|
|
106
|
+
) -> Result<(), LifecycleError> {
|
|
107
|
+
if !role_file_path.exists() {
|
|
108
|
+
return Err(LifecycleError::Compile(format!(
|
|
109
|
+
"role file not found: {}",
|
|
110
|
+
role_file_path.display()
|
|
111
|
+
)));
|
|
112
|
+
}
|
|
113
|
+
let (meta, _) = crate::compiler::read_front_matter(role_file_path)
|
|
114
|
+
.map_err(|error| LifecycleError::Compile(error.to_string()))?;
|
|
115
|
+
crate::compiler::preflight_pi_role_model_with(&meta, discover).map_err(|error| {
|
|
116
|
+
LifecycleError::PiModelPreflight {
|
|
117
|
+
requested: error.requested,
|
|
118
|
+
candidates: error.candidates,
|
|
119
|
+
action: error.action,
|
|
120
|
+
not_ready: error.not_ready,
|
|
121
|
+
}
|
|
122
|
+
})?;
|
|
123
|
+
let team_meta = crate::compiler::read_front_matter(&team_dir.join("TEAM.md"))
|
|
124
|
+
.map(|(meta, _)| meta)
|
|
125
|
+
.unwrap_or(Value::Null);
|
|
126
|
+
let compiled = crate::compiler::compile_role_agent(
|
|
127
|
+
role_file_path,
|
|
128
|
+
&team_meta,
|
|
129
|
+
&run_workspace.display().to_string(),
|
|
130
|
+
)
|
|
131
|
+
.map_err(|error| LifecycleError::Compile(error.to_string()))?;
|
|
132
|
+
if compiled.id != agent_id.as_str() {
|
|
133
|
+
return Err(LifecycleError::Compile(format!(
|
|
134
|
+
"role file declares name '{}' but add-agent id is '{}'",
|
|
135
|
+
compiled.id, agent_id
|
|
136
|
+
)));
|
|
137
|
+
}
|
|
138
|
+
Ok(())
|
|
139
|
+
}
|
|
140
|
+
|
|
96
141
|
fn reserve_agent_slot(
|
|
97
142
|
workspace: &Path,
|
|
98
143
|
team: Option<&str>,
|
|
@@ -209,6 +254,25 @@ pub fn add_agent(
|
|
|
209
254
|
role_file_path: &Path,
|
|
210
255
|
open_display: bool,
|
|
211
256
|
team: Option<&str>,
|
|
257
|
+
) -> Result<AddAgentReport, LifecycleError> {
|
|
258
|
+
let mut discover = discover_pi_model_candidates;
|
|
259
|
+
add_agent_with_pi_preflight(
|
|
260
|
+
workspace,
|
|
261
|
+
agent_id,
|
|
262
|
+
role_file_path,
|
|
263
|
+
open_display,
|
|
264
|
+
team,
|
|
265
|
+
&mut discover,
|
|
266
|
+
)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
fn add_agent_with_pi_preflight(
|
|
270
|
+
workspace: &Path,
|
|
271
|
+
agent_id: &AgentId,
|
|
272
|
+
role_file_path: &Path,
|
|
273
|
+
open_display: bool,
|
|
274
|
+
team: Option<&str>,
|
|
275
|
+
discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
|
|
212
276
|
) -> Result<AddAgentReport, LifecycleError> {
|
|
213
277
|
let selected = match crate::state::selector::resolve_active_team(
|
|
214
278
|
workspace,
|
|
@@ -225,13 +289,14 @@ pub fn add_agent(
|
|
|
225
289
|
workspace, team,
|
|
226
290
|
)
|
|
227
291
|
.unwrap_or_else(|_| crate::tmux_backend::TmuxBackend::for_workspace(workspace));
|
|
228
|
-
return
|
|
292
|
+
return add_agent_with_transport_pi_preflight(
|
|
229
293
|
workspace,
|
|
230
294
|
agent_id,
|
|
231
295
|
role_file_path,
|
|
232
296
|
open_display,
|
|
233
297
|
team,
|
|
234
298
|
&transport,
|
|
299
|
+
discover,
|
|
235
300
|
);
|
|
236
301
|
}
|
|
237
302
|
Err(error) => return Err(LifecycleError::TeamSelect(error.to_string())),
|
|
@@ -242,6 +307,13 @@ pub fn add_agent(
|
|
|
242
307
|
team: Some(selected.team_key.as_str()),
|
|
243
308
|
agent_id: Some(agent_id),
|
|
244
309
|
})?;
|
|
310
|
+
preflight_role_with_pi_model(
|
|
311
|
+
&selected.run_workspace,
|
|
312
|
+
&selected.team_dir,
|
|
313
|
+
agent_id,
|
|
314
|
+
role_file_path,
|
|
315
|
+
discover,
|
|
316
|
+
)?;
|
|
245
317
|
let reservation = reserve_agent_slot(
|
|
246
318
|
&selected.run_workspace,
|
|
247
319
|
Some(selected.team_key.as_str()),
|
|
@@ -292,9 +364,38 @@ pub fn add_agent_force(
|
|
|
292
364
|
open_display: bool,
|
|
293
365
|
team: Option<&str>,
|
|
294
366
|
force: bool,
|
|
367
|
+
) -> Result<AddAgentReport, LifecycleError> {
|
|
368
|
+
let mut discover = discover_pi_model_candidates;
|
|
369
|
+
add_agent_force_with_pi_preflight(
|
|
370
|
+
workspace,
|
|
371
|
+
agent_id,
|
|
372
|
+
role_file_path,
|
|
373
|
+
open_display,
|
|
374
|
+
team,
|
|
375
|
+
force,
|
|
376
|
+
&mut discover,
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#[allow(clippy::too_many_arguments)]
|
|
381
|
+
fn add_agent_force_with_pi_preflight(
|
|
382
|
+
workspace: &Path,
|
|
383
|
+
agent_id: &AgentId,
|
|
384
|
+
role_file_path: &Path,
|
|
385
|
+
open_display: bool,
|
|
386
|
+
team: Option<&str>,
|
|
387
|
+
force: bool,
|
|
388
|
+
discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
|
|
295
389
|
) -> Result<AddAgentReport, LifecycleError> {
|
|
296
390
|
if !force {
|
|
297
|
-
return
|
|
391
|
+
return add_agent_with_pi_preflight(
|
|
392
|
+
workspace,
|
|
393
|
+
agent_id,
|
|
394
|
+
role_file_path,
|
|
395
|
+
open_display,
|
|
396
|
+
team,
|
|
397
|
+
discover,
|
|
398
|
+
);
|
|
298
399
|
}
|
|
299
400
|
let selected = crate::state::selector::resolve_active_team(
|
|
300
401
|
workspace,
|
|
@@ -322,6 +423,7 @@ pub fn add_agent_force(
|
|
|
322
423
|
open_display,
|
|
323
424
|
Some(canonical_team_key.as_str()),
|
|
324
425
|
&transport,
|
|
426
|
+
discover,
|
|
325
427
|
)
|
|
326
428
|
}
|
|
327
429
|
|
|
@@ -340,6 +442,28 @@ pub(crate) fn add_agent_with_transport(
|
|
|
340
442
|
open_display: bool,
|
|
341
443
|
team: Option<&str>,
|
|
342
444
|
transport: &dyn Transport,
|
|
445
|
+
) -> Result<AddAgentReport, LifecycleError> {
|
|
446
|
+
let mut discover = discover_pi_model_candidates;
|
|
447
|
+
add_agent_with_transport_pi_preflight(
|
|
448
|
+
workspace,
|
|
449
|
+
agent_id,
|
|
450
|
+
role_file_path,
|
|
451
|
+
open_display,
|
|
452
|
+
team,
|
|
453
|
+
transport,
|
|
454
|
+
&mut discover,
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
#[allow(clippy::too_many_arguments)]
|
|
459
|
+
pub(crate) fn add_agent_with_transport_pi_preflight(
|
|
460
|
+
workspace: &Path,
|
|
461
|
+
agent_id: &AgentId,
|
|
462
|
+
role_file_path: &Path,
|
|
463
|
+
open_display: bool,
|
|
464
|
+
team: Option<&str>,
|
|
465
|
+
transport: &dyn Transport,
|
|
466
|
+
discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
|
|
343
467
|
) -> Result<AddAgentReport, LifecycleError> {
|
|
344
468
|
let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
|
|
345
469
|
.map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
|
|
@@ -349,6 +473,13 @@ pub(crate) fn add_agent_with_transport(
|
|
|
349
473
|
team,
|
|
350
474
|
agent_id: Some(agent_id),
|
|
351
475
|
})?;
|
|
476
|
+
preflight_role_with_pi_model(
|
|
477
|
+
&run_workspace,
|
|
478
|
+
workspace,
|
|
479
|
+
agent_id,
|
|
480
|
+
role_file_path,
|
|
481
|
+
discover,
|
|
482
|
+
)?;
|
|
352
483
|
let reservation = reserve_agent_slot(&run_workspace, team, agent_id)?;
|
|
353
484
|
drop(lifecycle_lock);
|
|
354
485
|
add_agent_with_transport_at_paths_reserved(
|
|
@@ -380,15 +511,40 @@ pub(crate) fn add_agent_with_transport_force(
|
|
|
380
511
|
team: Option<&str>,
|
|
381
512
|
force: bool,
|
|
382
513
|
transport: &dyn Transport,
|
|
514
|
+
) -> Result<AddAgentReport, LifecycleError> {
|
|
515
|
+
let mut discover = discover_pi_model_candidates;
|
|
516
|
+
add_agent_with_transport_force_pi_preflight(
|
|
517
|
+
workspace,
|
|
518
|
+
agent_id,
|
|
519
|
+
role_file_path,
|
|
520
|
+
open_display,
|
|
521
|
+
team,
|
|
522
|
+
force,
|
|
523
|
+
transport,
|
|
524
|
+
&mut discover,
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
#[allow(clippy::too_many_arguments)]
|
|
529
|
+
pub(crate) fn add_agent_with_transport_force_pi_preflight(
|
|
530
|
+
workspace: &Path,
|
|
531
|
+
agent_id: &AgentId,
|
|
532
|
+
role_file_path: &Path,
|
|
533
|
+
open_display: bool,
|
|
534
|
+
team: Option<&str>,
|
|
535
|
+
force: bool,
|
|
536
|
+
transport: &dyn Transport,
|
|
537
|
+
discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
|
|
383
538
|
) -> Result<AddAgentReport, LifecycleError> {
|
|
384
539
|
if !force {
|
|
385
|
-
return
|
|
540
|
+
return add_agent_with_transport_pi_preflight(
|
|
386
541
|
workspace,
|
|
387
542
|
agent_id,
|
|
388
543
|
role_file_path,
|
|
389
544
|
open_display,
|
|
390
545
|
team,
|
|
391
546
|
transport,
|
|
547
|
+
discover,
|
|
392
548
|
);
|
|
393
549
|
}
|
|
394
550
|
let run_workspace = crate::model::paths::canonical_run_workspace(workspace)
|
|
@@ -407,6 +563,7 @@ pub(crate) fn add_agent_with_transport_force(
|
|
|
407
563
|
open_display,
|
|
408
564
|
team,
|
|
409
565
|
transport,
|
|
566
|
+
discover,
|
|
410
567
|
)
|
|
411
568
|
}
|
|
412
569
|
|
|
@@ -415,6 +572,7 @@ pub(crate) fn add_agent_with_transport_force(
|
|
|
415
572
|
/// returns: 新席报告;成功后还要过快照的一致性校验
|
|
416
573
|
/// errors: 角色文件不存在先行返回 Compile;摘除、加回或一致性校验失败时按快照恢复并返回错误
|
|
417
574
|
/// ---
|
|
575
|
+
#[allow(clippy::too_many_arguments)]
|
|
418
576
|
pub(super) fn force_recreate_with_transport_locked(
|
|
419
577
|
run_workspace: &Path,
|
|
420
578
|
team_dir: &Path,
|
|
@@ -423,15 +581,16 @@ pub(super) fn force_recreate_with_transport_locked(
|
|
|
423
581
|
open_display: bool,
|
|
424
582
|
team: Option<&str>,
|
|
425
583
|
transport: &dyn Transport,
|
|
584
|
+
discover: &mut dyn FnMut(&str) -> Result<Vec<String>, ()>,
|
|
426
585
|
) -> Result<AddAgentReport, LifecycleError> {
|
|
427
586
|
// Reject an unusable replacement source before consuming the old seat.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
587
|
+
preflight_role_with_pi_model(
|
|
588
|
+
run_workspace,
|
|
589
|
+
team_dir,
|
|
590
|
+
agent_id,
|
|
591
|
+
role_file_path,
|
|
592
|
+
discover,
|
|
593
|
+
)?;
|
|
435
594
|
let snapshot = crate::lifecycle::restart::remove::ForceRecreateSnapshot::capture(
|
|
436
595
|
run_workspace,
|
|
437
596
|
agent_id,
|
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
|
|
16
16
|
use std::collections::BTreeSet;
|
|
17
17
|
use std::fs::OpenOptions;
|
|
18
|
-
use std::io::Write;
|
|
18
|
+
use std::io::{Read, Write};
|
|
19
19
|
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
|
20
20
|
use std::path::{Path, PathBuf};
|
|
21
|
-
use std::process::Command;
|
|
21
|
+
use std::process::{Command, Stdio};
|
|
22
|
+
use std::sync::mpsc;
|
|
23
|
+
use std::time::{Duration, Instant};
|
|
22
24
|
|
|
23
25
|
use sha2::{Digest, Sha256};
|
|
24
26
|
|
|
@@ -586,6 +588,78 @@ fn executable_file_type(path: &Path) -> Result<PiExecutableFileType, ProviderErr
|
|
|
586
588
|
}
|
|
587
589
|
}
|
|
588
590
|
|
|
591
|
+
/// Discover candidates with one PATH-first catalog observation. This deliberately
|
|
592
|
+
/// does not resolve adapters or scan wrapper chains; callers use it only for
|
|
593
|
+
/// rejecting an unqualified role model before lifecycle mutation.
|
|
594
|
+
pub(crate) fn run_pi_catalog(
|
|
595
|
+
executable: &Path,
|
|
596
|
+
timeout: Duration,
|
|
597
|
+
max_bytes: u64,
|
|
598
|
+
) -> Result<Vec<u8>, String> {
|
|
599
|
+
let mut child = Command::new(executable)
|
|
600
|
+
.arg("--list-models")
|
|
601
|
+
.stdin(Stdio::null())
|
|
602
|
+
.stdout(Stdio::piped())
|
|
603
|
+
.stderr(Stdio::null())
|
|
604
|
+
.spawn()
|
|
605
|
+
.map_err(|_| "Pi executable is unavailable on PATH".to_string())?;
|
|
606
|
+
let stdout = child.stdout.take().ok_or_else(|| {
|
|
607
|
+
let _ = child.kill();
|
|
608
|
+
let _ = child.wait();
|
|
609
|
+
"Pi model catalog output unavailable".to_string()
|
|
610
|
+
})?;
|
|
611
|
+
let (sender, receiver) = mpsc::channel();
|
|
612
|
+
std::thread::spawn(move || {
|
|
613
|
+
let mut bytes = Vec::new();
|
|
614
|
+
let result = stdout.take(max_bytes + 1).read_to_end(&mut bytes);
|
|
615
|
+
let _ = sender.send((result, bytes));
|
|
616
|
+
});
|
|
617
|
+
let start = Instant::now();
|
|
618
|
+
loop {
|
|
619
|
+
match child.try_wait() {
|
|
620
|
+
Ok(Some(status)) => {
|
|
621
|
+
let (result, bytes) = receiver.recv_timeout(timeout.saturating_sub(start.elapsed()))
|
|
622
|
+
.map_err(|_| "Pi model catalog command timed out".to_string())?;
|
|
623
|
+
if !status.success() { return Err("Pi model catalog command failed".into()); }
|
|
624
|
+
result.map_err(|_| "Pi model catalog could not be read".to_string())?;
|
|
625
|
+
if bytes.len() as u64 > max_bytes { return Err("Pi model catalog exceeds the bounded output limit".into()); }
|
|
626
|
+
return Ok(bytes);
|
|
627
|
+
}
|
|
628
|
+
Ok(None) if start.elapsed() < timeout => std::thread::sleep(Duration::from_millis(20)),
|
|
629
|
+
Ok(None) => {
|
|
630
|
+
let _ = child.kill();
|
|
631
|
+
let _ = child.wait();
|
|
632
|
+
return Err("Pi model catalog command timed out".into());
|
|
633
|
+
}
|
|
634
|
+
Err(_) => {
|
|
635
|
+
let _ = child.kill();
|
|
636
|
+
let _ = child.wait();
|
|
637
|
+
return Err("Pi model catalog command could not be observed".into());
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
pub(crate) fn pi_model_candidates(requested: &str) -> Result<Vec<String>, ProviderError> {
|
|
644
|
+
let path = std::env::var_os("PATH")
|
|
645
|
+
.ok_or_else(|| ProviderError::Command("PATH is not set".to_string()))?;
|
|
646
|
+
let executable = std::env::split_paths(&path)
|
|
647
|
+
.map(|directory| directory.join("pi"))
|
|
648
|
+
.find(|candidate| candidate.is_file())
|
|
649
|
+
.ok_or_else(|| ProviderError::Command("Pi executable not found on PATH".to_string()))?;
|
|
650
|
+
let catalog = run_pi_catalog(&executable, Duration::from_secs(10), 1024 * 1024)
|
|
651
|
+
.map_err(ProviderError::Command)?;
|
|
652
|
+
let models = parse_pi_list_models_table(&catalog)?;
|
|
653
|
+
Ok(models
|
|
654
|
+
.into_iter()
|
|
655
|
+
.filter(|model| {
|
|
656
|
+
model
|
|
657
|
+
.rsplit_once('/')
|
|
658
|
+
.is_some_and(|(_, name)| name == requested)
|
|
659
|
+
})
|
|
660
|
+
.collect())
|
|
661
|
+
}
|
|
662
|
+
|
|
589
663
|
fn pi_path_entries() -> Result<Vec<PathBuf>, ProviderError> {
|
|
590
664
|
let path = std::env::var_os("PATH")
|
|
591
665
|
.ok_or_else(|| ProviderError::Command("PATH is not set".to_string()))?;
|