@team-agent/installer 0.4.8 → 0.4.9

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.9"
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.9"
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).
@@ -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
  }
@@ -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 => {
@@ -636,7 +636,21 @@ impl TmuxBackend {
636
636
  first: bool,
637
637
  ) -> Result<SpawnResult, TransportError> {
638
638
  let command = shell_command(argv, cwd, env, env_unset);
639
- let spawn_argv = tmux_spawn_argv(session, window, &command, first);
639
+ self.spawn_with_command(session, window, &command, first)
640
+ }
641
+
642
+ /// 0.4.x (CR C-2): spawn variant that takes a pre-built shell command
643
+ /// (used by `spawn_first_with_leader_shell_wrapper` /
644
+ /// `spawn_into_with_leader_shell_wrapper` to inject the leader wrapper
645
+ /// shape without going through `shell_command`'s `exec`-only template).
646
+ fn spawn_with_command(
647
+ &self,
648
+ session: &SessionName,
649
+ window: &WindowName,
650
+ command: &str,
651
+ first: bool,
652
+ ) -> Result<SpawnResult, TransportError> {
653
+ let spawn_argv = tmux_spawn_argv(session, window, command, first);
640
654
  self.run_spawn(&spawn_argv)?;
641
655
  let pane_argv = vec![
642
656
  "tmux".to_string(),
@@ -1327,6 +1341,75 @@ fn shell_command(
1327
1341
  parts.join(" ")
1328
1342
  }
1329
1343
 
1344
+ /// 0.4.x (CR R6): single-source marker prefix. The exit marker emitted by
1345
+ /// `leader_shell_wrapper_command` and the substring detected by
1346
+ /// `leader_provider_health` MUST share this prefix exactly. Format:
1347
+ /// `"[team-agent] {provider_label} exited with {rc}"`.
1348
+ pub const LEADER_PROVIDER_EXIT_MARKER_PREFIX: &str = "[team-agent]";
1349
+ pub const LEADER_PROVIDER_EXIT_MARKER_SUFFIX: &str = "exited with";
1350
+
1351
+ /// 0.4.x (CR R6): build the leader exit marker text for `provider_label`.
1352
+ /// Used by both the shell wrapper (printf source) and the health check
1353
+ /// (capture substring) so they cannot drift.
1354
+ pub fn leader_provider_exit_marker(provider_label: &str) -> String {
1355
+ format!(
1356
+ "{LEADER_PROVIDER_EXIT_MARKER_PREFIX} {provider_label} {LEADER_PROVIDER_EXIT_MARKER_SUFFIX}"
1357
+ )
1358
+ }
1359
+
1360
+ /// 0.4.x (CR C-2): leader shell wrapper — provider runs as a CHILD of a
1361
+ /// long-lived shell, not as the pane's primary process. When the provider
1362
+ /// exits, the pane returns to an interactive shell with an explicit exit
1363
+ /// marker, matching manual `tmux new-session` then `claude` behaviour.
1364
+ ///
1365
+ /// Four required envelope sections (CR C-2):
1366
+ /// 1. cd <cwd> — same as `shell_command`
1367
+ /// 2. unset <KEY> ... — provider env_unset block
1368
+ /// 3. KEY=val ... <provider> — env exports + provider invocation
1369
+ /// (NO `exec` — runs as child)
1370
+ /// 4. printf exit marker; exec shell -l
1371
+ ///
1372
+ /// `provider_label` is a human-readable provider name (e.g. "claude",
1373
+ /// "codex") embedded in the exit marker for diagnostics.
1374
+ pub fn leader_shell_wrapper_command(
1375
+ argv: &[String],
1376
+ cwd: &Path,
1377
+ env: &BTreeMap<String, String>,
1378
+ env_unset: &[String],
1379
+ provider_label: &str,
1380
+ ) -> String {
1381
+ let mut parts = Vec::new();
1382
+ // 1. cd
1383
+ parts.push("cd".to_string());
1384
+ parts.push(shell_quote(&cwd.to_string_lossy()));
1385
+ parts.push("&&".to_string());
1386
+ // 2. unset
1387
+ for key in env_unset {
1388
+ parts.push("unset".to_string());
1389
+ parts.push(key.clone());
1390
+ parts.push("&&".to_string());
1391
+ }
1392
+ // 3. env exports + provider (NO `exec` so the provider is a child)
1393
+ for (key, value) in env {
1394
+ parts.push(format!("{key}={}", shell_quote(value)));
1395
+ }
1396
+ parts.extend(argv.iter().map(|arg| shell_quote(arg)));
1397
+ parts.push(";".to_string());
1398
+ // 4. exit marker + fall back to interactive shell
1399
+ parts.push("rc=$?;".to_string());
1400
+ parts.push("printf".to_string());
1401
+ // CR R6: marker text comes from single-source `leader_provider_exit_marker`.
1402
+ parts.push(shell_quote(&format!(
1403
+ "\n{} %s\n",
1404
+ leader_provider_exit_marker(provider_label)
1405
+ )));
1406
+ parts.push("\"$rc\";".to_string());
1407
+ parts.push("exec".to_string());
1408
+ parts.push("\"${SHELL:-/bin/zsh}\"".to_string());
1409
+ parts.push("-l".to_string());
1410
+ parts.join(" ")
1411
+ }
1412
+
1330
1413
  fn shell_quote(raw: &str) -> String {
1331
1414
  if raw.is_empty() {
1332
1415
  return "''".to_string();
@@ -1422,6 +1505,38 @@ impl Transport for TmuxBackend {
1422
1505
  self.spawn_split(session, window, argv, cwd, env, env_unset)
1423
1506
  }
1424
1507
 
1508
+ /// 0.4.x (CR C-2): TmuxBackend override of the leader-shell-wrapper
1509
+ /// variant. Builds the wrapper shell line via
1510
+ /// `leader_shell_wrapper_command` and runs it through
1511
+ /// `spawn_with_command` (bypassing the default `exec <cmd>` shape).
1512
+ fn spawn_first_with_leader_shell_wrapper(
1513
+ &self,
1514
+ session: &SessionName,
1515
+ window: &WindowName,
1516
+ argv: &[String],
1517
+ cwd: &Path,
1518
+ env: &BTreeMap<String, String>,
1519
+ env_unset: &[String],
1520
+ provider_label: &str,
1521
+ ) -> Result<SpawnResult, TransportError> {
1522
+ let command = leader_shell_wrapper_command(argv, cwd, env, env_unset, provider_label);
1523
+ self.spawn_with_command(session, window, &command, true)
1524
+ }
1525
+
1526
+ fn spawn_into_with_leader_shell_wrapper(
1527
+ &self,
1528
+ session: &SessionName,
1529
+ window: &WindowName,
1530
+ argv: &[String],
1531
+ cwd: &Path,
1532
+ env: &BTreeMap<String, String>,
1533
+ env_unset: &[String],
1534
+ provider_label: &str,
1535
+ ) -> Result<SpawnResult, TransportError> {
1536
+ let command = leader_shell_wrapper_command(argv, cwd, env, env_unset, provider_label);
1537
+ self.spawn_with_command(session, window, &command, false)
1538
+ }
1539
+
1425
1540
  fn inject(
1426
1541
  &self,
1427
1542
  target: &Target,
@@ -50,6 +50,9 @@ struct OfflineState {
50
50
  /// U1-C Tail-peek contract: every `capture()` call records its `CaptureRange`,
51
51
  /// in order, so a test can prove the delivery peek site narrowed Full → Tail(80).
52
52
  capture_ranges: Vec<CaptureRange>,
53
+ /// 0.4.x (CR C-3 / CR C-5): pre-staged `query(PaneField::PaneCurrentCommand)`
54
+ /// answer keyed by pane id. Used by leader provider health tests.
55
+ pane_current_commands: BTreeMap<String, String>,
53
56
  }
54
57
 
55
58
  impl Default for OfflineState {
@@ -73,6 +76,7 @@ impl Default for OfflineState {
73
76
  list_targets_error: None,
74
77
  capture_text: BTreeMap::new(),
75
78
  capture_ranges: Vec::new(),
79
+ pane_current_commands: BTreeMap::new(),
76
80
  }
77
81
  }
78
82
  }
@@ -163,6 +167,52 @@ impl OfflineTransport {
163
167
  self
164
168
  }
165
169
 
170
+ /// 0.4.x (CR C-3 / CR C-5): pre-stage a `pane_current_command` answer
171
+ /// for `query(PaneField::PaneCurrentCommand)`. Used by leader provider
172
+ /// health tests to simulate "pane is now in a shell" vs
173
+ /// "pane is running the provider".
174
+ pub fn with_pane_current_command(
175
+ self,
176
+ pane_id: impl Into<String>,
177
+ command: impl Into<String>,
178
+ ) -> Self {
179
+ self.with_state(|state| {
180
+ state.pane_current_commands.insert(pane_id.into(), command.into());
181
+ });
182
+ self
183
+ }
184
+
185
+ /// 0.4.x (CR C-3 / CR C-5): mutable setters for fluent test seeding
186
+ /// (alternative to builder chain when the transport already exists).
187
+ pub fn set_pane_addressable(&self, pane: &PaneId, addressable: bool) {
188
+ self.with_state(|state| {
189
+ state.liveness.insert(
190
+ pane.as_str().to_string(),
191
+ if addressable {
192
+ PaneLiveness::Live
193
+ } else {
194
+ PaneLiveness::Dead
195
+ },
196
+ );
197
+ });
198
+ }
199
+
200
+ pub fn set_pane_current_command(&self, pane: &PaneId, command: &str) {
201
+ self.with_state(|state| {
202
+ state
203
+ .pane_current_commands
204
+ .insert(pane.as_str().to_string(), command.to_string());
205
+ });
206
+ }
207
+
208
+ pub fn set_pane_capture(&self, pane: &PaneId, text: &str) {
209
+ self.with_state(|state| {
210
+ state
211
+ .capture_text
212
+ .insert(pane.as_str().to_string(), text.to_string());
213
+ });
214
+ }
215
+
166
216
  /// Pre-stage `capture()` output for a `Target::SessionWindow{session,window}` key.
167
217
  pub fn with_capture_for_session_window(
168
218
  self,
@@ -378,11 +428,20 @@ impl Transport for OfflineTransport {
378
428
 
379
429
  fn query(
380
430
  &self,
381
- _target: &Target,
382
- _field: PaneField,
431
+ target: &Target,
432
+ field: PaneField,
383
433
  ) -> Result<Option<String>, TransportError> {
384
434
  self.record("query");
385
- Ok(None)
435
+ if !matches!(field, PaneField::PaneCurrentCommand) {
436
+ return Ok(None);
437
+ }
438
+ let pane_id = match target {
439
+ Target::Pane(p) => p.as_str().to_string(),
440
+ Target::SessionWindow { .. } => return Ok(None),
441
+ };
442
+ Ok(self.with_state(|state| {
443
+ state.pane_current_commands.get(&pane_id).cloned()
444
+ }))
386
445
  }
387
446
 
388
447
  fn liveness(&self, pane: &PaneId) -> Result<PaneLiveness, TransportError> {
@@ -584,6 +584,44 @@ pub trait Transport: Send + Sync {
584
584
  self.spawn_into(session, window, argv, cwd, env)
585
585
  }
586
586
 
587
+ /// 0.4.x (CR C-2): leader-specific spawn variant. Instead of `exec <cmd>`
588
+ /// (which makes the provider the pane's primary process and turns the
589
+ /// pane into `[exited]` when the provider exits), build a shell line
590
+ /// that runs the provider as a CHILD of a long-lived shell:
591
+ /// `cd ... && unset ... && KEY=val ... <cmd>; rc=$?; printf '\n[team-agent] <provider> exited with %s\n' "$rc"; exec "${SHELL:-/bin/zsh}" -l`.
592
+ /// When the provider exits, the pane returns to an interactive shell with
593
+ /// an explicit exit marker — matching manual `tmux new-session` then
594
+ /// `claude` behaviour. Default falls back to plain `spawn_first_with_env_unset`
595
+ /// for backends that have no shell layer (test-only `OfflineTransport`).
596
+ fn spawn_first_with_leader_shell_wrapper(
597
+ &self,
598
+ session: &SessionName,
599
+ window: &WindowName,
600
+ argv: &[String],
601
+ cwd: &Path,
602
+ env: &BTreeMap<String, String>,
603
+ env_unset: &[String],
604
+ provider_label: &str,
605
+ ) -> Result<SpawnResult, TransportError> {
606
+ let _ = provider_label;
607
+ self.spawn_first_with_env_unset(session, window, argv, cwd, env, env_unset)
608
+ }
609
+
610
+ /// 同 [`Transport::spawn_first_with_leader_shell_wrapper`],对应 `spawn_into`。
611
+ fn spawn_into_with_leader_shell_wrapper(
612
+ &self,
613
+ session: &SessionName,
614
+ window: &WindowName,
615
+ argv: &[String],
616
+ cwd: &Path,
617
+ env: &BTreeMap<String, String>,
618
+ env_unset: &[String],
619
+ provider_label: &str,
620
+ ) -> Result<SpawnResult, TransportError> {
621
+ let _ = provider_label;
622
+ self.spawn_into_with_env_unset(session, window, argv, cwd, env, env_unset)
623
+ }
624
+
587
625
  // —— INJECT / CAPTURE / QUERY(RIE):按稳定 Target 寻址 ——
588
626
 
589
627
  /// 归并 set/load-buffer + paste-buffer + send submit;空文本走纯 send-keys
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
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.8",
24
- "@team-agent/cli-darwin-x64": "0.4.8",
25
- "@team-agent/cli-linux-x64": "0.4.8"
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"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",