@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.
package/Cargo.lock CHANGED
@@ -566,7 +566,7 @@ dependencies = [
566
566
 
567
567
  [[package]]
568
568
  name = "team-agent"
569
- version = "0.4.8"
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.8"
12
+ version = "0.4.10"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -33,6 +33,39 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
33
33
  "issue": "leader_not_attached",
34
34
  "action": "attach or claim a leader receiver before sending work",
35
35
  }));
36
+ } else {
37
+ // 0.4.x (CR R2 P0): leader provider health reconciliation. The
38
+ // leader_receiver may be marked `attached` (pane addressable) but the
39
+ // provider process has exited — pane fell back to shell with the exit
40
+ // marker. Distinguish `leader_provider_exited` from `attached` so
41
+ // status/diagnose surfaces the real state.
42
+ if let Some((pane_id, provider_label)) = leader_pane_and_provider(state) {
43
+ let health = crate::leader::leader_provider_health(
44
+ backend,
45
+ &crate::transport::PaneId::new(pane_id),
46
+ &provider_label,
47
+ );
48
+ match health {
49
+ crate::leader::LeaderProviderHealth::ProviderExited => {
50
+ issues.push(json!("leader_provider_exited"));
51
+ repairs.push(json!({
52
+ "issue": "leader_provider_exited",
53
+ "action": format!(
54
+ "leader pane fell back to shell — provider `{provider_label}` exited; \
55
+ relaunch with `team-agent {provider_label}` to restart the provider"
56
+ ),
57
+ }));
58
+ }
59
+ crate::leader::LeaderProviderHealth::Unreachable => {
60
+ issues.push(json!("leader_provider_unreachable"));
61
+ repairs.push(json!({
62
+ "issue": "leader_provider_unreachable",
63
+ "action": "leader pane is dead — relaunch the leader",
64
+ }));
65
+ }
66
+ crate::leader::LeaderProviderHealth::Alive => {}
67
+ }
68
+ }
36
69
  }
37
70
 
38
71
  if let Some(session_name) = state.get("session_name").and_then(Value::as_str).filter(|s| !s.is_empty()) {
@@ -62,6 +95,25 @@ pub(crate) fn diagnose_runtime(state: &Value, backend: &dyn Transport) -> (Value
62
95
  (Value::Array(issues), Value::Array(repairs))
63
96
  }
64
97
 
98
+ /// 0.4.x (CR R2): pull (pane_id, provider_label) from leader_receiver. Used
99
+ /// by `leader_provider_health` reconcile in diagnose. Returns None when the
100
+ /// leader is not attached or any field is missing.
101
+ fn leader_pane_and_provider(state: &Value) -> Option<(String, String)> {
102
+ let receiver = state.get("leader_receiver")?;
103
+ let pane_id = receiver
104
+ .get("pane_id")
105
+ .and_then(Value::as_str)
106
+ .filter(|s| !s.is_empty())?
107
+ .to_string();
108
+ let provider = receiver
109
+ .get("provider")
110
+ .and_then(Value::as_str)
111
+ .filter(|s| !s.is_empty())
112
+ .unwrap_or("claude")
113
+ .to_string();
114
+ Some((pane_id, provider))
115
+ }
116
+
65
117
  fn leader_receiver_attached(state: &Value) -> bool {
66
118
  let Some(receiver) = state.get("leader_receiver") else {
67
119
  return false;
@@ -1685,6 +1685,16 @@ pub mod lifecycle_port {
1685
1685
  /// matching cannot reap the leader — including when ANOTHER team's bare shutdown
1686
1686
  /// runs, where the leader is never in the invoker's ancestry.
1687
1687
  ///
1688
+ /// 0.4.x (CR R3): leader shell wrapper interaction. The leader pane's
1689
+ /// controlling process is the `sh -lc "...; exec ${SHELL} -l"` that
1690
+ /// runs Claude as a CHILD. When Claude exits and the shell wrapper falls
1691
+ /// back via `exec ${SHELL} -l`, the controlling PID is REPLACED in-place
1692
+ /// by the interactive shell (same pane_pid). Because this function
1693
+ /// protects by `pane.pane_pid` (Source 1 & 2), the fallback interactive
1694
+ /// shell is already covered by the same protection set — shutdown will
1695
+ /// NOT treat the fallback shell as a stray process. Verified by the
1696
+ /// `leader_fallback_shell_protected_when_provider_exited` test.
1697
+ ///
1688
1698
  /// Two leader-pane sources(N39 双来源,真机 grounded):
1689
1699
  /// 1. **Session prefix**: tmux session starts with `team-agent-leader-`(契约 grounded;
1690
1700
  /// 覆盖 LeaderStartMode::NewTmuxSession / AttachExisting).
@@ -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,
@@ -448,16 +448,57 @@ fn ensure_managed_leader_pane(
448
448
  });
449
449
  }
450
450
  }
451
+ // 0.4.x (CR C-1 + C-2): leader env_unset reuses the worker
452
+ // provider_env_unsets (single source of truth) + spawn through the
453
+ // leader shell wrapper so provider exit returns to a shell, not
454
+ // `[exited]`.
455
+ let env_unset = leader_env_unset_for_provider(plan.provider);
456
+ let provider_label = provider_command_name(plan.provider);
451
457
  transport
452
- .spawn_into(session, window, &plan.provider_argv, workspace, &plan.leader_env)
458
+ .spawn_into_with_leader_shell_wrapper(
459
+ session,
460
+ window,
461
+ &plan.provider_argv,
462
+ workspace,
463
+ &plan.leader_env,
464
+ &env_unset,
465
+ provider_label,
466
+ )
453
467
  .map_err(|error| LeaderError::Start(error.to_string()))
454
468
  } else {
469
+ let env_unset = leader_env_unset_for_provider(plan.provider);
470
+ let provider_label = provider_command_name(plan.provider);
455
471
  transport
456
- .spawn_first(session, window, &plan.provider_argv, workspace, &plan.leader_env)
472
+ .spawn_first_with_leader_shell_wrapper(
473
+ session,
474
+ window,
475
+ &plan.provider_argv,
476
+ workspace,
477
+ &plan.leader_env,
478
+ &env_unset,
479
+ provider_label,
480
+ )
457
481
  .map_err(|error| LeaderError::Start(error.to_string()))
458
482
  }
459
483
  }
460
484
 
485
+ /// 0.4.x (CR C-1): leader provider env-unset list — SINGLE SOURCE OF TRUTH
486
+ /// reused from worker spawn (`profile_launch::provider_env_unsets`).
487
+ /// Audit grep guard: this function MUST be the only place in
488
+ /// `crates/team-agent/src/leader/` that constructs a Claude/Codex/Copilot
489
+ /// env-unset list. Any new code path that needs it must call this function
490
+ /// or the underlying `provider_env_unsets`. Use `AuthMode::Subscription` —
491
+ /// the leader is the user's interactive provider, never CompatibleApi/
492
+ /// OfficialApi which are worker-only auth modes today.
493
+ pub fn leader_env_unset_for_provider(provider: Provider) -> Vec<String> {
494
+ crate::lifecycle::profile_launch::provider_env_unsets(
495
+ provider,
496
+ crate::model::enums::AuthMode::Subscription,
497
+ )
498
+ .into_iter()
499
+ .collect()
500
+ }
501
+
461
502
  fn ensure_managed_provider_live_after_attach(
462
503
  transport: &dyn Transport,
463
504
  spawned: &SpawnResult,
@@ -478,6 +519,117 @@ fn ensure_managed_provider_live_after_attach(
478
519
  )))
479
520
  }
480
521
 
522
+ /// 0.4.x (CR C-3 P0): leader provider health reconciliation. The default
523
+ /// `liveness()` check only proves the pane is ADDRESSABLE via tmux — it
524
+ /// returns `Live` even when the provider has exited and the wrapper shell
525
+ /// fell back to an interactive shell. This function distinguishes
526
+ /// `provider_alive` from `provider_exited` by:
527
+ /// 1. Reading `pane_current_command` (tmux `#{pane_current_command}`).
528
+ /// 2. If the current command matches the expected provider binary (or
529
+ /// one of its known aliases), report `Alive`.
530
+ /// 3. If the current command is an interactive shell AND the pane
531
+ /// content contains the exit marker `[team-agent] <provider> exited`
532
+ /// (emitted by `leader_shell_wrapper_command`), report
533
+ /// `ProviderExited`.
534
+ /// 4. Otherwise (Unknown shell, no marker), report `Alive` as the
535
+ /// conservative default — avoid false-positive exit alarms.
536
+ ///
537
+ /// Note: when the leader shell wrapper is used (CR C-2), a provider exit
538
+ /// leaves the pane as `<SHELL:-/bin/zsh>` with the exit marker in
539
+ /// scrollback. Pre-wrapper code that hit `exec claude` would have left the
540
+ /// pane as `[exited]` and `liveness()` would have returned `Dead`. The new
541
+ /// failure mode requires this richer health check to surface
542
+ /// `leader_provider_exited` as a distinct status.
543
+ pub fn leader_provider_health(
544
+ transport: &dyn Transport,
545
+ pane_id: &PaneId,
546
+ expected_provider_label: &str,
547
+ ) -> LeaderProviderHealth {
548
+ use crate::transport::{CaptureRange, PaneField, Target};
549
+ let liveness = transport.liveness(pane_id).ok();
550
+ if matches!(liveness, Some(PaneLiveness::Dead)) {
551
+ return LeaderProviderHealth::Unreachable;
552
+ }
553
+ let target = Target::Pane(pane_id.clone());
554
+ let current_command = transport
555
+ .query(&target, PaneField::PaneCurrentCommand)
556
+ .ok()
557
+ .flatten()
558
+ .map(|s| s.trim().to_lowercase())
559
+ .unwrap_or_default();
560
+ if !current_command.is_empty()
561
+ && (current_command == expected_provider_label
562
+ || current_command.contains(expected_provider_label))
563
+ {
564
+ return LeaderProviderHealth::Alive;
565
+ }
566
+ // Current command is NOT the provider — likely fell back to shell.
567
+ let is_shell = is_interactive_shell_basename(&current_command);
568
+ if is_shell {
569
+ // CR R6: marker text from single-source `leader_provider_exit_marker`
570
+ // so the wrapper printf and the health-check substring cannot drift.
571
+ let exit_marker_substr =
572
+ crate::tmux_backend::leader_provider_exit_marker(expected_provider_label);
573
+ if let Ok(cap) = transport.capture(&target, CaptureRange::Tail(200)) {
574
+ if cap.text.contains(&exit_marker_substr) {
575
+ return LeaderProviderHealth::ProviderExited;
576
+ }
577
+ }
578
+ }
579
+ // Conservative default — pane addressable, but couldn't positively
580
+ // confirm provider exit. Treat as Alive.
581
+ LeaderProviderHealth::Alive
582
+ }
583
+
584
+ /// Health status reported by [`leader_provider_health`].
585
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
586
+ pub enum LeaderProviderHealth {
587
+ /// Provider process appears to be the pane's current command.
588
+ Alive,
589
+ /// Pane has fallen back to a shell with the exit marker present —
590
+ /// provider has exited.
591
+ ProviderExited,
592
+ /// Pane is dead / unaddressable.
593
+ Unreachable,
594
+ }
595
+
596
+ /// 0.4.x (CR R6 + R3): single-source interactive-shell detection.
597
+ /// Used by:
598
+ /// - `leader_provider_health` to decide "pane fell back to shell"
599
+ /// - shutdown logic to recognise a leader pane in fallback-shell mode
600
+ /// as still owned by the leader (not stray).
601
+ ///
602
+ /// Matches by basename (case-insensitive) — `pane_current_command` returns
603
+ /// the basename of the running binary. Conservative whitelist of POSIX +
604
+ /// common interactive shells; missing entries here are false negatives
605
+ /// (shell looks like provider absent → health says Alive) which is the
606
+ /// safe default per the CR R6 conservative-Alive rule.
607
+ pub fn is_interactive_shell_basename(name: &str) -> bool {
608
+ let trimmed = name.trim().to_ascii_lowercase();
609
+ let basename = std::path::Path::new(&trimmed)
610
+ .file_name()
611
+ .and_then(|s| s.to_str())
612
+ .unwrap_or(&trimmed);
613
+ matches!(
614
+ basename,
615
+ "zsh"
616
+ | "bash"
617
+ | "sh"
618
+ | "fish"
619
+ | "dash"
620
+ | "ksh"
621
+ | "tcsh"
622
+ | "csh"
623
+ | "ash"
624
+ | "mksh"
625
+ | "yash"
626
+ | "elvish"
627
+ | "nu"
628
+ | "nushell"
629
+ | "xonsh"
630
+ )
631
+ }
632
+
481
633
  fn managed_spawned_pane_in_targets(transport: &dyn Transport, spawned: &SpawnResult) -> bool {
482
634
  transport
483
635
  .list_targets()
@@ -785,13 +937,35 @@ fn run_leader_argv(
785
937
  "leader launch argv is empty".to_string(),
786
938
  ));
787
939
  };
788
- let mut child = Command::new(program)
940
+ // 0.4.x regression fix (env-leak scenario 1, in-tmux ExecProvider path):
941
+ // the managed-tmux path got the provider env-unset block via the shell
942
+ // wrapper (cb9c217), but the ExecProvider in-tmux path here is a direct
943
+ // Command::spawn().wait() — it inherits the parent process's full env
944
+ // including any CLAUDE_CODE_SESSION_ID / CLAUDE_CODE_CHILD_SESSION /
945
+ // CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS / CLAUDE_CODE_ENTRYPOINT /
946
+ // CLAUDE_CODE_EXECPATH / CLAUDECODE that the launching shell carries.
947
+ // Apply the SAME env-unset list used by the managed path (CR R6 single
948
+ // source: profile_launch::provider_env_unsets via
949
+ // leader_env_unset_for_provider).
950
+ let env_unset = leader_env_unset_for_provider(plan.provider);
951
+ let mut command = Command::new(program);
952
+ command
789
953
  .args(argv.iter().skip(1))
790
- .envs(env)
791
954
  .stdin(Stdio::inherit())
792
955
  .stdout(Stdio::inherit())
793
- .stderr(Stdio::inherit())
794
- .spawn()?;
956
+ .stderr(Stdio::inherit());
957
+ // 0.4.x order fix: env_remove MUST run AFTER envs(). The `env` map comes
958
+ // from `merged_exec_env` which seeds with `std::env::vars().collect()` —
959
+ // calling `.envs(env)` re-adds every inherited CLAUDE_CODE_* the launching
960
+ // shell carried, overwriting any prior env_remove. By removing AFTER the
961
+ // bulk envs() call, the final Command env table has the leak keys
962
+ // structurally absent. Verified by the regression grep guard that the
963
+ // env_remove call appears AFTER `command.envs(env)`.
964
+ command.envs(env);
965
+ for key in &env_unset {
966
+ command.env_remove(key);
967
+ }
968
+ let mut child = command.spawn()?;
795
969
  if plan.mode == LeaderStartMode::ExecProvider {
796
970
  spawn_exec_provider_startup_prompt_handler(plan.provider, workspace.to_path_buf());
797
971
  }