@team-agent/installer 0.4.9 → 0.4.11

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.
@@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet};
4
4
  use std::path::{Path, PathBuf};
5
5
  use std::process::Command;
6
6
 
7
- use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider};
7
+ use crate::model::enums::{AuthMode, DisplayBackend, PaneLiveness, Provider, ProviderEffort};
8
8
  use crate::model::ids::AgentId;
9
9
  use crate::model::permissions::{self, AgentPermissionInput};
10
10
  use crate::model::yaml::{self, Value};
@@ -243,6 +243,16 @@ fn spawn_agents(
243
243
  Some(&mcp_config),
244
244
  )?;
245
245
  let command_model = profile_launch.command_overrides.model.as_deref().or(model);
246
+ // 0.4.x provider effort MVP step 4 + 7: resolve effort and emit
247
+ // unsupported warning event when the spec asked for an effort the
248
+ // provider can't satisfy.
249
+ let agent_effort = provider_effort_for_spawn(agent, provider);
250
+ if let Some(event_value) =
251
+ provider_effort_event_if_dropped(agent, provider, agent_id_raw)
252
+ {
253
+ let _ = crate::event_log::EventLog::new(workspace)
254
+ .write("provider.effort_unsupported", event_value);
255
+ }
246
256
  let mut plan = adapter
247
257
  .build_command_plan(crate::provider::ProviderCommandContext {
248
258
  auth_mode,
@@ -256,6 +266,7 @@ fn spawn_agents(
256
266
  // adapters can pass `--name <agent_id>`. Codex has no
257
267
  // equivalent flag and ignores the hint.
258
268
  agent_id_hint: Some(agent_id_raw),
269
+ effort: agent_effort,
259
270
  })
260
271
  .map_err(|e| LifecycleError::Provider(e.to_string()))?;
261
272
  if !plan.managed_mcp_config && !profile_launch.managed_mcp_config {
@@ -320,7 +331,10 @@ fn spawn_agents(
320
331
  apply_mcp_auto_approval_env(&mut env, &safety);
321
332
  // Python providers.py:145 + launch/core.py:253 — fresh launch runs the worker
322
333
  // with cwd=workspace, same as the RS fork/add and restart paths.
323
- let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
334
+ let env_unset: Vec<String> = extend_worker_env_unset_for_effort(
335
+ profile_launch.env_unset.iter().cloned().collect(),
336
+ provider,
337
+ );
324
338
  // BUG / C-1-2 / C-6-1 cr verdict — Copilot system_prompt 走 spawn env overlay +
325
339
  // per-worker AGENTS.md(B2 灵魂件降级):写
326
340
  // <workspace>/.team/runtime/copilot-instructions/<agent_id>/AGENTS.md
@@ -1721,6 +1735,13 @@ fn running_agent_state(
1721
1735
  model.map_or(serde_json::Value::Null, |m| serde_json::json!(m)),
1722
1736
  );
1723
1737
  state.insert("auth_mode".to_string(), serde_json::json!(auth_mode));
1738
+ // 0.4.x provider effort MVP step 8: persist resolved effort so restart
1739
+ // / resume reads the same value (no re-resolution from role/team).
1740
+ if let Some(effort_str) = agent.get("effort").and_then(Value::as_str) {
1741
+ if !effort_str.is_empty() {
1742
+ state.insert("effort".to_string(), serde_json::json!(effort_str));
1743
+ }
1744
+ }
1724
1745
  state.insert("profile".to_string(), profile);
1725
1746
  if agent.get("profile").is_some() {
1726
1747
  if let Some(profile_dir) = profile_dir {
@@ -2466,6 +2487,112 @@ fn parse_auth_mode(raw: &str) -> Option<AuthMode> {
2466
2487
  }
2467
2488
  }
2468
2489
 
2490
+ /// 0.4.x provider effort MVP step 4: low-level from a raw string. Returns
2491
+ /// `Some(effort)` when the level parses AND the provider supports it.
2492
+ pub(crate) fn provider_effort_from_raw(
2493
+ raw: Option<&str>,
2494
+ provider: Provider,
2495
+ ) -> Option<ProviderEffort> {
2496
+ let raw = raw?.trim();
2497
+ if raw.is_empty() {
2498
+ return None;
2499
+ }
2500
+ let effort = ProviderEffort::parse(raw)?;
2501
+ if effort.is_supported_by(provider) {
2502
+ Some(effort)
2503
+ } else {
2504
+ None
2505
+ }
2506
+ }
2507
+
2508
+ /// 0.4.x provider effort MVP step 7: warning event payload when the spec
2509
+ /// requested an effort level the provider does not support.
2510
+ pub(crate) fn provider_effort_event_payload(
2511
+ raw: Option<&str>,
2512
+ provider: Provider,
2513
+ agent_id: &str,
2514
+ ) -> Option<serde_json::Value> {
2515
+ let raw = raw?.trim();
2516
+ if raw.is_empty() {
2517
+ return None;
2518
+ }
2519
+ let effort = ProviderEffort::parse(raw)?;
2520
+ if effort.is_supported_by(provider) {
2521
+ return None;
2522
+ }
2523
+ Some(serde_json::json!({
2524
+ "agent_id": agent_id,
2525
+ "provider": format!("{provider:?}").to_lowercase(),
2526
+ "effort": effort.as_str(),
2527
+ "action": "ignored",
2528
+ "reason": "provider does not support effort",
2529
+ }))
2530
+ }
2531
+
2532
+ /// 0.4.x provider effort MVP step 9: defensive guarantee that `CLAUDE_EFFORT`
2533
+ /// is unset in the Claude/ClaudeCode worker spawn env. As of the
2534
+ /// `profile_launch::provider_env_unsets` update, the base list already
2535
+ /// includes `CLAUDE_EFFORT` for Claude — so this function is idempotent
2536
+ /// (returns input unchanged). Kept as a belt-and-braces guard so a future
2537
+ /// refactor that bypasses provider_env_unsets cannot silently drop the
2538
+ /// scrub. The structural win is in `tmux_backend::shell_command` which now
2539
+ /// filters env exports by env_unset (preventing inherited values from
2540
+ /// re-introducing keys we just unset).
2541
+ pub(crate) fn extend_worker_env_unset_for_effort(
2542
+ base: Vec<String>,
2543
+ provider: Provider,
2544
+ ) -> Vec<String> {
2545
+ if !matches!(provider, Provider::Claude | Provider::ClaudeCode) {
2546
+ return base;
2547
+ }
2548
+ let mut out = base;
2549
+ if !out.iter().any(|k| k == "CLAUDE_EFFORT") {
2550
+ out.push("CLAUDE_EFFORT".to_string());
2551
+ }
2552
+ out
2553
+ }
2554
+
2555
+ /// Convenience: resolve effort for a yaml::Value agent (spec / compiled).
2556
+ pub(crate) fn provider_effort_for_spawn(
2557
+ agent: &crate::model::yaml::Value,
2558
+ provider: Provider,
2559
+ ) -> Option<ProviderEffort> {
2560
+ provider_effort_from_raw(agent.get("effort").and_then(|v| v.as_str()), provider)
2561
+ }
2562
+
2563
+ pub(crate) fn provider_effort_event_if_dropped(
2564
+ agent: &crate::model::yaml::Value,
2565
+ provider: Provider,
2566
+ agent_id: &str,
2567
+ ) -> Option<serde_json::Value> {
2568
+ provider_effort_event_payload(
2569
+ agent.get("effort").and_then(|v| v.as_str()),
2570
+ provider,
2571
+ agent_id,
2572
+ )
2573
+ }
2574
+
2575
+ /// Same as [`provider_effort_for_spawn`] but for serde_json state values
2576
+ /// (used by restart paths reading from `state.agents[id]`).
2577
+ pub(crate) fn provider_effort_for_spawn_json(
2578
+ agent: &serde_json::Value,
2579
+ provider: Provider,
2580
+ ) -> Option<ProviderEffort> {
2581
+ provider_effort_from_raw(agent.get("effort").and_then(serde_json::Value::as_str), provider)
2582
+ }
2583
+
2584
+ pub(crate) fn provider_effort_event_if_dropped_json(
2585
+ agent: &serde_json::Value,
2586
+ provider: Provider,
2587
+ agent_id: &str,
2588
+ ) -> Option<serde_json::Value> {
2589
+ provider_effort_event_payload(
2590
+ agent.get("effort").and_then(serde_json::Value::as_str),
2591
+ provider,
2592
+ agent_id,
2593
+ )
2594
+ }
2595
+
2469
2596
  fn quick_start_requested_team_key<'a>(
2470
2597
  team_id: Option<&'a str>,
2471
2598
  name: Option<&'a str>,
@@ -3786,6 +3913,16 @@ fn upsert_agent_state_from_role(
3786
3913
  }
3787
3914
  }
3788
3915
  }
3916
+ // 0.4.x provider effort MVP step 8 (dynamic add-agent): persist effort
3917
+ // from the role doc front matter (compiler.rs validates syntax/semantics
3918
+ // at compile; add-agent path validates here too in case of direct YAML).
3919
+ if let Some(effort_str) = meta.get("effort").and_then(Value::as_str) {
3920
+ if !effort_str.is_empty() {
3921
+ if let Some(obj) = entry.as_object_mut() {
3922
+ obj.insert("effort".to_string(), serde_json::json!(effort_str));
3923
+ }
3924
+ }
3925
+ }
3789
3926
  if let Some(obj) = entry.as_object_mut() {
3790
3927
  persist_effective_approval_policy(obj, safety);
3791
3928
  }
@@ -4075,6 +4212,15 @@ pub fn fork_agent_with_transport(
4075
4212
  Some(&mcp_config),
4076
4213
  )?;
4077
4214
  let command_model = profile_launch.command_overrides.model.as_deref().or(model);
4215
+ // 0.4.x provider effort MVP: fork inherits effort from the new agent JSON
4216
+ // (compiler.rs propagated the role/team effort into the agent at fork-spawn).
4217
+ let fork_effort = provider_effort_for_spawn(new_agent, provider);
4218
+ if let Some(event_value) =
4219
+ provider_effort_event_if_dropped(new_agent, provider, as_agent_id.as_str())
4220
+ {
4221
+ let _ = crate::event_log::EventLog::new(&workspace)
4222
+ .write("provider.effort_unsupported", event_value);
4223
+ }
4078
4224
  let mut plan = adapter
4079
4225
  .fork_plan(
4080
4226
  Some(&session_id),
@@ -4086,6 +4232,7 @@ pub fn fork_agent_with_transport(
4086
4232
  tools: &resolved_tool_refs,
4087
4233
  profile_launch: Some(&profile_launch),
4088
4234
  agent_id_hint: Some(as_agent_id.as_str()),
4235
+ effort: fork_effort,
4089
4236
  },
4090
4237
  )
4091
4238
  .map_err(|e| {
@@ -4111,7 +4258,10 @@ pub fn fork_agent_with_transport(
4111
4258
  // _tmux_session_exists — an ABSENT session => new-session (spawn_first), present => new-window
4112
4259
  // (spawn_into). The Rust restart seam (restart.rs spawn_agent_window) uses the same branch.
4113
4260
  let session_live = transport.has_session(&session_name).unwrap_or(false);
4114
- let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
4261
+ let env_unset: Vec<String> = extend_worker_env_unset_for_effort(
4262
+ profile_launch.env_unset.iter().cloned().collect(),
4263
+ provider,
4264
+ );
4115
4265
  let spawn_result = if session_live {
4116
4266
  transport.spawn_into_with_env_unset(
4117
4267
  &session_name,
@@ -4495,6 +4645,8 @@ fn upsert_forked_agent_state(
4495
4645
  "profile",
4496
4646
  "_profile_dir",
4497
4647
  "role",
4648
+ // 0.4.x provider effort MVP step 8: fork inherits compiled effort.
4649
+ "effort",
4498
4650
  ] {
4499
4651
  if let Some(value) = spec_agent.get(key) {
4500
4652
  entry.insert(key.to_string(), yaml_value_to_json(value));
@@ -450,6 +450,18 @@ pub(crate) fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BT
450
450
  unsets.insert("CLAUDE_CODE_ENTRYPOINT".to_string());
451
451
  unsets.insert("CLAUDE_CODE_EXECPATH".to_string());
452
452
  unsets.insert("CLAUDECODE".to_string());
453
+ // 0.4.x provider effort MVP step 9: CLAUDE_EFFORT carried from the
454
+ // launching shell would silently override the framework's effort
455
+ // decision (or, when no spec effort is configured, override the
456
+ // documented "provider default"). Scrubbing here is the single
457
+ // source — `extend_worker_env_unset_for_effort` is a thin alias
458
+ // that calls into this list. Applies to BOTH worker spawn AND
459
+ // leader spawn (leader_env_unset_for_provider also reads this).
460
+ // MVP scope: leader-effort is not configured yet, but if a
461
+ // launching shell already carries CLAUDE_EFFORT, the leader
462
+ // inheriting it would conflict with future leader-effort
463
+ // semantics. Scrub uniformly for predictability.
464
+ unsets.insert("CLAUDE_EFFORT".to_string());
453
465
  if auth_mode == AuthMode::CompatibleApi {
454
466
  unsets.insert("ANTHROPIC_API_KEY".to_string());
455
467
  }
@@ -420,6 +420,48 @@ fn agent_pane_live_by_id(
420
420
  }
421
421
  }
422
422
 
423
+ /// 0.4.10+ reset duplicate-window fix (CR-approved, plan §1).
424
+ ///
425
+ /// Enumerate live panes whose `(session, window_name)` match the given pair.
426
+ /// Used by `stop_agent_at_paths` (when the stored pane_id is stale/dead but a
427
+ /// same-role window survives) and by the reset hard gate (to prove no
428
+ /// duplicate window residue remains before `start_agent_at_paths`).
429
+ ///
430
+ /// `list_targets()` is a point-in-time tmux snapshot — duplicates ARE
431
+ /// preserved in the result so callers can see the full set.
432
+ ///
433
+ /// Caller MUST also check `is_per_agent_window(window, agent_id)` before
434
+ /// using this list to kill panes (plan §2 safety constraint): a shared
435
+ /// layout window may host co-tenants.
436
+ fn list_same_role_panes(
437
+ transport: &dyn crate::transport::Transport,
438
+ session: &crate::transport::SessionName,
439
+ window: &str,
440
+ ) -> Vec<crate::transport::PaneInfo> {
441
+ transport
442
+ .list_targets()
443
+ .unwrap_or_default()
444
+ .into_iter()
445
+ .filter(|pane| {
446
+ pane.session.as_str() == session.as_str()
447
+ && pane.window_name.as_ref().map(WindowName::as_str) == Some(window)
448
+ })
449
+ .collect()
450
+ }
451
+
452
+ /// 0.4.10+ reset duplicate-window fix (plan §2 safety constraint).
453
+ ///
454
+ /// Returns true only when `window == agent_id`, i.e. the canonical
455
+ /// per-agent window. Adaptive/shared layout windows (`workers`, `team`,
456
+ /// or any layout window name produced by `is_adaptive_layout_window_pub`)
457
+ /// MUST NOT be broad-killed by name — they may host co-tenants. The
458
+ /// caller falls back to safer behavior (refuse stop, surface
459
+ /// RequirementUnmet/transport error) when this returns false.
460
+ fn is_per_agent_window(window: &str, agent_id: &AgentId) -> bool {
461
+ window == agent_id.as_str()
462
+ && !crate::lifecycle::launch::is_adaptive_layout_window_pub(window)
463
+ }
464
+
423
465
  fn tmux_start_mode_for_spawn(
424
466
  spawn: &SpawnedAgentWindow,
425
467
  into_existing_session: bool,
@@ -602,22 +644,62 @@ pub(super) fn stop_agent_at_paths(
602
644
  .as_ref()
603
645
  .map(|pane| pane.as_str().to_string())
604
646
  .unwrap_or_else(|| format!("{}:{window}", session_name.as_str()));
605
- let stopped = pane_id
647
+ // 0.4.10+ reset duplicate-window fix (plan §2): stop must resolve a
648
+ // STALE stored pane_id to live same-role panes by `(session, window)`
649
+ // enumeration BEFORE concluding the worker is absent. Pre-fix logic
650
+ // returned stopped=false when pane_id was dead even if a residual
651
+ // duplicate window survived — that residue then collided with
652
+ // reset's unconditional start, producing the observed
653
+ // `stopped=false, started=true` duplicate-window pattern.
654
+ //
655
+ // Only the STALE-pane-id branch enumerates same-role panes. When
656
+ // pane_id was never set in state (legacy state shape, never observed
657
+ // a real spawn), the existing window-based fallback is preserved —
658
+ // the duplicate-window bug requires a stored-but-stale pane_id as
659
+ // the trigger.
660
+ let stored_pane_live = pane_id
606
661
  .as_ref()
607
662
  .map(|pane| agent_pane_live_by_id(transport, pane))
608
- .unwrap_or_else(|| window_exists(transport, &session_name, &window));
663
+ .unwrap_or(false);
664
+ let stored_pane_stale = pane_id.is_some() && !stored_pane_live;
665
+ let same_role_panes: Vec<crate::transport::PaneInfo> =
666
+ if stored_pane_stale && is_per_agent_window(&window, agent_id) {
667
+ list_same_role_panes(transport, &session_name, &window)
668
+ } else {
669
+ Vec::new()
670
+ };
671
+ let stopped = stored_pane_live
672
+ || !same_role_panes.is_empty()
673
+ || (pane_id.is_none() && window_exists(transport, &session_name, &window));
609
674
  if stopped {
610
675
  // golden operations.py:84-86: a non-zero kill-window raises
611
676
  // RuntimeError(f"failed to stop agent {agent_id}: {proc.stderr.strip()}").
612
- let kill_result = if let Some(pane) = pane_id.as_ref() {
613
- transport.kill_pane(pane)
614
- } else {
615
- let target = Target::SessionWindow {
616
- session: session_name.clone(),
617
- window: WindowName::new(&window),
677
+ //
678
+ // 0.4.10+ kill resolution order (plan §2):
679
+ // 1. stored pane_id is live → kill it by pane_id.
680
+ // 2. stored pane_id is stale BUT same-role panes survive →
681
+ // kill each by pane_id (duplicate window names make
682
+ // kill-window -t session:window ambiguous).
683
+ // 3. no pane_id at all but window exists → kill_window as
684
+ // before (legacy compat for state without pane_id field).
685
+ let kill_result: Result<(), crate::transport::TransportError> =
686
+ if let Some(pane) = pane_id.as_ref().filter(|_| stored_pane_live) {
687
+ transport.kill_pane(pane)
688
+ } else if !same_role_panes.is_empty() {
689
+ let mut last_err: Option<crate::transport::TransportError> = None;
690
+ for residual in &same_role_panes {
691
+ if let Err(e) = transport.kill_pane(&residual.pane_id) {
692
+ last_err = Some(e);
693
+ }
694
+ }
695
+ last_err.map(Err).unwrap_or(Ok(()))
696
+ } else {
697
+ let target = Target::SessionWindow {
698
+ session: session_name.clone(),
699
+ window: WindowName::new(&window),
700
+ };
701
+ transport.kill_window(&target)
618
702
  };
619
- transport.kill_window(&target)
620
- };
621
703
  if let Err(e) = kill_result {
622
704
  let stderr = match &e {
623
705
  crate::transport::TransportError::Subprocess { stderr, .. } => stderr.trim().to_string(),
@@ -906,7 +988,158 @@ fn reset_agent_at_paths(
906
988
  .filter(|session| !session.is_empty())
907
989
  .map(SessionId::new);
908
990
  crate::lifecycle::launch::ensure_owner_allowed_for_state(&state_before_stop, Some(agent_id))?;
991
+ // Capture old pane_id / pane_pid / window BEFORE stop, so the hard gate
992
+ // below can prove the same prior instance is gone (or refuse start).
993
+ let old_pane_id_before = state_before_stop
994
+ .get("agents")
995
+ .and_then(|v| v.get(agent_id.as_str()))
996
+ .and_then(|v| v.get("pane_id"))
997
+ .and_then(|v| v.as_str())
998
+ .filter(|p| !p.is_empty())
999
+ .map(crate::transport::PaneId::new);
1000
+ let old_pane_pid_before = state_pane_pid(&state_before_stop, agent_id);
1001
+ // CR C-2: take ONE pre-stop snapshot of the team session's panes so
1002
+ // the gate below can compute "what survived stop" by set difference,
1003
+ // not "what panes exist at all" (which would refuse legitimate
1004
+ // reset flows where stop killed the pane and the post-stop snapshot
1005
+ // includes that same pane id in a transport mock that does not
1006
+ // reflect kill removal).
1007
+ let pre_stop_window = state_before_stop
1008
+ .get("agents")
1009
+ .and_then(|v| v.get(agent_id.as_str()))
1010
+ .and_then(|v| v.get("window"))
1011
+ .and_then(|v| v.as_str())
1012
+ .filter(|s| !s.is_empty())
1013
+ .unwrap_or_else(|| agent_id.as_str())
1014
+ .to_string();
1015
+ let pre_stop_spec = load_team_spec(spec_workspace).ok();
1016
+ let pre_stop_session = pre_stop_spec
1017
+ .as_ref()
1018
+ .map(|spec| state_session_name_from_spec(&state_before_stop, spec));
1019
+ let pre_stop_pane_ids: std::collections::BTreeSet<String> =
1020
+ if let Some(session) = pre_stop_session.as_ref() {
1021
+ if is_per_agent_window(&pre_stop_window, agent_id) {
1022
+ list_same_role_panes(transport, session, &pre_stop_window)
1023
+ .iter()
1024
+ .map(|p| p.pane_id.as_str().to_string())
1025
+ .collect()
1026
+ } else {
1027
+ std::collections::BTreeSet::new()
1028
+ }
1029
+ } else {
1030
+ std::collections::BTreeSet::new()
1031
+ };
909
1032
  let stop = stop_agent_at_paths(workspace, spec_workspace, agent_id, team, transport)?;
1033
+
1034
+ // 0.4.10+ paused agent skip: a paused agent's start path returns
1035
+ // StartAgentOutcome::Paused (no spawn). There is no duplicate-window
1036
+ // hazard, so the gate is a no-op for paused agents.
1037
+ let agent_is_paused = state_before_stop
1038
+ .get("agents")
1039
+ .and_then(|v| v.get(agent_id.as_str()))
1040
+ .and_then(|v| v.get("paused"))
1041
+ .and_then(|v| v.as_bool())
1042
+ .unwrap_or(false);
1043
+ // 0.4.10+ reset duplicate-window fix (plan §3): HARD GATE before start.
1044
+ // After stop_agent_at_paths returns, prove the old instance is gone OR
1045
+ // refuse to spawn. The pre-fix path unconditionally proceeded to
1046
+ // discard/save/start even when stop returned stopped=false (stop's
1047
+ // pane_id was stale), creating the observed duplicate-window pattern.
1048
+ //
1049
+ // Residue definition (correct: differential, not absolute):
1050
+ // A pane is RESIDUE iff it appears in BOTH the pre-stop snapshot
1051
+ // AND the post-stop snapshot. The pre-fix attempt used "any
1052
+ // matching pane exists post-stop" which broke legitimate flows
1053
+ // where the transport mock does not model kill_pane removal.
1054
+ //
1055
+ // Old pane id / pid checks:
1056
+ // The OLD stored pane_id / pane_pid must be gone (not just
1057
+ // reachable but actually killed). For real tmux this is the
1058
+ // structural truth source; for mocks the differential approach
1059
+ // above covers the post-stop visibility.
1060
+ //
1061
+ // CR C-5: gate is reset-specific; standalone stop-agent path keeps
1062
+ // existing "already absent is ok" behavior.
1063
+ //
1064
+ // Gate scope refinement: when stop.stopped == true, the kill_pane
1065
+ // call already succeeded (and drain_old_pane_and_pid polled for
1066
+ // the pane to become unreachable). Treat that as the authoritative
1067
+ // signal — running the gate again post-stop introduces a race
1068
+ // window where tmux's has_pane lag can spuriously report Live.
1069
+ // Only gate the dangerous case: stop reported stopped == false
1070
+ // (state's stale pane_id pointed at nothing kill-able), which is
1071
+ // exactly the duplicate-window bug pattern from the macmini
1072
+ // evidence: `stop_agent.complete stopped=false` followed by an
1073
+ // unconditional `start_agent.agent_start`.
1074
+ if !agent_is_paused && !stop.stopped {
1075
+ let spec_for_gate = load_team_spec(spec_workspace)?;
1076
+ let gate_state = resolve_team_scoped_state_or_refuse(workspace, team)?;
1077
+ let session_name_gate = state_session_name_from_spec(&gate_state, &spec_for_gate);
1078
+ let gate_window = gate_state
1079
+ .get("agents")
1080
+ .and_then(|v| v.get(agent_id.as_str()))
1081
+ .and_then(|v| v.get("window"))
1082
+ .and_then(|v| v.as_str())
1083
+ .filter(|s| !s.is_empty())
1084
+ .unwrap_or_else(|| agent_id.as_str())
1085
+ .to_string();
1086
+ let old_pane_still_live = old_pane_id_before
1087
+ .as_ref()
1088
+ .map(|p| agent_pane_live_by_id(transport, p))
1089
+ .unwrap_or(false);
1090
+ // Take a SECOND snapshot post-stop and compute the differential.
1091
+ // Only panes present in BOTH snapshots are residue (stop did not
1092
+ // remove them).
1093
+ let post_stop_panes: Vec<crate::transport::PaneInfo> =
1094
+ if is_per_agent_window(&gate_window, agent_id) {
1095
+ list_same_role_panes(transport, &session_name_gate, &gate_window)
1096
+ } else {
1097
+ Vec::new()
1098
+ };
1099
+ let remaining_panes: Vec<crate::transport::PaneInfo> = post_stop_panes
1100
+ .into_iter()
1101
+ .filter(|p| pre_stop_pane_ids.contains(p.pane_id.as_str()))
1102
+ .collect();
1103
+ // Pid-alone aliveness is secondary evidence and noisy under fixtures
1104
+ // (synthetic pids may by chance be live on the test machine). Block
1105
+ // ONLY on tmux-visible residue: old pane still live OR same-role
1106
+ // panes survived stop. The pid is still recorded in the event for
1107
+ // diagnostics.
1108
+ let old_pid_still_live = old_pane_pid_before
1109
+ .filter(|_| old_pane_still_live)
1110
+ .map(|pid| pid_is_alive(pid))
1111
+ .unwrap_or(false);
1112
+ if old_pane_still_live || !remaining_panes.is_empty() {
1113
+ let remaining_pane_ids: Vec<String> = remaining_panes
1114
+ .iter()
1115
+ .map(|p| p.pane_id.as_str().to_string())
1116
+ .collect();
1117
+ let old_pane_str = old_pane_id_before
1118
+ .as_ref()
1119
+ .map(|p| p.as_str().to_string())
1120
+ .unwrap_or_default();
1121
+ let old_pid_val = old_pane_pid_before.unwrap_or(0);
1122
+ let _ = write_reset_stop_not_proven_event(
1123
+ workspace,
1124
+ agent_id,
1125
+ &old_pane_str,
1126
+ old_pid_val,
1127
+ &remaining_pane_ids,
1128
+ );
1129
+ // CR C-1 N38 three-line error: error / action / log_hint.
1130
+ let _ = stop; // silence unused on the refusal path
1131
+ return Err(LifecycleError::RequirementUnmet(format!(
1132
+ "reset refused: old agent instance still live for {agent_id}\n\
1133
+ action: stop the worker manually with `team-agent stop-agent {agent_id} --team <team>` then retry reset, or kill the residual tmux panes [{ids}]\n\
1134
+ log_hint: see reset_agent.stop_not_proven event (old_pane_id={old}, old_pane_pid={pid}, remaining_panes=[{ids}])",
1135
+ agent_id = agent_id.as_str(),
1136
+ ids = remaining_pane_ids.join(", "),
1137
+ old = old_pane_str,
1138
+ pid = old_pid_val,
1139
+ )));
1140
+ }
1141
+ }
1142
+
910
1143
  let mut state = resolve_team_scoped_state_or_refuse(workspace, team)?;
911
1144
  let spec = load_team_spec(spec_workspace)?;
912
1145
  discard_agent_session_fields(&mut state, agent_id)?;
@@ -1058,6 +1291,15 @@ fn write_start_agent_start_event(
1058
1291
  .model
1059
1292
  .as_deref()
1060
1293
  .or(model);
1294
+ // 0.4.x provider effort MVP: start_agent path preserves effort from the
1295
+ // persisted agent JSON.
1296
+ let start_agent_effort = crate::lifecycle::launch::provider_effort_for_spawn_json(&agent, provider);
1297
+ if let Some(event_value) = crate::lifecycle::launch::provider_effort_event_if_dropped_json(
1298
+ &agent, provider, agent_id.as_str(),
1299
+ ) {
1300
+ let _ = crate::event_log::EventLog::new(workspace)
1301
+ .write("provider.effort_unsupported", event_value);
1302
+ }
1061
1303
  let context = crate::provider::ProviderCommandContext {
1062
1304
  auth_mode,
1063
1305
  mcp_config: Some(&mcp_config),
@@ -1066,6 +1308,7 @@ fn write_start_agent_start_event(
1066
1308
  tools: &resolved_tool_refs,
1067
1309
  profile_launch: Some(&profile_launch),
1068
1310
  agent_id_hint: Some(agent_id.as_str()),
1311
+ effort: start_agent_effort,
1069
1312
  };
1070
1313
  let mut plan = match session_id {
1071
1314
  Some(session_id) => adapter
@@ -1188,6 +1431,32 @@ fn write_reset_tombstone_event(
1188
1431
  Ok(())
1189
1432
  }
1190
1433
 
1434
+ /// 0.4.10+ reset duplicate-window fix (plan §3): emit a structured event
1435
+ /// when the reset hard gate refuses to start because the old instance is
1436
+ /// proven still live (old pane id reachable, old pane pid alive, or
1437
+ /// same-role panes remain in the team session).
1438
+ fn write_reset_stop_not_proven_event(
1439
+ workspace: &Path,
1440
+ agent_id: &AgentId,
1441
+ old_pane_id: &str,
1442
+ old_pane_pid: u32,
1443
+ remaining_panes: &[String],
1444
+ ) -> Result<(), LifecycleError> {
1445
+ crate::event_log::EventLog::new(workspace)
1446
+ .write(
1447
+ "reset_agent.stop_not_proven",
1448
+ serde_json::json!({
1449
+ "agent_id": agent_id.as_str(),
1450
+ "old_pane_id": old_pane_id,
1451
+ "old_pane_pid": old_pane_pid,
1452
+ "remaining_panes": remaining_panes,
1453
+ "action": "stop the worker manually then retry reset, or kill the residual tmux panes",
1454
+ }),
1455
+ )
1456
+ .map_err(|e| LifecycleError::StatePersist(e.to_string()))?;
1457
+ Ok(())
1458
+ }
1459
+
1191
1460
  fn write_reset_complete_event(
1192
1461
  workspace: &Path,
1193
1462
  agent_id: &AgentId,
@@ -114,6 +114,15 @@ pub(super) fn spawn_agent_window(
114
114
  Some(&mcp_config),
115
115
  )?;
116
116
  let command_model = profile_launch.command_overrides.model.as_deref().or(model);
117
+ // 0.4.x provider effort MVP: restart/resume preserves effort from the
118
+ // persisted agent JSON (state's agent["effort"] field, set by launch).
119
+ let restart_effort = crate::lifecycle::launch::provider_effort_for_spawn_json(agent, provider);
120
+ if let Some(event_value) = crate::lifecycle::launch::provider_effort_event_if_dropped_json(
121
+ agent, provider, agent_id.as_str(),
122
+ ) {
123
+ let _ = crate::event_log::EventLog::new(workspace)
124
+ .write("provider.effort_unsupported", event_value);
125
+ }
117
126
  let context = crate::provider::ProviderCommandContext {
118
127
  auth_mode,
119
128
  mcp_config: Some(&mcp_config),
@@ -122,6 +131,7 @@ pub(super) fn spawn_agent_window(
122
131
  tools: &resolved_tool_refs,
123
132
  profile_launch: Some(&profile_launch),
124
133
  agent_id_hint: Some(agent_id.as_str()),
134
+ effort: restart_effort,
125
135
  };
126
136
  let mut plan = match resume_session_id {
127
137
  Some(session_id) => adapter
@@ -167,7 +177,13 @@ pub(super) fn spawn_agent_window(
167
177
  // a per-agent YAML `spawn_cwd` field if one is set. Until then, override
168
178
  // > workspace; state-based override is silently dropped.
169
179
  let spawn_cwd = spawn_cwd_override.unwrap_or(workspace);
170
- let env_unset: Vec<String> = profile_launch.env_unset.iter().cloned().collect();
180
+ // 0.4.x provider effort MVP step 9: scrub CLAUDE_EFFORT for Claude
181
+ // worker spawn so a parent shell env cannot silently override the
182
+ // framework's effort decision.
183
+ let env_unset: Vec<String> = crate::lifecycle::launch::extend_worker_env_unset_for_effort(
184
+ profile_launch.env_unset.iter().cloned().collect(),
185
+ provider,
186
+ );
171
187
 
172
188
  // 0.4.6 Stage 2: write actual spawn plan event BEFORE invoking the
173
189
  // transport spawn. Mirrors `launch.rs:359-380` (the reference impl)