@team-agent/installer 0.4.9 → 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.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.4.9"
569
+ version = "0.4.10"
570
570
  dependencies = [
571
571
  "anyhow",
572
572
  "chrono",
package/Cargo.toml CHANGED
@@ -9,7 +9,7 @@ members = ["crates/team-agent"]
9
9
 
10
10
  [workspace.package]
11
11
  edition = "2021"
12
- version = "0.4.9"
12
+ version = "0.4.10"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -743,7 +743,10 @@ use rusqlite::params;
743
743
  return json!({});
744
744
  };
745
745
  let mut out = Map::new();
746
- for key in ["status", "provider", "activity", "last_output_at"] {
746
+ // 0.4.x Phase 1: add `worker_state` (canonical 5-state product
747
+ // surface). `activity` is preserved alongside as the deprecated
748
+ // legacy classifier output (CR R3 same-source contract).
749
+ for key in ["status", "provider", "worker_state", "activity", "last_output_at"] {
747
750
  if let Some(value) = input.get(key) {
748
751
  out.insert(key.to_string(), value.clone());
749
752
  }
@@ -26,6 +26,7 @@
26
26
  use std::fs;
27
27
  use std::path::Path;
28
28
 
29
+ use crate::model::enums::{Provider, ProviderEffort};
29
30
  use crate::model::yaml::Value;
30
31
  use crate::model::{paths, spec, yaml, ModelError};
31
32
 
@@ -182,17 +183,35 @@ pub fn compile_team(team_dir: &Path) -> Result<Value, ModelError> {
182
183
  })
183
184
  .collect::<Vec<_>>();
184
185
 
186
+ // 0.4.x provider effort MVP step 2: validate TEAM.md provider_effort early
187
+ // (unknown literal rejects compile). Empty/absent → no team-level effort.
188
+ let team_provider_effort = match string_field(&team_meta, "provider_effort") {
189
+ Some(raw) if !raw.trim().is_empty() => {
190
+ let value = raw.trim();
191
+ let parsed = ProviderEffort::parse(value).ok_or_else(|| {
192
+ ModelError::Validation(format!(
193
+ "{}: unknown provider_effort '{value}' (allowed: low|medium|high|xhigh|max)",
194
+ team_md.display()
195
+ ))
196
+ })?;
197
+ Some(parsed)
198
+ }
199
+ _ => None,
200
+ };
201
+
202
+ let mut team_fields: Vec<(&str, Value)> = vec![
203
+ ("name", Value::Str(team_name.clone())),
204
+ ("mode", Value::Str("supervisor_worker".to_string())),
205
+ ("objective", Value::Str(objective)),
206
+ ("workspace", Value::Str(workspace_s)),
207
+ ];
208
+ if let Some(effort) = team_provider_effort {
209
+ team_fields.push(("provider_effort", Value::Str(effort.as_str().to_string())));
210
+ }
211
+
185
212
  let spec = map(vec![
186
213
  ("version", Value::Int(1)),
187
- (
188
- "team",
189
- map(vec![
190
- ("name", Value::Str(team_name.clone())),
191
- ("mode", Value::Str("supervisor_worker".to_string())),
192
- ("objective", Value::Str(objective)),
193
- ("workspace", Value::Str(workspace_s)),
194
- ]),
195
- ),
214
+ ("team", map(team_fields)),
196
215
  (
197
216
  "leader",
198
217
  map(vec![
@@ -366,6 +385,56 @@ pub fn compile_role_agent(
366
385
  if let Some(profile) = string_field(&meta, "profile") {
367
386
  agent_items.push(("profile", Value::Str(profile)));
368
387
  }
388
+ // 0.4.x provider effort MVP step 3: resolve effort with role > team > none.
389
+ // Validate (unknown literal) AND check provider/effort compatibility
390
+ // (max is Claude-only; emit hard error for max + non-Claude here so
391
+ // unsupported combinations fail at compile, not at runtime).
392
+ let role_effort = match string_field(&meta, "effort") {
393
+ Some(raw) if !raw.trim().is_empty() => {
394
+ let value = raw.trim();
395
+ let parsed = ProviderEffort::parse(value).ok_or_else(|| {
396
+ ModelError::Validation(format!(
397
+ "{}: unknown effort '{value}' (allowed: low|medium|high|xhigh|max)",
398
+ role_path.display()
399
+ ))
400
+ })?;
401
+ Some(parsed)
402
+ }
403
+ _ => None,
404
+ };
405
+ let team_effort = match string_field(team_meta, "provider_effort") {
406
+ Some(raw) if !raw.trim().is_empty() => ProviderEffort::parse(raw.trim()),
407
+ _ => None,
408
+ };
409
+ let resolved_effort = role_effort.or(team_effort);
410
+ if let Some(effort) = resolved_effort {
411
+ // Reject max + non-Claude at compile time.
412
+ let provider_str = agent_items
413
+ .iter()
414
+ .find(|(k, _)| *k == "provider")
415
+ .and_then(|(_, v)| match v {
416
+ Value::Str(s) => Some(s.as_str()),
417
+ _ => None,
418
+ })
419
+ .unwrap_or("");
420
+ let provider_enum = match provider_str {
421
+ "claude" => Provider::Claude,
422
+ "claude_code" => Provider::ClaudeCode,
423
+ "codex" => Provider::Codex,
424
+ "copilot" => Provider::Copilot,
425
+ "gemini_cli" => Provider::GeminiCli,
426
+ "fake" => Provider::Fake,
427
+ _ => Provider::Codex, // unknown providers caught elsewhere
428
+ };
429
+ if effort.is_claude_only() && !matches!(provider_enum, Provider::Claude | Provider::ClaudeCode) {
430
+ return Err(ModelError::Validation(format!(
431
+ "{}: effort '{}' is only supported by claude/claude_code (provider: {provider_str})",
432
+ role_path.display(),
433
+ effort.as_str()
434
+ )));
435
+ }
436
+ agent_items.push(("effort", Value::Str(effort.as_str().to_string())));
437
+ }
369
438
  Ok(CompiledRole {
370
439
  id,
371
440
  role,
@@ -3103,6 +3103,11 @@ fn write_activity(
3103
3103
  .get("last_output_at")
3104
3104
  .and_then(Value::as_str)
3105
3105
  .map(str::to_string);
3106
+ // 0.4.x Phase 1: resolve the 5-state worker runtime state alongside the
3107
+ // legacy `activity` write. CR R3: activity field is preserved as the
3108
+ // deprecated surface; worker_state is the new canonical surface.
3109
+ let worker_state =
3110
+ resolve_worker_runtime_state_with_fg_pgrp(agent, Some(activity));
3106
3111
  let Some(agent_obj) = agent.as_object_mut() else {
3107
3112
  return previous_last_output;
3108
3113
  };
@@ -3115,6 +3120,10 @@ fn write_activity(
3115
3120
  "rationale": activity.rationale,
3116
3121
  }),
3117
3122
  );
3123
+ agent_obj.insert(
3124
+ "worker_state".to_string(),
3125
+ serde_json::json!(worker_state.as_wire()),
3126
+ );
3118
3127
  if output_advanced {
3119
3128
  let last_output_at = chrono::Utc::now().to_rfc3339();
3120
3129
  agent_obj.insert(
@@ -3126,6 +3135,83 @@ fn write_activity(
3126
3135
  previous_last_output
3127
3136
  }
3128
3137
 
3138
+ /// 0.4.x Phase 1 worker-runtime-state resolver.
3139
+ ///
3140
+ /// Pure resolution from (agent JSON, optional activity classifier output).
3141
+ /// Reads `agent.pane_id`, `agent.pane_pid`, `agent.awaiting_human_confirm`,
3142
+ /// and the trust/startup approval flags. Uses
3143
+ /// `os_probe::pane_foreground_and_root_pgrp` to detect a child process
3144
+ /// occupying the terminal foreground.
3145
+ ///
3146
+ /// Precedence (matches Phase 1 plan §4):
3147
+ /// 1. Dead — pane_pid exists but `getpgid` returns no process,
3148
+ /// OR foreground probe explicitly reports the PID gone.
3149
+ /// 2. Blocked — agent.awaiting_human_confirm / startup-trust flags.
3150
+ /// 3. Busy — fg_pgrp != root_pgrp (child occupies terminal) OR
3151
+ /// JSONL classifier reports Working.
3152
+ /// 4. ProbablyIdle — JSONL classifier reports Idle AND no fg-pgrp
3153
+ /// conflict (or probe unavailable + activity idle).
3154
+ /// 5. Unknown — missing pane_pid, probe error, or no decisive
3155
+ /// signal. Iron law: never silently Idle.
3156
+ pub(crate) fn resolve_worker_runtime_state_with_fg_pgrp(
3157
+ agent: &Value,
3158
+ activity: Option<&crate::messaging::AgentActivity>,
3159
+ ) -> crate::messaging::WorkerRuntimeState {
3160
+ use crate::messaging::{ActivityStatus, WorkerRuntimeState};
3161
+
3162
+ // §4.2 Blocked: trust prompt / awaiting human confirm.
3163
+ let blocked = agent
3164
+ .get("awaiting_human_confirm")
3165
+ .and_then(Value::as_bool)
3166
+ .unwrap_or(false)
3167
+ || agent
3168
+ .get("approval")
3169
+ .and_then(Value::as_str)
3170
+ .is_some_and(|s| s == "pending" || s == "awaiting_trust_prompt")
3171
+ || agent
3172
+ .get("approval_status")
3173
+ .and_then(Value::as_str)
3174
+ .is_some_and(|s| s == "pending" || s == "awaiting_trust_prompt");
3175
+ if blocked {
3176
+ return WorkerRuntimeState::Blocked;
3177
+ }
3178
+
3179
+ // §4.3 Busy via fg-pgrp probe (when pane_pid is available).
3180
+ let pane_pid = agent.get("pane_pid").and_then(Value::as_u64).map(|p| p as u32);
3181
+ if let Some(pid) = pane_pid {
3182
+ match crate::os_probe::pane_foreground_and_root_pgrp(pid) {
3183
+ Ok(Some((tpgid, pgid))) => {
3184
+ if tpgid != pgid {
3185
+ return WorkerRuntimeState::Busy;
3186
+ }
3187
+ // Same pgrp — pane process owns foreground. Defer to
3188
+ // JSONL/activity for finer state.
3189
+ }
3190
+ Ok(None) => {
3191
+ // Probe could not read: missing PID, no controlling
3192
+ // terminal, ps unavailable. Fall through to activity
3193
+ // classifier; if that's also missing/uncertain, the
3194
+ // function returns Unknown.
3195
+ }
3196
+ Err(_) => {
3197
+ // Subprocess error — degrade to Unknown unless activity
3198
+ // is decisive.
3199
+ }
3200
+ }
3201
+ }
3202
+
3203
+ // §4.4/4.5 Activity-derived classification.
3204
+ if let Some(activity) = activity {
3205
+ return match activity.status {
3206
+ ActivityStatus::Working => WorkerRuntimeState::Busy,
3207
+ ActivityStatus::Idle => WorkerRuntimeState::ProbablyIdle,
3208
+ ActivityStatus::Stuck | ActivityStatus::Uncertain => WorkerRuntimeState::Unknown,
3209
+ };
3210
+ }
3211
+
3212
+ WorkerRuntimeState::Unknown
3213
+ }
3214
+
3129
3215
  fn activity_status_wire(status: crate::messaging::ActivityStatus) -> &'static str {
3130
3216
  match status {
3131
3217
  crate::messaging::ActivityStatus::Idle => "idle",
@@ -3144,6 +3230,16 @@ fn agent_health_status_wire(status: crate::messaging::ActivityStatus) -> &'stati
3144
3230
  }
3145
3231
  }
3146
3232
 
3233
+ /// 0.4.x Phase 1: read the worker_state wire string that `write_activity`
3234
+ /// persisted alongside the legacy activity. Returns None when no worker_state
3235
+ /// has been written yet (older state row pre-upgrade).
3236
+ pub(crate) fn agent_worker_state(agent: &Value) -> Option<crate::messaging::WorkerRuntimeState> {
3237
+ agent
3238
+ .get("worker_state")
3239
+ .and_then(Value::as_str)
3240
+ .map(crate::messaging::WorkerRuntimeState::parse_wire)
3241
+ }
3242
+
3147
3243
  fn write_agent_health(
3148
3244
  store: &crate::message_store::MessageStore,
3149
3245
  team: &str,
@@ -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
  }
@@ -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
+ }
@@ -410,7 +410,7 @@ impl ProviderAdapter for BasicProviderAdapter {
410
410
  Provider::Claude | Provider::ClaudeCode => {
411
411
  Ok(claude_launch_command(self, auth_mode, mcp_config, system_prompt, model, tools)?)
412
412
  }
413
- Provider::Codex => Ok(codex_base_command(None, auth_mode, mcp_config, system_prompt, model, tools, None)),
413
+ Provider::Codex => Ok(codex_base_command(None, auth_mode, mcp_config, system_prompt, model, tools, None, None)),
414
414
  // §C1 worker argv 形态 + C-1/C-5/C-6 cr verdict:
415
415
  // copilot --no-color --no-auto-update [<dangerous|granular>] [--model]
416
416
  // --additional-mcp-config <inline json> --session-id <expected_uuid> -C <cwd>
@@ -469,6 +469,7 @@ impl ProviderAdapter for BasicProviderAdapter {
469
469
  model,
470
470
  ctx.tools,
471
471
  managed,
472
+ ctx.effort,
472
473
  )?;
473
474
  argv.push("--session-id".to_string());
474
475
  argv.push(expected.clone());
@@ -507,6 +508,7 @@ impl ProviderAdapter for BasicProviderAdapter {
507
508
  ctx.model,
508
509
  ctx.tools,
509
510
  ctx.profile_launch.map(|profile| &profile.command_overrides),
511
+ ctx.effort,
510
512
  ))),
511
513
  // §C1 + §C4 cr verdict — copilot plan 端预定 UUID + workspace `-C` 双保险:
512
514
  // * `--session-id <uuid>`(claude 同法,捕获免目录扫描,sqlite 仅校验)
@@ -638,13 +640,14 @@ impl ProviderAdapter for BasicProviderAdapter {
638
640
  model,
639
641
  tools,
640
642
  None,
643
+ None,
641
644
  );
642
645
  argv.push(session_id.as_str().to_string());
643
646
  Ok(argv)
644
647
  }
645
648
  Provider::Claude | Provider::ClaudeCode => {
646
649
  let mut argv =
647
- claude_base_command(self, auth_mode, mcp_config, system_prompt, model, tools, false)?;
650
+ claude_base_command(self, auth_mode, mcp_config, system_prompt, model, tools, false, None)?;
648
651
  argv.push("--resume".to_string());
649
652
  argv.push(session_id.as_str().to_string());
650
653
  Ok(argv)
@@ -686,6 +689,7 @@ impl ProviderAdapter for BasicProviderAdapter {
686
689
  model,
687
690
  ctx.tools,
688
691
  managed,
692
+ ctx.effort,
689
693
  )?;
690
694
  argv.push("--resume".to_string());
691
695
  argv.push(session_id.as_str().to_string());
@@ -725,6 +729,7 @@ impl ProviderAdapter for BasicProviderAdapter {
725
729
  ctx.model,
726
730
  ctx.tools,
727
731
  ctx.profile_launch.map(|profile| &profile.command_overrides),
732
+ ctx.effort,
728
733
  );
729
734
  argv.push(session_id.as_str().to_string());
730
735
  Ok(CommandPlan::argv_only(argv))
@@ -779,13 +784,14 @@ impl ProviderAdapter for BasicProviderAdapter {
779
784
  model,
780
785
  tools,
781
786
  None,
787
+ None,
782
788
  );
783
789
  argv.push(session_id.as_str().to_string());
784
790
  Ok(argv)
785
791
  }
786
792
  Provider::Claude | Provider::ClaudeCode => {
787
793
  let mut argv =
788
- claude_base_command(self, auth_mode, mcp_config, system_prompt, model, tools, false)?;
794
+ claude_base_command(self, auth_mode, mcp_config, system_prompt, model, tools, false, None)?;
789
795
  argv.push("--session-id".to_string());
790
796
  argv.push(next_session_token());
791
797
  argv.push("--resume".to_string());
@@ -837,6 +843,7 @@ impl ProviderAdapter for BasicProviderAdapter {
837
843
  model,
838
844
  ctx.tools,
839
845
  managed,
846
+ ctx.effort,
840
847
  )?;
841
848
  argv.push("--session-id".to_string());
842
849
  argv.push(expected.clone());
@@ -870,6 +877,7 @@ impl ProviderAdapter for BasicProviderAdapter {
870
877
  ctx.model,
871
878
  ctx.tools,
872
879
  ctx.profile_launch.map(|profile| &profile.command_overrides),
880
+ ctx.effort,
873
881
  );
874
882
  argv.push(session_id.as_str().to_string());
875
883
  Ok(CommandPlan::argv_only(argv))
@@ -34,6 +34,7 @@ pub(crate) fn claude_launch_command(
34
34
  model,
35
35
  tools,
36
36
  false,
37
+ None,
37
38
  )?;
38
39
  argv.push("--session-id".to_string());
39
40
  argv.push(next_session_token());
@@ -48,6 +49,9 @@ pub(crate) fn claude_base_command(
48
49
  model: Option<&str>,
49
50
  tools: &[&str],
50
51
  managed_mcp_config: bool,
52
+ // 0.4.x provider effort MVP step 5: when Some, inject `--effort <level>`
53
+ // immediately after the model (before prompt/MCP).
54
+ effort: Option<crate::model::enums::ProviderEffort>,
51
55
  ) -> Result<Vec<String>, ProviderError> {
52
56
  let mut argv = vec!["claude".to_string()];
53
57
  if claude_dangerous_auto_approve(tools) {
@@ -60,6 +64,10 @@ pub(crate) fn claude_base_command(
60
64
  argv.push("--model".to_string());
61
65
  argv.push(model.to_string());
62
66
  }
67
+ if let Some(effort) = effort {
68
+ argv.push("--effort".to_string());
69
+ argv.push(effort.as_str().to_string());
70
+ }
63
71
  if let Some(prompt) = system_prompt {
64
72
  argv.push("--append-system-prompt".to_string());
65
73
  argv.push(prompt.to_string());
@@ -17,6 +17,12 @@ pub(crate) fn codex_base_command(
17
17
  model: Option<&str>,
18
18
  tools: &[&str],
19
19
  overrides: Option<&ProviderCommandOverrides>,
20
+ // 0.4.x provider effort MVP step 6: when Some, inject
21
+ // `-c model_reasoning_effort=<level>` AFTER existing profile
22
+ // codex_config overrides — explicit launch effort wins over profile.
23
+ // Codex does not support `max`; the caller filters that case via
24
+ // `ProviderEffort::is_supported_by` before reaching this point.
25
+ effort: Option<crate::model::enums::ProviderEffort>,
20
26
  ) -> Vec<String> {
21
27
  let mut argv = vec!["codex".to_string()];
22
28
  if let Some(subcommand) = subcommand {
@@ -51,6 +57,10 @@ pub(crate) fn codex_base_command(
51
57
  argv.push(config.clone());
52
58
  }
53
59
  }
60
+ if let Some(effort) = effort {
61
+ argv.push("-c".to_string());
62
+ argv.push(format!("model_reasoning_effort={}", effort.as_str()));
63
+ }
54
64
  if let Some(prompt) = system_prompt {
55
65
  // codex.py:120 — escape order matters: backslash first, then quote, then newline.
56
66
  let escaped = prompt
@@ -347,6 +347,14 @@ pub struct ProviderCommandContext<'a> {
347
347
  /// no `--name` CLI flag (probe verdict 2026-06-22) and ignores this
348
348
  /// field. None = legacy callers (the field is purely additive).
349
349
  pub agent_id_hint: Option<&'a str>,
350
+ /// 0.4.x provider effort MVP step 4: reasoning effort level resolved
351
+ /// from role doc / TEAM.md / provider default. `None` means the
352
+ /// framework passes no effort flag (provider default).
353
+ /// - Claude / ClaudeCode: low|medium|high|xhigh|max → `--effort <level>`
354
+ /// - Codex: low|medium|high|xhigh → `-c model_reasoning_effort=<level>`
355
+ /// - Copilot / Gemini / Fake: ignored, warning event emitted at the
356
+ /// caller (lifecycle/launch.rs / lifecycle/restart) before construct.
357
+ pub effort: Option<crate::model::enums::ProviderEffort>,
350
358
  }
351
359
 
352
360
  #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -1321,6 +1321,8 @@ fn shell_command(
1321
1321
  env: &BTreeMap<String, String>,
1322
1322
  env_unset: &[String],
1323
1323
  ) -> String {
1324
+ let unset_set: std::collections::BTreeSet<&str> =
1325
+ env_unset.iter().map(String::as_str).collect();
1324
1326
  let mut parts = Vec::new();
1325
1327
  parts.push("cd".to_string());
1326
1328
  parts.push(shell_quote(&cwd.to_string_lossy()));
@@ -1333,7 +1335,16 @@ fn shell_command(
1333
1335
  parts.push(key.clone());
1334
1336
  parts.push("&&".to_string());
1335
1337
  }
1338
+ // 0.4.x ordering fix (env-leak symptom #3): KEY=val exports must NOT
1339
+ // re-introduce any key that was just unset. Filter env entries whose key
1340
+ // appears in env_unset so the unset wins on the final shell line. This
1341
+ // matters when inherited env (worker_spawn_env / apply_profile_launch_env)
1342
+ // contains the very keys we want to scrub (e.g. CLAUDE_EFFORT carried
1343
+ // forward from the launching shell into the env map).
1336
1344
  for (key, value) in env {
1345
+ if unset_set.contains(key.as_str()) {
1346
+ continue;
1347
+ }
1337
1348
  parts.push(format!("{key}={}", shell_quote(value)));
1338
1349
  }
1339
1350
  parts.push("exec".to_string());
@@ -1378,6 +1389,8 @@ pub fn leader_shell_wrapper_command(
1378
1389
  env_unset: &[String],
1379
1390
  provider_label: &str,
1380
1391
  ) -> String {
1392
+ let unset_set: std::collections::BTreeSet<&str> =
1393
+ env_unset.iter().map(String::as_str).collect();
1381
1394
  let mut parts = Vec::new();
1382
1395
  // 1. cd
1383
1396
  parts.push("cd".to_string());
@@ -1389,8 +1402,13 @@ pub fn leader_shell_wrapper_command(
1389
1402
  parts.push(key.clone());
1390
1403
  parts.push("&&".to_string());
1391
1404
  }
1392
- // 3. env exports + provider (NO `exec` so the provider is a child)
1405
+ // 3. env exports + provider (NO `exec` so the provider is a child).
1406
+ // 0.4.x ordering fix: skip keys present in env_unset so KEY=val does not
1407
+ // re-introduce a just-unset variable from the inherited env map.
1393
1408
  for (key, value) in env {
1409
+ if unset_set.contains(key.as_str()) {
1410
+ continue;
1411
+ }
1394
1412
  parts.push(format!("{key}={}", shell_quote(value)));
1395
1413
  }
1396
1414
  parts.extend(argv.iter().map(|arg| shell_quote(arg)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.4.9",
3
+ "version": "0.4.10",
4
4
  "description": "npx installer for Team Agent",
5
5
  "keywords": [
6
6
  "codex",
@@ -20,9 +20,9 @@
20
20
  "team-agent-installer": "npm/install.mjs"
21
21
  },
22
22
  "optionalDependencies": {
23
- "@team-agent/cli-darwin-arm64": "0.4.9",
24
- "@team-agent/cli-darwin-x64": "0.4.9",
25
- "@team-agent/cli-linux-x64": "0.4.9"
23
+ "@team-agent/cli-darwin-arm64": "0.4.10",
24
+ "@team-agent/cli-darwin-x64": "0.4.10",
25
+ "@team-agent/cli-linux-x64": "0.4.10"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",