@team-agent/installer 0.4.7 → 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.
@@ -636,6 +636,105 @@ fn classify_restart_plan_never_captured_null_session_auto_fresh_partial_resume()
636
636
  );
637
637
  }
638
638
 
639
+ #[test]
640
+ fn restart_required_missing_skips_never_captured_workers() {
641
+ // RESTART-RESUME-001 (0.4.8): the pre-selection convergence missing-set
642
+ // predicate must skip never-captured workers (null session_id, no
643
+ // first_send_at/last_result_at/task_prompt_delivered) so a single
644
+ // never-captured role doesn't burn the capture deadline and trigger
645
+ // resume_not_ready when the selection stage would auto-fresh it anyway.
646
+ use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
647
+ let state = json!({
648
+ "agents": {
649
+ "w1": { "provider": "claude", "session_id": null, "status": "running" }
650
+ }
651
+ });
652
+ let missing = restart_required_missing_session_agent_ids(&state);
653
+ assert!(
654
+ missing.is_empty(),
655
+ "never-captured null-session worker must NOT appear in required-missing; \
656
+ got {missing:?}"
657
+ );
658
+ }
659
+
660
+ #[test]
661
+ fn restart_required_missing_keeps_null_session_with_first_send_at() {
662
+ // RESTART-RESUME-001: null session_id + valid first_send_at means the
663
+ // leader DID send a message; that context must be preserved → still
664
+ // missing → restart refuses without --allow-fresh.
665
+ use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
666
+ let state = json!({
667
+ "agents": {
668
+ "w1": {
669
+ "provider": "claude",
670
+ "session_id": null,
671
+ "status": "running",
672
+ "spawn_cwd": "/tmp/ws",
673
+ "first_send_at": "2026-01-01T00:00:00+00:00",
674
+ }
675
+ }
676
+ });
677
+ let missing = restart_required_missing_session_agent_ids(&state);
678
+ assert_eq!(
679
+ missing,
680
+ vec!["w1".to_string()],
681
+ "null-session + valid first_send_at (context bearing) must remain in \
682
+ required-missing; got {missing:?}"
683
+ );
684
+ }
685
+
686
+ #[test]
687
+ fn restart_required_missing_keeps_null_session_with_last_result_at() {
688
+ // RESTART-RESUME-001: MCP / report_result paths may not have
689
+ // first_send_at, but a non-empty last_result_at means the worker DID
690
+ // produce a result; that context must be preserved.
691
+ use crate::lifecycle::restart::restart_required_missing_session_agent_ids;
692
+ let state = json!({
693
+ "agents": {
694
+ "w1": {
695
+ "provider": "claude",
696
+ "session_id": null,
697
+ "status": "running",
698
+ "spawn_cwd": "/tmp/ws",
699
+ "last_result_at": "2026-01-01T00:00:00+00:00",
700
+ }
701
+ }
702
+ });
703
+ let missing = restart_required_missing_session_agent_ids(&state);
704
+ assert_eq!(
705
+ missing,
706
+ vec!["w1".to_string()],
707
+ "null-session + last_result_at must remain in required-missing; \
708
+ got {missing:?}"
709
+ );
710
+ }
711
+
712
+ #[test]
713
+ fn classify_restart_plan_null_session_with_last_result_at_refuses_without_allow_fresh() {
714
+ // RESTART-RESUME-001: selection stage must also refuse null-session +
715
+ // last_result_at (no first_send_at) without --allow-fresh. Otherwise the
716
+ // pre-selection convergence and selection-stage refuse semantics drift
717
+ // and the gate fails for MCP-only report paths.
718
+ let state = json!({
719
+ "agents": {
720
+ "w1": {
721
+ "provider": "claude",
722
+ "session_id": null,
723
+ "last_result_at": "2026-01-01T00:00:00+00:00",
724
+ }
725
+ }
726
+ });
727
+ let plan = classify_restart_plan(&state, false).expect("纯验证不应 Err");
728
+ assert_eq!(plan.decisions.len(), 1);
729
+ assert_eq!(
730
+ plan.decisions[0].decision,
731
+ ResumeDecision::Refuse,
732
+ "null-session + last_result_at must Refuse without --allow-fresh \
733
+ (context to preserve); got {:?}",
734
+ plan.decisions[0].decision
735
+ );
736
+ }
737
+
639
738
  #[test]
640
739
  fn classify_restart_plan_never_interacted_null_session_with_allow_fresh_marks_forced_fresh() {
641
740
  // E6 层2: 同上自启动 null-session worker,但显式 --allow-fresh → 用户主动认账丢上下文 → FreshStart。
@@ -452,6 +452,71 @@ pub fn deliver_pending_message(
452
452
  channel: None,
453
453
  });
454
454
  }
455
+ // S1-CAPTURE-001 (0.4.8, CR M4 Claude phase-1): for Claude/ClaudeCode
456
+ // recipients with a known authoritative rollout_path, verify the message
457
+ // token actually reached the worker's transcript before marking
458
+ // delivered. This catches the gate's mis-attribution: pane inject
459
+ // succeeded but the token landed in the leader/unassigned transcript,
460
+ // not the worker's. Budget per architect plan: 64KB tail / single
461
+ // per-delivery check / short grace window. Phase-1 Claude only —
462
+ // codex/copilot keep the pre-fix behaviour (will be addressed in
463
+ // phase-2 once Claude phase-1 is field-validated).
464
+ if let Some((rollout_path, provider_wire_str)) = claude_recipient_rollout(state, &message.recipient) {
465
+ let token_marker = format!("[team-agent-token:{message_id}]");
466
+ let grace = std::time::Duration::from_millis(200);
467
+ let deadline = std::time::Instant::now() + std::time::Duration::from_millis(500);
468
+ let mut transcript_has_token = false;
469
+ loop {
470
+ transcript_has_token =
471
+ rollout_tail_contains(&rollout_path, &token_marker, 64 * 1024);
472
+ if transcript_has_token || std::time::Instant::now() >= deadline {
473
+ break;
474
+ }
475
+ std::thread::sleep(grace);
476
+ }
477
+ if !transcript_has_token {
478
+ // Loud but non-fatal: emit a mismatch event for diagnose/status
479
+ // observability. Do NOT mark delivered — degrade to
480
+ // submitted_unverified so capture is forced to re-attribute.
481
+ let reason = format!(
482
+ "transcript_missing:provider={provider_wire_str},rollout={}",
483
+ rollout_path.display()
484
+ );
485
+ event_log.write(
486
+ "provider.session.transcript_mismatch",
487
+ serde_json::json!({
488
+ "message_id": message_id,
489
+ "recipient": message.recipient,
490
+ "provider": provider_wire_str,
491
+ "rollout_path": rollout_path.to_string_lossy(),
492
+ "pane_id": state
493
+ .get("agents")
494
+ .and_then(|a| a.get(&message.recipient))
495
+ .and_then(|a| a.get("pane_id"))
496
+ .and_then(serde_json::Value::as_str)
497
+ .unwrap_or(""),
498
+ "spawn_epoch": state
499
+ .get("agents")
500
+ .and_then(|a| a.get(&message.recipient))
501
+ .and_then(|a| a.get("spawn_epoch"))
502
+ .and_then(serde_json::Value::as_u64)
503
+ .unwrap_or(0),
504
+ "reason": "transcript_missing",
505
+ }),
506
+ )?;
507
+ store.mark(message_id, "submitted_unverified", Some(&reason))?;
508
+ return Ok(DeliveryOutcome {
509
+ ok: false,
510
+ status: DeliveryStatus::Failed,
511
+ message_status: MessageStatusShadow("submitted_unverified".to_string()),
512
+ message_id: Some(message_id.to_string()),
513
+ verification: Some(reason),
514
+ stage: Some(DeliveryStage::Submit),
515
+ reason: None,
516
+ channel: None,
517
+ });
518
+ }
519
+ }
455
520
  store.mark(message_id, "delivered", None)?;
456
521
  event_log.write(
457
522
  "message.delivered",
@@ -1690,3 +1755,51 @@ pub fn retry_injection_after_trust_auto_answer(
1690
1755
  channel: None,
1691
1756
  })
1692
1757
  }
1758
+
1759
+ /// S1-CAPTURE-001 (0.4.8 phase-1): returns (rollout_path, provider_wire) when
1760
+ /// the recipient agent is Claude/ClaudeCode with a known authoritative
1761
+ /// rollout_path; otherwise None (skip transcript verify). Phase-1 Claude only —
1762
+ /// codex/copilot return None and keep pre-fix delivery semantics.
1763
+ fn claude_recipient_rollout(
1764
+ state: &serde_json::Value,
1765
+ recipient: &str,
1766
+ ) -> Option<(std::path::PathBuf, &'static str)> {
1767
+ let agent = state.get("agents")?.get(recipient)?;
1768
+ let provider = agent.get("provider").and_then(serde_json::Value::as_str)?;
1769
+ let provider_wire_str = match provider {
1770
+ "claude" => "claude",
1771
+ "claude_code" | "claude-code" => "claude_code",
1772
+ _ => return None,
1773
+ };
1774
+ let rollout = agent
1775
+ .get("rollout_path")
1776
+ .and_then(serde_json::Value::as_str)
1777
+ .filter(|s| !s.is_empty())?;
1778
+ Some((std::path::PathBuf::from(rollout), provider_wire_str))
1779
+ }
1780
+
1781
+ /// S1-CAPTURE-001 (0.4.8 phase-1): bounded tail read of the rollout file
1782
+ /// searching for `needle`. Reads up to `tail_bytes` from the end of the file
1783
+ /// (default 64KB budget). Returns true iff the needle appears in the tail.
1784
+ /// Silent on read errors — callers treat missing/unreadable as "token not
1785
+ /// present" which forces the unverified path.
1786
+ fn rollout_tail_contains(path: &std::path::Path, needle: &str, tail_bytes: u64) -> bool {
1787
+ use std::io::{Read, Seek, SeekFrom};
1788
+ let Ok(mut file) = std::fs::File::open(path) else {
1789
+ return false;
1790
+ };
1791
+ let Ok(metadata) = file.metadata() else {
1792
+ return false;
1793
+ };
1794
+ let len = metadata.len();
1795
+ let start = len.saturating_sub(tail_bytes);
1796
+ if file.seek(SeekFrom::Start(start)).is_err() {
1797
+ return false;
1798
+ }
1799
+ let mut buf = Vec::with_capacity(tail_bytes.min(len) as usize);
1800
+ if file.take(tail_bytes).read_to_end(&mut buf).is_err() {
1801
+ return false;
1802
+ }
1803
+ let haystack = String::from_utf8_lossy(&buf);
1804
+ haystack.contains(needle)
1805
+ }
@@ -296,7 +296,24 @@ where
296
296
  .get("rollout_path")
297
297
  .and_then(Value::as_str)
298
298
  .is_some_and(|s| !s.is_empty());
299
- if has_session {
299
+ // S1-CAPTURE-001 (0.4.8): a pending_session_id that differs from the
300
+ // on-row session_id means a fresh respawn is in flight — the prior
301
+ // worker's session_id may still be on the row (concurrent capture race,
302
+ // delayed persist, or persist-backfill replay) but the NEW worker has
303
+ // not yet been bound. Stamping capture_state=captured here would
304
+ // falsely declare the OLD session authoritative for the NEW worker
305
+ // pane — the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001
306
+ // gate evidence. Only stamp `captured` when session_id agrees with
307
+ // _pending_session_id (or _pending is absent, meaning no rebind in
308
+ // flight).
309
+ let pending_mismatch = match (
310
+ agent_obj.get("_pending_session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
311
+ agent_obj.get("session_id").and_then(Value::as_str).filter(|s| !s.is_empty()),
312
+ ) {
313
+ (Some(pending), Some(current)) => pending != current,
314
+ _ => false,
315
+ };
316
+ if has_session && !pending_mismatch {
300
317
  // Captured already — fix state field if drifted.
301
318
  if agent_obj
302
319
  .get("capture_state")
@@ -684,6 +701,29 @@ fn agent_session_complete(agent: &Value) -> bool {
684
701
  if !session_id_ok {
685
702
  return false;
686
703
  }
704
+ // S1-CAPTURE-001 (0.4.8, CR M3 provider-agnostic): pending mismatch guard.
705
+ // When fresh-start has written a new `_pending_session_id` but the old
706
+ // authoritative `session_id` is still present and differs, the tuple is
707
+ // STALE — the new process has a different session that has not been
708
+ // captured yet. Returning true here would let capture skip this agent
709
+ // and keep treating the old transcript as authoritative (the gate's
710
+ // observed mis-attribution). Force re-capture by reporting incomplete.
711
+ // The fresh-tuple clear in mark_agent_started (lifecycle/restart/agent.rs)
712
+ // is the primary fix; this guard handles historical poison state where
713
+ // both fields already coexist from a prior version.
714
+ let pending = agent
715
+ .get("_pending_session_id")
716
+ .and_then(Value::as_str)
717
+ .filter(|s| !s.is_empty());
718
+ let current = agent
719
+ .get("session_id")
720
+ .and_then(Value::as_str)
721
+ .filter(|s| !s.is_empty());
722
+ if let (Some(pending), Some(current)) = (pending, current) {
723
+ if pending != current {
724
+ return false;
725
+ }
726
+ }
687
727
  let rollout_path = match agent
688
728
  .get("rollout_path")
689
729
  .and_then(Value::as_str)
@@ -1002,6 +1042,16 @@ fn apply_captured_session(
1002
1042
  serde_json::to_value(captured.attribution_confidence).unwrap_or(Value::Null),
1003
1043
  );
1004
1044
  agent_obj.remove("attribution_ambiguous");
1045
+ // S1-CAPTURE-001 (0.4.8): after writing the authoritative tuple, the
1046
+ // `_pending_session_id` placeholder is no longer needed — remove it so
1047
+ // future reads don't trip the pending-mismatch guard in
1048
+ // agent_session_complete. Also stamp capture_state="captured" for
1049
+ // diagnose/status observability.
1050
+ agent_obj.remove("_pending_session_id");
1051
+ agent_obj.insert(
1052
+ "capture_state".to_string(),
1053
+ serde_json::json!("captured"),
1054
+ );
1005
1055
  true
1006
1056
  }
1007
1057
 
@@ -1784,4 +1834,84 @@ mod u1_tests {
1784
1834
  let _ = std::fs::remove_dir_all(workspace.join(".team"));
1785
1835
  let _ = std::fs::remove_dir_all(&workspace);
1786
1836
  }
1837
+
1838
+ /// S1-CAPTURE-001 (0.4.8): pending mismatch guard. When fresh-start has
1839
+ /// written a new `_pending_session_id` but the old authoritative
1840
+ /// `session_id` is still present and differs, `agent_session_complete`
1841
+ /// must return false so capture re-runs and re-binds to the new process.
1842
+ /// Without this, the historical poison state (old + new coexist) lets
1843
+ /// the stale rollout look authoritative.
1844
+ #[test]
1845
+ fn agent_session_complete_returns_false_on_pending_mismatch() {
1846
+ let dir = std::env::temp_dir().join(format!(
1847
+ "ta-pending-mismatch-{}",
1848
+ std::process::id()
1849
+ ));
1850
+ let _ = std::fs::create_dir_all(&dir);
1851
+ let rollout = dir.join("old.jsonl");
1852
+ std::fs::write(&rollout, "{\"type\":\"assistant\"}\n").unwrap();
1853
+
1854
+ let agent = serde_json::json!({
1855
+ "provider": "claude",
1856
+ "session_id": "old",
1857
+ "rollout_path": rollout.to_string_lossy(),
1858
+ "captured_at": "2026-01-01T00:00:00+00:00",
1859
+ "captured_via": "fs_watch",
1860
+ "_pending_session_id": "new",
1861
+ });
1862
+
1863
+ assert!(
1864
+ !super::agent_session_complete(&agent),
1865
+ "S1-CAPTURE-001: pending != session_id must force incomplete \
1866
+ (capture must re-attribute to new session)"
1867
+ );
1868
+
1869
+ // Sanity: when they match, complete is true.
1870
+ let agent_match = serde_json::json!({
1871
+ "provider": "claude",
1872
+ "session_id": "new",
1873
+ "rollout_path": rollout.to_string_lossy(),
1874
+ "captured_at": "2026-01-01T00:00:00+00:00",
1875
+ "captured_via": "fs_watch",
1876
+ "_pending_session_id": "new",
1877
+ });
1878
+ assert!(
1879
+ super::agent_session_complete(&agent_match),
1880
+ "pending == session_id (in-progress capture confirmed): must be complete"
1881
+ );
1882
+
1883
+ let _ = std::fs::remove_file(&rollout);
1884
+ let _ = std::fs::remove_dir_all(&dir);
1885
+ }
1886
+
1887
+ /// S1-CAPTURE-001 (0.4.8): apply_captured_session clears
1888
+ /// `_pending_session_id` and stamps `capture_state="captured"` so the
1889
+ /// pending-mismatch guard doesn't fire on subsequent reads.
1890
+ #[test]
1891
+ fn apply_captured_session_clears_pending_and_stamps_capture_state() {
1892
+ let mut agent = serde_json::Map::new();
1893
+ agent.insert(
1894
+ "_pending_session_id".to_string(),
1895
+ serde_json::json!("new-sess"),
1896
+ );
1897
+ let captured = CapturedSession {
1898
+ session_id: Some(SessionId::new("new-sess")),
1899
+ rollout_path: Some(RolloutPath::new(PathBuf::from("/tmp/new.jsonl"))),
1900
+ captured_via: CaptureVia::FsWatch,
1901
+ attribution_confidence: Confidence::High,
1902
+ spawn_cwd: PathBuf::from("/tmp/cwd"),
1903
+ };
1904
+ let written = super::apply_captured_session(&mut agent, &captured);
1905
+ assert!(written, "apply must return true for valid captured");
1906
+ assert!(
1907
+ agent.get("_pending_session_id").is_none(),
1908
+ "S1-CAPTURE-001: _pending_session_id must be removed after capture \
1909
+ (agent={agent:?})"
1910
+ );
1911
+ assert_eq!(
1912
+ agent.get("capture_state").and_then(serde_json::Value::as_str),
1913
+ Some("captured"),
1914
+ "S1-CAPTURE-001: capture_state must be stamped 'captured'"
1915
+ );
1916
+ }
1787
1917
  }
@@ -160,10 +160,14 @@ pub enum CommandScope {
160
160
  /// Caller passed `--team X` explicitly (or there's only one alive team
161
161
  /// and we resolved it). Carries the canonical team_key.
162
162
  Resolved(String),
163
- /// No `--team` and multiple alive teams. Destructive commands MUST
164
- /// refuse with this list of candidates so the operator chooses
165
- /// explicitly. Read-only commands may still proceed (their scope is
166
- /// "all teams" by default).
163
+ /// No `--team` and multiple alive teams. Destructive commands AND
164
+ /// selected-team commands (status, etc.) MUST refuse with this list
165
+ /// of candidates so the operator chooses explicitly. Only true
166
+ /// all-team aggregation commands may proceed without --team.
167
+ /// (S4QR-001 0.4.8: status was previously documented as a read-only
168
+ /// command that could proceed; in fact it projects a single team's
169
+ /// view and silently defaulting to the active team caused gate
170
+ /// failures when multiple alive teams existed.)
167
171
  Ambiguous(Vec<String>),
168
172
  /// No `--team` and no teams alive yet (fresh workspace) — bare
169
173
  /// commands fall through to legacy single-team behaviour.
@@ -560,6 +560,23 @@ fn backfill_capture_fields(incoming_agent: &mut Value, latest_agent: &Value) {
560
560
  if !latest_complete {
561
561
  return;
562
562
  }
563
+ // S1-CAPTURE-001 (0.4.8) Rule 5: a fresh restart marker (`_pending_session_id`
564
+ // present on incoming) signals the worker just respawned and the prior
565
+ // tuple was intentionally cleared. The capture scanner is the only path
566
+ // permitted to promote `_pending_session_id` into the authoritative tuple
567
+ // after it confirms backing. Persist-side backfill MUST NOT revive the
568
+ // latest tuple in this state — doing so resurrects the previous worker's
569
+ // session/rollout pair and delivered tokens land in the OLD transcript
570
+ // (the leader/unassigned mis-attribution surfaced in S1-CAPTURE-001 gate
571
+ // evidence). Refuse backfill regardless of whether incoming session_id
572
+ // is null vs latest's value: the pending marker is authoritative intent.
573
+ let incoming_pending = incoming_row
574
+ .get("_pending_session_id")
575
+ .and_then(Value::as_str)
576
+ .filter(|s| !s.is_empty());
577
+ if incoming_pending.is_some() {
578
+ return;
579
+ }
563
580
  // Rule 2: incoming carries a DIFFERENT non-null session_id → do not mix.
564
581
  let incoming_session = incoming_row.get("session_id").and_then(Value::as_str);
565
582
  let latest_session = latest_agent.get("session_id").and_then(Value::as_str);
@@ -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> {