@team-agent/installer 0.5.70 → 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.
@@ -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
- if write_result.is_some() {
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, managed_provider_reentry) =
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
- let (authority, attach_mode) =
148
- managed_launcher_ambient_route(workspace, allow_nested_attach)?;
149
- (authority, attach_mode, false)
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
- managed_provider_reentry,
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
- managed_provider_reentry: bool,
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 managed_provider_reentry {
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((None, Some(ManagedClientAttachMode::AttachSession)));
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((Some(authority), Some(ManagedClientAttachMode::SwitchClient)));
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 add_agent_with_transport(
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 add_agent(workspace, agent_id, role_file_path, open_display, team);
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 add_agent_with_transport(
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
- // Deeper compile/spawn failures remain covered by the transaction snapshot.
429
- if !role_file_path.exists() {
430
- return Err(LifecycleError::Compile(format!(
431
- "role file not found: {}",
432
- role_file_path.display()
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
 
@@ -450,13 +452,24 @@ pub(crate) fn validate_pi_wrapper_source(
450
452
  /// errors: identity、序列化、目录、写盘或 rename 失败时返回 ProviderError
451
453
  /// ---
452
454
  pub(crate) fn write_pi_wrapper(request: PiWrapperRequest<'_>) -> Result<PathBuf, ProviderError> {
455
+ write_pi_wrapper_with_publish(request, |from, to| std::fs::rename(from, to))
456
+ }
457
+
458
+ pub(crate) fn write_pi_wrapper_with_publish(
459
+ request: PiWrapperRequest<'_>,
460
+ publish: impl FnOnce(&Path, &Path) -> std::io::Result<()>,
461
+ ) -> Result<PathBuf, ProviderError> {
453
462
  let source = render_pi_wrapper(&request)?;
454
463
  validate_pi_wrapper_source(&source, request.adapter, request.candidate_executable)?;
455
464
  let parent = request.destination.parent().ok_or_else(|| {
456
465
  ProviderError::Io("Pi wrapper destination has no parent directory".to_string())
457
466
  })?;
458
- std::fs::create_dir_all(parent)
459
- .map_err(|error| ProviderError::Io(format!("{}: {error}", parent.display())))?;
467
+ std::fs::create_dir_all(parent).map_err(|error| {
468
+ ProviderError::Io(format!(
469
+ "Pi wrapper atomic write stage=create_parent path={}: {error}",
470
+ parent.display()
471
+ ))
472
+ })?;
460
473
  let temp = parent.join(format!(".team-mcp.ts.{}.tmp", std::process::id()));
461
474
  let write_result = (|| {
462
475
  let mut file = OpenOptions::new()
@@ -464,22 +477,38 @@ pub(crate) fn write_pi_wrapper(request: PiWrapperRequest<'_>) -> Result<PathBuf,
464
477
  .write(true)
465
478
  .mode(0o600)
466
479
  .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| {
480
+ .map_err(|error| {
481
+ ProviderError::Io(format!(
482
+ "Pi wrapper atomic write stage=open_temp path={}: {error}",
483
+ temp.display()
484
+ ))
485
+ })?;
486
+ file.write_all(source.as_bytes()).map_err(|error| {
473
487
  ProviderError::Io(format!(
474
- "rename {} to {}: {error}",
475
- temp.display(),
476
- request.destination.display()
488
+ "Pi wrapper atomic write stage=write_temp path={}: {error}",
489
+ temp.display()
477
490
  ))
478
491
  })?;
479
- std::fs::set_permissions(request.destination, std::fs::Permissions::from_mode(0o600))
492
+ file.set_permissions(std::fs::Permissions::from_mode(0o600))
480
493
  .map_err(|error| {
481
- ProviderError::Io(format!("{}: {error}", request.destination.display()))
494
+ ProviderError::Io(format!(
495
+ "Pi wrapper atomic write stage=chmod_temp_fd path={}: {error}",
496
+ temp.display()
497
+ ))
482
498
  })?;
499
+ file.sync_all().map_err(|error| {
500
+ ProviderError::Io(format!(
501
+ "Pi wrapper atomic write stage=fsync_temp path={}: {error}",
502
+ temp.display()
503
+ ))
504
+ })?;
505
+ publish(&temp, request.destination).map_err(|error| {
506
+ ProviderError::Io(format!(
507
+ "Pi wrapper atomic write stage=rename_publish from={} to={}: {error}",
508
+ temp.display(),
509
+ request.destination.display()
510
+ ))
511
+ })?;
483
512
  Ok(request.destination.to_path_buf())
484
513
  })();
485
514
  if write_result.is_err() {
@@ -559,6 +588,78 @@ fn executable_file_type(path: &Path) -> Result<PiExecutableFileType, ProviderErr
559
588
  }
560
589
  }
561
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
+
562
663
  fn pi_path_entries() -> Result<Vec<PathBuf>, ProviderError> {
563
664
  let path = std::env::var_os("PATH")
564
665
  .ok_or_else(|| ProviderError::Command("PATH is not set".to_string()))?;