@team-agent/installer 0.5.71 → 0.5.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/src/cli/adapters.rs +107 -9
  4. package/crates/team-agent/src/cli/emit.rs +35 -5
  5. package/crates/team-agent/src/cli/mod.rs +78 -7
  6. package/crates/team-agent/src/cli/models.rs +443 -0
  7. package/crates/team-agent/src/cli/spec.rs +4 -1
  8. package/crates/team-agent/src/cli/types.rs +8 -0
  9. package/crates/team-agent/src/compiler.rs +157 -0
  10. package/crates/team-agent/src/leader/owner_bind.rs +21 -0
  11. package/crates/team-agent/src/leader/provider_attribution.rs +25 -0
  12. package/crates/team-agent/src/leader/registry.rs +25 -1
  13. package/crates/team-agent/src/leader/start.rs +41 -9
  14. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +169 -10
  15. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +119 -9
  16. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +859 -12
  17. package/crates/team-agent/src/lifecycle/launch/spawn.rs +1 -0
  18. package/crates/team-agent/src/lifecycle/restart/common.rs +1 -0
  19. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +168 -0
  20. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +209 -0
  21. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +1 -1
  22. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +71 -1
  23. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +3 -1
  24. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +396 -3
  25. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +3 -1
  26. package/crates/team-agent/src/lifecycle/types.rs +8 -0
  27. package/crates/team-agent/src/provider/adapters/pi.rs +12 -6
  28. package/npm/check-skill-links.mjs +88 -0
  29. package/package.json +5 -4
  30. package/skills/team-agent/SKILL.md +15 -49
  31. package/skills/team-agent/command-coverage.json +8 -1
  32. 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
- 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,8 @@ 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,
165
+ allow_nested_attach,
162
166
  )?;
163
167
  Ok(PreparedLeaderStart {
164
168
  plan,
@@ -193,6 +197,7 @@ pub(crate) fn leader_start_plan_after_ambient_authority_check(
193
197
  in_tmux,
194
198
  managed_client_attach_mode,
195
199
  false,
200
+ false,
196
201
  )
197
202
  }
198
203
 
@@ -206,7 +211,8 @@ fn leader_start_plan_with_ambient_authority(
206
211
  external_leader: bool,
207
212
  in_tmux: bool,
208
213
  managed_client_attach_mode: Option<ManagedClientAttachMode>,
209
- managed_provider_reentry: bool,
214
+ managed_exec_provider: bool,
215
+ allow_nested_attach: bool,
210
216
  ) -> Result<LeaderStartPlan, LeaderError> {
211
217
  if attach_session.is_some() && !confirm_attach {
212
218
  return Err(LeaderError::Start(
@@ -264,7 +270,7 @@ fn leader_start_plan_with_ambient_authority(
264
270
  } else {
265
271
  false
266
272
  };
267
- let mode = if managed_provider_reentry {
273
+ let mode = if managed_exec_provider {
268
274
  LeaderStartMode::ExecProvider
269
275
  } else if !external_path {
270
276
  LeaderStartMode::ManagedTmuxClient
@@ -324,6 +330,11 @@ fn leader_start_plan_with_ambient_authority(
324
330
  tool_categories: &tools,
325
331
  team_mcp_tools: &team_mcp_tools,
326
332
  mcp_config: &mcp_config,
333
+ session_scope: crate::lifecycle::launch::pi_mcp::pi_leader_session_scope(
334
+ provider_args,
335
+ external_path,
336
+ allow_nested_attach,
337
+ ),
327
338
  },
328
339
  )
329
340
  .map_err(|error| LeaderError::Start(error.to_string()))?
@@ -555,17 +566,24 @@ fn ambient_pane_authority_preflight(
555
566
  }
556
567
 
557
568
  fn managed_launcher_ambient_route(
569
+ provider: Provider,
570
+ provider_args: &[String],
558
571
  workspace: &Path,
559
572
  allow_nested_attach: bool,
560
573
  ) -> Result<
561
574
  (
562
575
  Option<VerifiedAmbientPaneAuthority>,
563
576
  Option<ManagedClientAttachMode>,
577
+ bool,
564
578
  ),
565
579
  PrepareLeaderStartError,
566
580
  > {
567
581
  let Some(tmux) = std::env::var_os("TMUX") else {
568
- return Ok((None, Some(ManagedClientAttachMode::AttachSession)));
582
+ return Ok((
583
+ None,
584
+ Some(ManagedClientAttachMode::AttachSession),
585
+ false,
586
+ ));
569
587
  };
570
588
  let tmux = tmux.into_string().map_err(|_| {
571
589
  PrepareLeaderStartError::PaneAuthorityRefused(ambient_tmux_endpoint_refusal(
@@ -575,6 +593,15 @@ fn managed_launcher_ambient_route(
575
593
  })?;
576
594
  let observed_endpoint = validated_ambient_tmux_endpoint(workspace, &tmux)
577
595
  .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
596
+ if provider == Provider::Pi && provider_args.is_empty() && !allow_nested_attach {
597
+ // Pi's zero-argument managed entry already owns this verified pane as its
598
+ // user-facing terminal. Replacing the current process is safe on either
599
+ // the workspace server or another server; switching/attaching would move
600
+ // the user away from the positively verified workspace pane.
601
+ let authority = verified_ambient_pane_authority(workspace, &tmux)
602
+ .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
603
+ return Ok((Some(authority), None, true));
604
+ }
578
605
  let target_socket_name = TmuxBackend::for_workspace(workspace)
579
606
  .tmux_endpoint()
580
607
  .ok_or_else(|| LeaderError::Start("workspace tmux endpoint missing".to_string()))?;
@@ -589,7 +616,11 @@ fn managed_launcher_ambient_route(
589
616
  if same_server {
590
617
  let authority = verified_ambient_pane_authority(workspace, &tmux)
591
618
  .map_err(PrepareLeaderStartError::PaneAuthorityRefused)?;
592
- return Ok((Some(authority), Some(ManagedClientAttachMode::SwitchClient)));
619
+ return Ok((
620
+ Some(authority),
621
+ Some(ManagedClientAttachMode::SwitchClient),
622
+ false,
623
+ ));
593
624
  }
594
625
  if !allow_nested_attach {
595
626
  return Err(LeaderError::Validation(format!(
@@ -603,6 +634,7 @@ fn managed_launcher_ambient_route(
603
634
  Ok((
604
635
  Some(authority),
605
636
  Some(ManagedClientAttachMode::AttachSession),
637
+ false,
606
638
  ))
607
639
  }
608
640
 
@@ -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,