@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 +1 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/src/cli/diagnose.rs +52 -0
- package/crates/team-agent/src/cli/mod.rs +10 -0
- package/crates/team-agent/src/leader/start.rs +180 -6
- package/crates/team-agent/src/lifecycle/profile_launch.rs +8 -1
- package/crates/team-agent/src/tmux_backend.rs +116 -1
- package/crates/team-agent/src/transport/test_support.rs +62 -3
- package/crates/team-agent/src/transport.rs +38 -0
- package/package.json +4 -4
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
|
@@ -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
|
-
.
|
|
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
|
-
.
|
|
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(¤t_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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
-
|
|
431
|
+
target: &Target,
|
|
432
|
+
field: PaneField,
|
|
383
433
|
) -> Result<Option<String>, TransportError> {
|
|
384
434
|
self.record("query");
|
|
385
|
-
|
|
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.
|
|
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.
|
|
24
|
-
"@team-agent/cli-darwin-x64": "0.4.
|
|
25
|
-
"@team-agent/cli-linux-x64": "0.4.
|
|
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",
|