@team-agent/installer 0.4.8 → 0.4.10

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));
@@ -415,7 +415,14 @@ fn provider_env_exports(
415
415
  exports
416
416
  }
417
417
 
418
- fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<String> {
418
+ /// 0.4.x (CR C-1): exposed as `pub(crate)` so the leader launcher
419
+ /// (leader/start.rs) can reuse the SAME Claude/Codex/Copilot env-unset set
420
+ /// used by worker spawn. Single source of truth — adding a new provider env
421
+ /// var to clean up means changing this one function. Audit grep guard in
422
+ /// `crates/team-agent/src/leader/start.rs::leader_env_unset_for_provider`
423
+ /// confirms the leader path imports from here and does not maintain its own
424
+ /// duplicate list.
425
+ pub(crate) fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<String> {
419
426
  let mut unsets = BTreeSet::new();
420
427
  match provider {
421
428
  Provider::Claude | Provider::ClaudeCode => {
@@ -443,6 +450,18 @@ fn provider_env_unsets(provider: Provider, auth_mode: AuthMode) -> BTreeSet<Stri
443
450
  unsets.insert("CLAUDE_CODE_ENTRYPOINT".to_string());
444
451
  unsets.insert("CLAUDE_CODE_EXECPATH".to_string());
445
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());
446
465
  if auth_mode == AuthMode::CompatibleApi {
447
466
  unsets.insert("ANTHROPIC_API_KEY".to_string());
448
467
  }
@@ -1058,6 +1058,15 @@ fn write_start_agent_start_event(
1058
1058
  .model
1059
1059
  .as_deref()
1060
1060
  .or(model);
1061
+ // 0.4.x provider effort MVP: start_agent path preserves effort from the
1062
+ // persisted agent JSON.
1063
+ let start_agent_effort = crate::lifecycle::launch::provider_effort_for_spawn_json(&agent, provider);
1064
+ if let Some(event_value) = crate::lifecycle::launch::provider_effort_event_if_dropped_json(
1065
+ &agent, provider, agent_id.as_str(),
1066
+ ) {
1067
+ let _ = crate::event_log::EventLog::new(workspace)
1068
+ .write("provider.effort_unsupported", event_value);
1069
+ }
1061
1070
  let context = crate::provider::ProviderCommandContext {
1062
1071
  auth_mode,
1063
1072
  mcp_config: Some(&mcp_config),
@@ -1066,6 +1075,7 @@ fn write_start_agent_start_event(
1066
1075
  tools: &resolved_tool_refs,
1067
1076
  profile_launch: Some(&profile_launch),
1068
1077
  agent_id_hint: Some(agent_id.as_str()),
1078
+ effort: start_agent_effort,
1069
1079
  };
1070
1080
  let mut plan = match session_id {
1071
1081
  Some(session_id) => adapter
@@ -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)
@@ -102,7 +102,8 @@ pub use types::{
102
102
  CheckKind, CheckStatus, ContractSuiteCheck, DeliveryOutcome, DeliveryRefusal, DeliveryStage,
103
103
  DeliveryStatus, IdleEvaluation, LeaderNotificationKey, LeaderReceiver, PaneWidthQuery,
104
104
  ProviderSdkCalls, ReceiverMode, ScheduledKind, SelftestCheck, SelftestReport, SendEventPayload,
105
- TrustRetryPayload, WatcherNotice, RESULT_DELIVERY_MAX_ATTEMPTS, SEND_RETRY_MAX_ATTEMPTS,
105
+ TrustRetryPayload, WatcherNotice, WorkerRuntimeState,
106
+ RESULT_DELIVERY_MAX_ATTEMPTS, SEND_RETRY_MAX_ATTEMPTS,
106
107
  TRUST_RETRY_BACKOFF_SECONDS, TRUST_RETRY_MAX_ATTEMPTS,
107
108
  };
108
109
  pub use watchers::{
@@ -126,6 +126,92 @@ impl ActivityStatus {
126
126
  }
127
127
  }
128
128
 
129
+ /// 0.4.x Phase 1 worker runtime-state (fg-pgrp + 5-state plan, CR APPROVED).
130
+ ///
131
+ /// Canonical runtime-state enum surfaced by `team-agent status` and consumed
132
+ /// by automatic-dispatch / idle-gate predicates. Distinct from
133
+ /// [`ActivityStatus`] (which remains the internal classifier output for
134
+ /// backwards compatibility) and from lifecycle `agents.<id>.status` (which
135
+ /// remains launch/stop-owned: `running`/`paused`/`stopped`/etc.).
136
+ ///
137
+ /// Rules:
138
+ /// * `Busy` when `foreground_pgrp != agent_root_pgrp` (a child process
139
+ /// occupies the terminal — provider is not at idle prompt).
140
+ /// * `Unknown` is NEVER idle-eligible and must be treated as busy by
141
+ /// automatic-dispatch / idle-gate predicates.
142
+ /// * `ProbablyIdle` is a weak conclusion, not proof of idleness.
143
+ /// * `Dead` when the pane is missing or the root process is gone.
144
+ /// * `Blocked` when the worker is awaiting human confirmation (trust
145
+ /// prompt, startup approval).
146
+ ///
147
+ /// CR R4: deserialise with `#[serde(other)]` so a future variant added by
148
+ /// a newer build does not crash an older reader (rolling upgrade safe).
149
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
150
+ #[serde(rename_all = "SCREAMING_SNAKE_CASE")]
151
+ pub enum WorkerRuntimeState {
152
+ Dead,
153
+ Busy,
154
+ Blocked,
155
+ ProbablyIdle,
156
+ /// Default fallback. CR R4: also catches unknown wire strings via
157
+ /// `#[serde(other)]`.
158
+ #[serde(other)]
159
+ Unknown,
160
+ }
161
+
162
+ impl WorkerRuntimeState {
163
+ /// Wire string used by status/diagnose JSON.
164
+ pub fn as_wire(self) -> &'static str {
165
+ match self {
166
+ Self::Dead => "DEAD",
167
+ Self::Busy => "BUSY",
168
+ Self::Blocked => "BLOCKED",
169
+ Self::ProbablyIdle => "PROBABLY_IDLE",
170
+ Self::Unknown => "UNKNOWN",
171
+ }
172
+ }
173
+
174
+ /// Parse from wire string. Returns `Self::Unknown` for unknown literals
175
+ /// (defence-in-depth alongside `#[serde(other)]`).
176
+ pub fn parse_wire(value: &str) -> Self {
177
+ match value.trim() {
178
+ "DEAD" => Self::Dead,
179
+ "BUSY" => Self::Busy,
180
+ "BLOCKED" => Self::Blocked,
181
+ "PROBABLY_IDLE" => Self::ProbablyIdle,
182
+ _ => Self::Unknown,
183
+ }
184
+ }
185
+
186
+ /// True for states that MUST NOT receive automatic dispatch:
187
+ /// `Dead | Busy | Blocked | Unknown`. Only `ProbablyIdle` is safe.
188
+ /// CR R3 + Phase 1 §8 dispatch boundary.
189
+ pub fn blocks_automatic_dispatch(self) -> bool {
190
+ !matches!(self, Self::ProbablyIdle)
191
+ }
192
+
193
+ /// True only for `ProbablyIdle` — the sole idle candidate. `Unknown` is
194
+ /// NEVER idle (Iron Law: bug-071/077/085).
195
+ pub fn is_idle_candidate(self) -> bool {
196
+ matches!(self, Self::ProbablyIdle)
197
+ }
198
+
199
+ /// Map from the legacy [`ActivityStatus`] for backward-compat. Used by
200
+ /// status/diagnose code paths that have an old AgentActivity in hand
201
+ /// and need a runtime-state surface for the product enum.
202
+ /// Working → Busy
203
+ /// Idle → ProbablyIdle
204
+ /// Stuck → Unknown (CR rule: stale signals are never idle)
205
+ /// Uncertain → Unknown
206
+ pub fn from_activity(activity: ActivityStatus) -> Self {
207
+ match activity {
208
+ ActivityStatus::Working => Self::Busy,
209
+ ActivityStatus::Idle => Self::ProbablyIdle,
210
+ ActivityStatus::Stuck | ActivityStatus::Uncertain => Self::Unknown,
211
+ }
212
+ }
213
+ }
214
+
129
215
  /// 告警类型 (card §49;`scheduler.py:38` `_ALERT_TYPES`)。`stuck_cancel` 还接 `all` (展开全集)。
130
216
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
131
217
  #[serde(rename_all = "snake_case")]
@@ -23,6 +23,71 @@ pub enum Provider {
23
23
  Fake,
24
24
  }
25
25
 
26
+ /// 0.4.x Provider effort MVP: reasoning effort level passed to the provider.
27
+ /// Configuration sources (resolution order):
28
+ /// 1. role doc front matter `effort: low|medium|high|xhigh|max`
29
+ /// 2. TEAM.md front matter `provider_effort: low|medium|high|xhigh|max`
30
+ /// 3. provider default (framework passes no flag)
31
+ ///
32
+ /// Provider support:
33
+ /// - claude / claude_code: low|medium|high|xhigh|max → `--effort <level>`
34
+ /// - codex: low|medium|high|xhigh (NOT max) → `-c model_reasoning_effort=<level>`
35
+ /// - copilot / gemini_cli / fake: unsupported — warning event, no flag
36
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
37
+ pub enum ProviderEffort {
38
+ #[serde(rename = "low")]
39
+ Low,
40
+ #[serde(rename = "medium")]
41
+ Medium,
42
+ #[serde(rename = "high")]
43
+ High,
44
+ #[serde(rename = "xhigh")]
45
+ XHigh,
46
+ #[serde(rename = "max")]
47
+ Max,
48
+ }
49
+
50
+ impl ProviderEffort {
51
+ /// Parse from a wire string. Returns None on unknown literal.
52
+ pub fn parse(value: &str) -> Option<Self> {
53
+ match value.trim() {
54
+ "low" => Some(Self::Low),
55
+ "medium" => Some(Self::Medium),
56
+ "high" => Some(Self::High),
57
+ "xhigh" => Some(Self::XHigh),
58
+ "max" => Some(Self::Max),
59
+ _ => None,
60
+ }
61
+ }
62
+
63
+ /// Wire string used by both CLI argv and state serialization.
64
+ pub fn as_str(self) -> &'static str {
65
+ match self {
66
+ Self::Low => "low",
67
+ Self::Medium => "medium",
68
+ Self::High => "high",
69
+ Self::XHigh => "xhigh",
70
+ Self::Max => "max",
71
+ }
72
+ }
73
+
74
+ /// Effort levels Claude-only (Codex / others must reject `max`).
75
+ pub fn is_claude_only(self) -> bool {
76
+ matches!(self, Self::Max)
77
+ }
78
+
79
+ /// True when the given provider supports this effort level. `max` is
80
+ /// Claude-only; other levels are supported by Claude and Codex, ignored
81
+ /// by Copilot/Gemini/Fake (warning emitted at runtime).
82
+ pub fn is_supported_by(self, provider: Provider) -> bool {
83
+ match provider {
84
+ Provider::Claude | Provider::ClaudeCode => true,
85
+ Provider::Codex => !self.is_claude_only(),
86
+ Provider::Copilot | Provider::GeminiCli | Provider::Fake => false,
87
+ }
88
+ }
89
+ }
90
+
26
91
  /// auth 模式(`AUTH_MODES` `profiles/constants.py:6`)。
27
92
  #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
28
93
  #[serde(rename_all = "snake_case")]
@@ -250,8 +250,19 @@ fn basic_schema_errors(spec: &Yaml) -> Vec<String> {
250
250
  if !matches!(spec.get("version"), Some(Yaml::Int(1))) {
251
251
  e.push("/version: must equal 1".to_string());
252
252
  }
253
- let team_keys = &["name", "mode", "objective", "workspace"];
254
- check_keys_y(spec.get("team"), "/team", team_keys, team_keys, &mut e);
253
+ // 0.4.x provider effort MVP step 3: provider_effort is allowed but not required.
254
+ let team_required = &["name", "mode", "objective", "workspace"];
255
+ let team_allowed = &["name", "mode", "objective", "workspace", "provider_effort"];
256
+ check_keys_y(spec.get("team"), "/team", team_required, team_allowed, &mut e);
257
+ if let Some(team) = spec.get("team") {
258
+ if let Some(raw) = team.get("provider_effort").and_then(Yaml::as_str) {
259
+ if crate::model::enums::ProviderEffort::parse(raw).is_none() {
260
+ e.push(format!(
261
+ "/team/provider_effort: unknown effort '{raw}' (allowed: low|medium|high|xhigh|max)"
262
+ ));
263
+ }
264
+ }
265
+ }
255
266
  let mode = spec.get("team").and_then(|t| t.get("mode")).and_then(Yaml::as_str);
256
267
  if !matches!(mode, Some("supervisor_worker" | "swarm_limited")) {
257
268
  e.push("/team/mode: invalid mode".to_string());
@@ -298,11 +309,32 @@ fn check_agent(agent: &Yaml, path: &str, errors: &mut Vec<String>) {
298
309
  "id", "role", "provider", "model", "working_directory", "system_prompt", "tools",
299
310
  "permission_mode", "preferred_for", "avoid_for", "output_contract", "paused", "auth_mode",
300
311
  "profile", "credential_ref", "forked_from",
312
+ // 0.4.x provider effort MVP step 3: per-agent effort override (resolved at compile).
313
+ "effort",
301
314
  ];
302
315
  check_keys_y(Some(agent), path, req, allowed, errors);
303
316
  if !agent.is_map() {
304
317
  return;
305
318
  }
319
+ // 0.4.x provider effort MVP step 3: effort syntactic + semantic validation.
320
+ if let Some(raw) = agent.get("effort").and_then(Yaml::as_str) {
321
+ match crate::model::enums::ProviderEffort::parse(raw) {
322
+ None => {
323
+ errors.push(format!(
324
+ "{path}/effort: unknown effort '{raw}' (allowed: low|medium|high|xhigh|max)"
325
+ ));
326
+ }
327
+ Some(effort) if effort.is_claude_only() => {
328
+ let provider = agent.get("provider").and_then(Yaml::as_str).unwrap_or("");
329
+ if !matches!(provider, "claude" | "claude_code") {
330
+ errors.push(format!(
331
+ "{path}/effort: effort '{raw}' is only supported by claude/claude_code (provider: {provider})"
332
+ ));
333
+ }
334
+ }
335
+ Some(_) => {}
336
+ }
337
+ }
306
338
  check_keys_y(agent.get("system_prompt"), &format!("{path}/system_prompt"), &["inline", "file"], &["inline", "file"], errors);
307
339
  check_list_y(agent.get("tools"), &format!("{path}/tools"), errors);
308
340
  check_list_y(agent.get("preferred_for"), &format!("{path}/preferred_for"), errors);
@@ -128,3 +128,82 @@ fn read_and_remove(path: &std::path::Path) -> Vec<u8> {
128
128
  let _ = std::fs::remove_file(path);
129
129
  stdout
130
130
  }
131
+
132
+ /// 0.4.x Phase 1 (fg-pgrp): terminal foreground process-group probe.
133
+ ///
134
+ /// macOS probe confirmed (2026-06-29, .team/artifacts/fg-pgrp-probe.md):
135
+ /// `TIOCGPGRP` ioctl returns Errno 25 on tmux slave PTYs opened from a
136
+ /// non-controlling process. We use `ps -o tpgid,pgid -p <pid>` instead —
137
+ /// reliable on macOS AND Linux, no platform-specific cfg needed.
138
+ ///
139
+ /// Returns `Ok(Some((tpgid, pgid)))` on success, `Ok(None)` when the PID
140
+ /// is gone / ps returned no row / values unparseable. The caller maps
141
+ /// `None` to `WorkerRuntimeState::Unknown` (never to idle — Iron Law).
142
+ ///
143
+ /// `tpgid` = terminal foreground process group ID.
144
+ /// `pgid` = the agent root's own process group ID.
145
+ /// `tpgid != pgid` means a child process owns the foreground (BUSY signal).
146
+ ///
147
+ /// Bounded via [`run_bounded_command`] so a wedged ps cannot stall the
148
+ /// coordinator tick.
149
+ pub(crate) fn pane_foreground_and_root_pgrp(pane_pid: u32) -> io::Result<Option<(u32, u32)>> {
150
+ let mut command = Command::new("ps");
151
+ command.args(["-o", "tpgid=,pgid=", "-p", &pane_pid.to_string()]);
152
+ let output = bounded_command_output_with_probe(
153
+ &mut command,
154
+ "pane_foreground_and_root_pgrp",
155
+ Some(pane_pid),
156
+ )?;
157
+ if probe_timed_out() {
158
+ return Ok(None);
159
+ }
160
+ if !output.status.success() {
161
+ return Ok(None);
162
+ }
163
+ let text = String::from_utf8_lossy(&output.stdout);
164
+ let first_line = text.lines().next().unwrap_or("").trim();
165
+ if first_line.is_empty() {
166
+ return Ok(None);
167
+ }
168
+ let parts: Vec<&str> = first_line.split_whitespace().collect();
169
+ if parts.len() < 2 {
170
+ return Ok(None);
171
+ }
172
+ let tpgid = parts[0].parse::<i64>().ok();
173
+ let pgid = parts[1].parse::<i64>().ok();
174
+ match (tpgid, pgid) {
175
+ // ps prints `-1` for tpgid when there is no controlling terminal —
176
+ // treat as unknown rather than fabricating a u32.
177
+ (Some(t), Some(p)) if t > 0 && p > 0 => Ok(Some((t as u32, p as u32))),
178
+ _ => Ok(None),
179
+ }
180
+ }
181
+
182
+ #[cfg(test)]
183
+ mod fg_pgrp_tests {
184
+ use super::*;
185
+
186
+ #[test]
187
+ fn pane_foreground_and_root_pgrp_self_returns_some_or_none() {
188
+ // The current test process is not necessarily attached to a
189
+ // controlling terminal (CI runs detached), so we accept Some OR
190
+ // None. The contract is: never panic, return io::Result.
191
+ let pid = std::process::id();
192
+ match pane_foreground_and_root_pgrp(pid) {
193
+ Ok(Some((tpgid, pgid))) => {
194
+ assert!(tpgid > 0 && pgid > 0, "positive pgid/tpgid");
195
+ }
196
+ Ok(None) => {} // headless / no tty — acceptable
197
+ Err(e) => panic!("probe must not error in normal env: {e}"),
198
+ }
199
+ }
200
+
201
+ #[test]
202
+ fn pane_foreground_and_root_pgrp_missing_pid_returns_none() {
203
+ // PID 1 is init/launchd — exists but we read fields; non-existent
204
+ // PIDs return Ok(None) (ps exits non-zero).
205
+ let result =
206
+ pane_foreground_and_root_pgrp(0xFFFF_FFFE).expect("must not error on missing pid");
207
+ assert!(result.is_none(), "missing pid → None");
208
+ }
209
+ }