@team-agent/installer 0.5.74 → 0.5.77

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.
Files changed (45) hide show
  1. package/Cargo.lock +1 -1
  2. package/Cargo.toml +1 -1
  3. package/README.md +9 -0
  4. package/README.zh.md +8 -0
  5. package/crates/team-agent/src/cli/adapters.rs +23 -96
  6. package/crates/team-agent/src/cli/emit.rs +35 -7
  7. package/crates/team-agent/src/cli/mod.rs +63 -42
  8. package/crates/team-agent/src/cli/models.rs +138 -27
  9. package/crates/team-agent/src/cli/status_port/brief.rs +1137 -0
  10. package/crates/team-agent/src/cli/status_port.rs +8 -0
  11. package/crates/team-agent/src/cli/tests/mod.rs +1 -0
  12. package/crates/team-agent/src/cli/tests/run_delegation.rs +29 -24
  13. package/crates/team-agent/src/cli/tests/status_brief.rs +691 -0
  14. package/crates/team-agent/src/cli/tests/status_send.rs +8 -2
  15. package/crates/team-agent/src/layout/worker_env.rs +366 -0
  16. package/crates/team-agent/src/leader/lease.rs +274 -40
  17. package/crates/team-agent/src/leader/registry.rs +177 -19
  18. package/crates/team-agent/src/leader/types.rs +4 -0
  19. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +93 -5
  20. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +1820 -60
  21. package/crates/team-agent/src/lifecycle/launch/spawn.rs +4 -0
  22. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +138 -13
  23. package/crates/team-agent/src/lifecycle/restart/common.rs +3 -0
  24. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +11 -4
  25. package/crates/team-agent/src/lifecycle/tests/b0_reader_hideone_audit_contract.rs +45 -14
  26. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +1 -0
  27. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  28. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +24 -28
  29. package/crates/team-agent/src/lifecycle/tests/upgrade_compat_0211_red.rs +22 -10
  30. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +110 -31
  31. package/crates/team-agent/src/lifecycle/types.rs +4 -0
  32. package/crates/team-agent/src/messaging/delivery.rs +200 -29
  33. package/crates/team-agent/src/messaging/leader_channel.rs +12 -3
  34. package/crates/team-agent/src/messaging/watchers.rs +48 -4
  35. package/crates/team-agent/src/state/paths.rs +17 -2
  36. package/crates/team-agent/src/state/persist.rs +166 -6
  37. package/crates/team-agent/src/state/projection.rs +18 -2
  38. package/crates/team-agent/src/state/repository.rs +36 -0
  39. package/crates/team-agent/src/state/selector.rs +23 -3
  40. package/crates/team-agent/src/test_support/models_timeout_fixture.rs +122 -0
  41. package/crates/team-agent/src/tmux_backend/tests.rs +350 -7
  42. package/crates/team-agent/src/tmux_backend.rs +217 -26
  43. package/crates/team-agent/src/transport/test_support.rs +18 -3
  44. package/crates/team-agent/src/transport.rs +99 -0
  45. package/package.json +4 -4
@@ -54,14 +54,14 @@ fn worker_spawn_inherits_parent_process_env_for_proxy_and_ca() {
54
54
  &launch_transport,
55
55
  )
56
56
  .expect("launch fixture should spawn worker");
57
- let launch_command_line = launch_transport.single_spawn_command_line();
57
+ let launch_spawn = launch_transport.single_spawn();
58
58
 
59
59
  let restart_team = compiled_team_dir("restart", &[("worker_a", "Restart Worker")]);
60
60
  seed_restart_state(&restart_team, "worker_a");
61
61
  let restart_transport = RecordingTransport::new().with_session_present(false);
62
62
  restart_with_transport(&restart_team, true, None, &restart_transport)
63
63
  .expect("restart fixture should spawn worker");
64
- let restart_command_line = restart_transport.single_spawn_command_line();
64
+ let restart_spawn = restart_transport.single_spawn();
65
65
 
66
66
  let add_team = compiled_team_dir("add-agent", &[("worker_a", "Existing Worker")]);
67
67
  seed_running_add_agent_state(&add_team, "worker_a");
@@ -77,17 +77,15 @@ fn worker_spawn_inherits_parent_process_env_for_proxy_and_ca() {
77
77
  &add_transport,
78
78
  )
79
79
  .expect("add-agent fixture should spawn worker");
80
- let add_command_line = add_transport.single_spawn_command_line();
80
+ let add_spawn = add_transport.single_spawn();
81
81
 
82
82
  let failures = [
83
- ("quick-start launch", launch_command_line, "worker_a"),
84
- ("restart", restart_command_line, "worker_a"),
85
- ("add-agent", add_command_line, "worker_b"),
83
+ ("quick-start launch", launch_spawn, "worker_a"),
84
+ ("restart", restart_spawn, "worker_a"),
85
+ ("add-agent", add_spawn, "worker_b"),
86
86
  ]
87
87
  .into_iter()
88
- .flat_map(|(surface, command_line, agent_id)| {
89
- spawn_env_contract_failures(surface, &command_line, agent_id)
90
- })
88
+ .flat_map(|(surface, spawn, agent_id)| spawn_env_contract_failures(surface, &spawn, agent_id))
91
89
  .collect::<Vec<_>>();
92
90
 
93
91
  assert!(
@@ -97,6 +95,61 @@ fn worker_spawn_inherits_parent_process_env_for_proxy_and_ca() {
97
95
  );
98
96
  }
99
97
 
98
+ #[test]
99
+ fn tmux_control_filter_uses_env_keys_not_substring_in_inherited_values() {
100
+ let mut env = BTreeMap::from([
101
+ ("TA_SPAWN_ENV_CANARY".to_string(), "FOO".to_string()),
102
+ (
103
+ "HTTP_PROXY".to_string(),
104
+ "http://canary-proxy:1".to_string(),
105
+ ),
106
+ (
107
+ "NODE_EXTRA_CA_CERTS".to_string(),
108
+ "/canary/ca.pem".to_string(),
109
+ ),
110
+ ("TEAM_AGENT_WORKSPACE".to_string(), "/ws".to_string()),
111
+ (
112
+ "TEAM_AGENT_OWNER_TEAM_ID".to_string(),
113
+ "teamdir".to_string(),
114
+ ),
115
+ ("TEAM_AGENT_AGENT_ID".to_string(), "worker_a".to_string()),
116
+ (
117
+ "HARNESS_PROBE".to_string(),
118
+ "printf 'LS_USR_BIN_TMUX=%s\\n'\n".to_string(),
119
+ ),
120
+ ]);
121
+ let spawn = RecordedSpawn {
122
+ argv: vec!["codex".to_string()],
123
+ env: env.clone(),
124
+ env_unset: Vec::new(),
125
+ session: SessionName::new("s"),
126
+ window: WindowName::new("w"),
127
+ pane_id: PaneId::new("%1"),
128
+ };
129
+ assert!(
130
+ spawn_env_contract_failures("harness-value", &spawn, "worker_a").is_empty(),
131
+ "inherited values may contain the letters TMUX= without leaking TMUX/TMUX_PANE keys"
132
+ );
133
+
134
+ env.insert(
135
+ "TMUX".to_string(),
136
+ "/tmp/tmux-canary/default,1".to_string(),
137
+ );
138
+ let leaked = RecordedSpawn {
139
+ env,
140
+ ..spawn
141
+ };
142
+ let failures = spawn_env_contract_failures("key-leak", &leaked, "worker_a");
143
+ assert!(
144
+ failures.iter().any(|row| row.contains("present_keys=") && row.contains("TMUX")),
145
+ "actual TMUX key must still fail closed; failures={failures:?}"
146
+ );
147
+ assert!(
148
+ failures.iter().all(|row| !row.contains("command_line=")),
149
+ "failures must not dump command_line; failures={failures:?}"
150
+ );
151
+ }
152
+
100
153
  #[test]
101
154
  #[serial(env)]
102
155
  fn worker_spawn_scrubs_cross_provider_process_identity_but_keeps_config_env() {
@@ -234,42 +287,67 @@ fn worker_spawn_stays_out_of_coordinator_tick_and_daemon_preserves_parent_env()
234
287
  );
235
288
  }
236
289
 
237
- fn spawn_env_contract_failures(surface: &str, command_line: &str, agent_id: &str) -> Vec<String> {
290
+ fn spawn_env_has_tmux_control_assignment(spawn: &RecordedSpawn) -> bool {
291
+ spawn.env.contains_key("TMUX")
292
+ || spawn.env.contains_key("TMUX_PANE")
293
+ || spawn.argv.iter().any(|part| {
294
+ part.split_whitespace().any(|tok| {
295
+ tok == "TMUX" || tok.starts_with("TMUX=") || tok.starts_with("TMUX_PANE=")
296
+ })
297
+ })
298
+ }
299
+
300
+ fn spawn_env_contract_failures(
301
+ surface: &str,
302
+ spawn: &RecordedSpawn,
303
+ agent_id: &str,
304
+ ) -> Vec<String> {
238
305
  let mut failures = Vec::new();
239
- for expected in [
240
- "TA_SPAWN_ENV_CANARY=FOO",
241
- "HTTP_PROXY=http://canary-proxy:1",
242
- "NODE_EXTRA_CA_CERTS=/canary/ca.pem",
243
- "TEAM_AGENT_WORKSPACE=",
244
- "TEAM_AGENT_OWNER_TEAM_ID=teamdir",
306
+ for (key, expected) in [
307
+ ("TA_SPAWN_ENV_CANARY", Some("FOO")),
308
+ ("HTTP_PROXY", Some("http://canary-proxy:1")),
309
+ ("NODE_EXTRA_CA_CERTS", Some("/canary/ca.pem")),
310
+ ("TEAM_AGENT_OWNER_TEAM_ID", Some("teamdir")),
245
311
  ] {
246
- if !command_line.contains(expected) {
247
- failures.push(format!(
248
- "{surface}: missing `{expected}`; worker spawn must inherit parent env and overlay Team Agent identity; command_line={command_line:?}"
249
- ));
312
+ match (spawn.env.get(key).map(String::as_str), expected) {
313
+ (Some(actual), Some(wanted)) if actual == wanted => {}
314
+ (Some(_), Some(wanted)) => failures.push(format!(
315
+ "{surface}: `{key}` present but not `{wanted}`"
316
+ )),
317
+ _ => failures.push(format!(
318
+ "{surface}: missing `{key}` overlay/inherit"
319
+ )),
250
320
  }
251
321
  }
252
- if !command_line.contains(&format!("TEAM_AGENT_AGENT_ID={agent_id}")) {
322
+ if spawn
323
+ .env
324
+ .get("TEAM_AGENT_WORKSPACE")
325
+ .is_none_or(|value| value.is_empty())
326
+ {
327
+ failures.push(format!("{surface}: missing `TEAM_AGENT_WORKSPACE` overlay"));
328
+ }
329
+ if spawn.env.get("TEAM_AGENT_AGENT_ID").map(String::as_str) != Some(agent_id) {
253
330
  failures.push(format!(
254
- "{surface}: missing `TEAM_AGENT_AGENT_ID={agent_id}` overlay; command_line={command_line:?}"
331
+ "{surface}: missing `TEAM_AGENT_AGENT_ID={agent_id}` overlay"
255
332
  ));
256
333
  }
257
- if command_line.contains("TMUX=") || command_line.contains("TMUX_PANE=") {
334
+ if spawn_env_has_tmux_control_assignment(spawn) {
335
+ let present: Vec<&str> = ["TMUX", "TMUX_PANE"]
336
+ .into_iter()
337
+ .filter(|key| spawn.env.contains_key(*key))
338
+ .collect();
258
339
  failures.push(format!(
259
- "{surface}: worker spawn must filter tmux control env from inherited parent env; command_line={command_line:?}"
340
+ "{surface}: worker spawn must filter tmux control env from inherited parent env; present_keys={present:?}"
260
341
  ));
261
342
  }
262
- if !command_line.split_whitespace().any(|part| part == "codex") {
343
+ if !spawn.argv.iter().any(|part| part == "codex") {
263
344
  failures.push(format!(
264
- "{surface}: provider executable must remain the command name `codex`; command_line={command_line:?}"
345
+ "{surface}: provider executable must remain the command name `codex`"
265
346
  ));
266
347
  }
267
- if command_line
268
- .split_whitespace()
269
- .any(|part| part.ends_with("/codex"))
270
- {
348
+ if spawn.argv.iter().any(|part| part.ends_with("/codex")) {
271
349
  failures.push(format!(
272
- "{surface}: provider executable must not be hard-coded as an absolute codex path; command_line={command_line:?}"
350
+ "{surface}: provider executable must not be hard-coded as an absolute codex path"
273
351
  ));
274
352
  }
275
353
  failures
@@ -580,6 +658,7 @@ impl RecordingTransport {
580
658
  self
581
659
  }
582
660
 
661
+ #[allow(dead_code)]
583
662
  fn single_spawn_command_line(&self) -> String {
584
663
  self.single_spawn().command_line()
585
664
  }
@@ -797,6 +797,8 @@ pub enum RestartReport {
797
797
  coordinator: CoordinatorStartSummary,
798
798
  next_actions: Vec<String>,
799
799
  attach_commands: Vec<String>,
800
+ /// Debt returned by this restart's best-effort auto-attach, if any.
801
+ attach_window_failures: Option<serde_json::Value>,
800
802
  },
801
803
  /// At least one worker failed during live spawn, but other workers were isolated
802
804
  /// and restarted. The CLI reports `status=partial` and exits non-zero.
@@ -811,6 +813,8 @@ pub enum RestartReport {
811
813
  coordinator: CoordinatorStartSummary,
812
814
  next_actions: Vec<String>,
813
815
  attach_commands: Vec<String>,
816
+ /// Debt returned by this restart's best-effort auto-attach, if any.
817
+ attach_window_failures: Option<serde_json::Value>,
814
818
  },
815
819
  /// All workers failed during live spawn. No worker is reported as restarted.
816
820
  Failed {
@@ -31,9 +31,9 @@ use crate::provider::wire::{
31
31
  is_claude_family, parse_canonical_provider, parse_provider, provider_wire,
32
32
  };
33
33
  use crate::transport::{
34
- submit_verification_wire, turn_verification_wire, CaptureRange, InjectPayload, InjectReport,
35
- InjectVerification, Key, PaneId, PaneInfo, SessionName, SubmitObserver, SubmitVerification,
36
- Target, Transport, WindowName,
34
+ submit_verification_wire, turn_verification_wire, CaptureRange, CaptureSampleOutcome,
35
+ InjectPayload, InjectReport, InjectVerification, InputSurfaceProbe, Key, PaneId, PaneInfo,
36
+ QueryOutcome, SessionName, SubmitObserver, SubmitVerification, Target, Transport, WindowName,
37
37
  };
38
38
 
39
39
  use super::helpers::{message_exists, MessageStatusShadow};
@@ -749,16 +749,20 @@ pub fn deliver_pending_message(
749
749
  // are preserved byte-for-byte for grep compatibility; new keys are
750
750
  // ADDITIONAL.
751
751
  let submit_attempts_detail = render_submit_diagnostics(&inject_report);
752
- event_log.write(
753
- "send.unverified",
754
- serde_json::json!({
755
- "message_id": message_id,
756
- "recipient": message.recipient,
757
- "reason": reason,
758
- "attempts": inject_report.attempts,
759
- "submit_attempts_detail": submit_attempts_detail,
760
- }),
761
- )?;
752
+ let mut unverified = serde_json::json!({
753
+ "message_id": message_id,
754
+ "recipient": message.recipient,
755
+ "reason": reason,
756
+ "attempts": inject_report.attempts,
757
+ "submit_attempts_detail": submit_attempts_detail,
758
+ });
759
+ merge_inject_readback_fields(
760
+ &mut unverified,
761
+ &inject_report,
762
+ Some(&target),
763
+ resolved.metadata.as_ref(),
764
+ );
765
+ event_log.write("send.unverified", unverified)?;
762
766
  if inject_report.attempts >= u32::from(SEND_RETRY_MAX_ATTEMPTS) {
763
767
  store.mark(message_id, "failed", Some("send_unverified_exhausted"))?;
764
768
  emit_send_failed_exhausted(
@@ -1070,7 +1074,7 @@ fn persist_submit_verification(workspace: &Path, message_id: &str, report: &Inje
1070
1074
  if std::fs::create_dir_all(&dir).is_err() {
1071
1075
  return;
1072
1076
  }
1073
- let body = serde_json::json!({
1077
+ let mut body = serde_json::json!({
1074
1078
  "message_id": message_id,
1075
1079
  "submit_verification": format!("{:?}", report.submit_verification),
1076
1080
  "inject_verification": format!("{:?}", report.inject_verification),
@@ -1078,6 +1082,7 @@ fn persist_submit_verification(workspace: &Path, message_id: &str, report: &Inje
1078
1082
  "submit_verified": inject_submit_verified(report),
1079
1083
  "attempts": report.attempts,
1080
1084
  });
1085
+ merge_inject_readback_fields(&mut body, report, None, None);
1081
1086
  let _ = std::fs::write(dir.join("submit-verification.json"), format!("{body}\n"));
1082
1087
  }
1083
1088
 
@@ -1914,6 +1919,116 @@ fn render_submit_diagnostics(report: &InjectReport) -> serde_json::Value {
1914
1919
  )
1915
1920
  }
1916
1921
 
1922
+ fn json_opt_bool(value: Option<bool>) -> serde_json::Value {
1923
+ match value {
1924
+ Some(flag) => serde_json::Value::Bool(flag),
1925
+ None => serde_json::Value::Null,
1926
+ }
1927
+ }
1928
+
1929
+ fn inject_pane_id(target: &Target) -> Option<&str> {
1930
+ match target {
1931
+ Target::Pane(pane) => Some(pane.as_str()),
1932
+ Target::SessionWindow { .. } => None,
1933
+ }
1934
+ }
1935
+
1936
+ fn merge_inject_readback_fields(
1937
+ event: &mut serde_json::Value,
1938
+ report: &InjectReport,
1939
+ target: Option<&Target>,
1940
+ metadata: Option<&TargetMetadata>,
1941
+ ) {
1942
+ if let Some(metadata) = metadata {
1943
+ append_target_metadata(event, metadata);
1944
+ }
1945
+ let Some(obj) = event.as_object_mut() else {
1946
+ return;
1947
+ };
1948
+ let inject_pane = target.and_then(inject_pane_id);
1949
+ if let Some(pane) = inject_pane {
1950
+ obj.insert(
1951
+ "inject_target_pane_id".to_string(),
1952
+ serde_json::json!(pane),
1953
+ );
1954
+ }
1955
+ let resolved_pane = metadata.map(|meta| meta.target_pane_id.as_str());
1956
+ obj.insert(
1957
+ "target_pane_matched".to_string(),
1958
+ json_opt_bool(match (inject_pane, resolved_pane) {
1959
+ (Some(left), Some(right)) => Some(left == right),
1960
+ _ => None,
1961
+ }),
1962
+ );
1963
+ let inject_endpoint = metadata.and_then(|meta| meta.tmux_endpoint.as_deref());
1964
+ // metadata.tmux_endpoint is the transport used to resolve; compare to
1965
+ // itself is tautological. Equality vs inject target id is the pane check
1966
+ // above. Socket equality is recorded when both sides exist on metadata.
1967
+ obj.insert(
1968
+ "resolved_tmux_endpoint_present".to_string(),
1969
+ serde_json::Value::Bool(inject_endpoint.is_some()),
1970
+ );
1971
+ let diag = report.submit_diagnostics.as_ref();
1972
+ obj.insert(
1973
+ "token_seen_before_paste".to_string(),
1974
+ json_opt_bool(diag.and_then(|d| d.token_seen_before_paste)),
1975
+ );
1976
+ obj.insert(
1977
+ "token_seen_after_paste".to_string(),
1978
+ json_opt_bool(diag.and_then(|d| d.token_seen_after_paste)),
1979
+ );
1980
+ obj.insert(
1981
+ "token_seen_after_enter".to_string(),
1982
+ json_opt_bool(diag.and_then(|d| d.token_seen_after_enter)),
1983
+ );
1984
+ obj.insert(
1985
+ "capture_ok_count".to_string(),
1986
+ serde_json::json!(diag.map(|d| d.capture_ok_count).unwrap_or(0)),
1987
+ );
1988
+ obj.insert(
1989
+ "capture_err_count".to_string(),
1990
+ serde_json::json!(diag.map(|d| d.capture_err_count).unwrap_or(0)),
1991
+ );
1992
+ obj.insert(
1993
+ "capture_sample_count".to_string(),
1994
+ serde_json::json!(diag.map(|d| d.capture_sample_count).unwrap_or(0)),
1995
+ );
1996
+ obj.insert(
1997
+ "last_capture_outcome".to_string(),
1998
+ serde_json::json!(diag
1999
+ .map(|d| d.last_capture_outcome.as_str())
2000
+ .unwrap_or(CaptureSampleOutcome::NotAttempted.as_str())),
2001
+ );
2002
+ obj.insert(
2003
+ "pane_command_basename".to_string(),
2004
+ diag.and_then(|d| d.pane_command_basename.as_deref())
2005
+ .map(|name| serde_json::json!(name))
2006
+ .unwrap_or(serde_json::Value::Null),
2007
+ );
2008
+ obj.insert(
2009
+ "pane_command_query".to_string(),
2010
+ serde_json::json!(diag
2011
+ .map(|d| d.pane_command_query.as_str())
2012
+ .unwrap_or(QueryOutcome::Unavailable.as_str())),
2013
+ );
2014
+ obj.insert(
2015
+ "input_surface".to_string(),
2016
+ serde_json::json!(diag
2017
+ .map(|d| d.input_surface.as_str())
2018
+ .unwrap_or(InputSurfaceProbe::Unavailable.as_str())),
2019
+ );
2020
+ obj.insert(
2021
+ "diag_target_pane_id".to_string(),
2022
+ diag.and_then(|d| d.target_pane_id.as_deref())
2023
+ .map(|pane| serde_json::json!(pane))
2024
+ .unwrap_or(serde_json::Value::Null),
2025
+ );
2026
+ obj.insert(
2027
+ "diag_target_pane_query_matched".to_string(),
2028
+ json_opt_bool(diag.and_then(|d| d.target_pane_query_matched)),
2029
+ );
2030
+ }
2031
+
1917
2032
  #[allow(clippy::too_many_arguments)]
1918
2033
  fn emit_send_failed_exhausted(
1919
2034
  workspace: &Path,
@@ -1944,21 +2059,22 @@ fn emit_send_failed_exhausted(
1944
2059
  .and_then(|r| r.submit_diagnostics.as_ref())
1945
2060
  .and_then(|d| d.attempts_detail.last())
1946
2061
  .map(|a| a.pane_tail_excerpt.clone());
1947
- event_log.write(
1948
- "send.failed",
1949
- serde_json::json!({
1950
- "message_id": message_id,
1951
- "recipient": recipient,
1952
- "attempts": attempts,
1953
- "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
1954
- "reason": failure_reason,
1955
- "verification": verification,
1956
- "submit_attempts_detail": submit_attempts_detail,
1957
- "total_elapsed_ms": total_elapsed_ms,
1958
- "last_matched_literal": last_matched_literal,
1959
- "last_pane_tail_excerpt": last_pane_tail_excerpt,
1960
- }),
1961
- )?;
2062
+ let mut failed = serde_json::json!({
2063
+ "message_id": message_id,
2064
+ "recipient": recipient,
2065
+ "attempts": attempts,
2066
+ "max_attempts": SEND_RETRY_MAX_ATTEMPTS,
2067
+ "reason": failure_reason,
2068
+ "verification": verification,
2069
+ "submit_attempts_detail": submit_attempts_detail,
2070
+ "total_elapsed_ms": total_elapsed_ms,
2071
+ "last_matched_literal": last_matched_literal,
2072
+ "last_pane_tail_excerpt": last_pane_tail_excerpt,
2073
+ });
2074
+ if let Some(report) = inject_report {
2075
+ merge_inject_readback_fields(&mut failed, report, None, None);
2076
+ }
2077
+ event_log.write("send.failed", failed)?;
1962
2078
  let content = format!(
1963
2079
  "send.failed\nerror: send to {recipient} failed with {failure_reason} after {attempts}/{SEND_RETRY_MAX_ATTEMPTS} attempts\naction: inspect the target pane and retry the send\nlog: .team/logs/events.jsonl"
1964
2080
  );
@@ -3266,4 +3382,59 @@ mod paste_floor_tests {
3266
3382
  Duration::ZERO
3267
3383
  );
3268
3384
  }
3385
+
3386
+ fn empty_report() -> InjectReport {
3387
+ InjectReport {
3388
+ stage_reached: crate::transport::InjectStage::Submit,
3389
+ inject_verification: InjectVerification::CaptureMissingToken,
3390
+ submit_verification: SubmitVerification::SubmitConsumptionUnverified,
3391
+ turn_verification: crate::transport::TurnVerification::NotYetObserved,
3392
+ attempts: 1,
3393
+ submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
3394
+ last_capture_outcome: CaptureSampleOutcome::Failed,
3395
+ capture_err_count: 1,
3396
+ capture_sample_count: 1,
3397
+ pane_command_basename: Some("grok".to_string()),
3398
+ pane_command_query: QueryOutcome::Observed,
3399
+ input_surface: InputSurfaceProbe::Input,
3400
+ target_pane_id: Some("%9".to_string()),
3401
+ target_pane_query_matched: Some(true),
3402
+ token_seen_after_paste: None,
3403
+ ..crate::transport::SubmitDiagnostics::default()
3404
+ }),
3405
+ }
3406
+ }
3407
+
3408
+ #[test]
3409
+ fn inject_readback_fields_are_safe_scalars_and_keep_attempts_array() {
3410
+ let report = empty_report();
3411
+ let mut event = serde_json::json!({
3412
+ "message_id": "msg_x",
3413
+ "submit_attempts_detail": [{"matched": false}],
3414
+ });
3415
+ merge_inject_readback_fields(
3416
+ &mut event,
3417
+ &report,
3418
+ Some(&Target::Pane(PaneId::new("%9"))),
3419
+ None,
3420
+ );
3421
+ assert_eq!(event["last_capture_outcome"], serde_json::json!("failed"));
3422
+ assert_eq!(event["token_seen_after_paste"], serde_json::Value::Null);
3423
+ assert_eq!(event["pane_command_basename"], serde_json::json!("grok"));
3424
+ assert_eq!(event["input_surface"], serde_json::json!("input"));
3425
+ assert_eq!(event["inject_target_pane_id"], serde_json::json!("%9"));
3426
+ assert!(event["submit_attempts_detail"].is_array());
3427
+ assert!(event.get("pane_tail_excerpt").is_none());
3428
+ let dump = event.to_string();
3429
+ assert!(
3430
+ !dump.contains("--help") && !dump.contains("argv"),
3431
+ "readback fields must not leak argv: {dump}"
3432
+ );
3433
+ assert_eq!(
3434
+ report.inject_verification,
3435
+ InjectVerification::CaptureMissingToken
3436
+ );
3437
+ assert!(!inject_submit_verified(&report));
3438
+ assert!(!pane_readback_verified(&report));
3439
+ }
3269
3440
  }
@@ -7,6 +7,11 @@ use crate::codex_app_server::AppServerBinding;
7
7
  use crate::model::pane_authority_refusal::PaneWorkspaceMismatchFacts;
8
8
  use crate::transport::{PaneInfo, Transport};
9
9
 
10
+ /// Scope authority written by a fresh quick-start caller binding. It is distinct
11
+ /// from an operator-initiated explicit claim, but uses the same pane-instance
12
+ /// nonce and exact workspace checks at delivery.
13
+ pub(crate) const FRESH_CALLER_SCOPE_AUTHORITY: &str = "fresh_caller";
14
+
10
15
  #[derive(Debug, Clone, PartialEq, Eq)]
11
16
  pub enum LiveLeaderChannel {
12
17
  DirectTmux(DirectTmuxLeaderChannel),
@@ -117,7 +122,7 @@ pub fn resolve_live_leader_channel(
117
122
  };
118
123
  if let Some(observed_pane_workspace) = observed.current_path.clone() {
119
124
  if !path_is_in_workspace(&observed_pane_workspace, workspace)
120
- && !explicit_claim_authority_matches(workspace, receiver, &observed)
125
+ && !scoped_authority_matches(workspace, receiver, &observed)
121
126
  {
122
127
  return LeaderChannelResolution::Unbound(LeaderChannelUnbound::PaneWorkspaceMismatch(
123
128
  PaneWorkspaceMismatchFacts {
@@ -138,12 +143,16 @@ pub fn resolve_live_leader_channel(
138
143
  }))
139
144
  }
140
145
 
141
- fn explicit_claim_authority_matches(
146
+ fn scoped_authority_matches(
142
147
  workspace: &Path,
143
148
  receiver: &Value,
144
149
  observed: &PaneInfo,
145
150
  ) -> bool {
146
- if string_field(receiver, "scope_authority") != Some("explicit_claim") {
151
+ let authority = string_field(receiver, "scope_authority");
152
+ if !matches!(
153
+ authority,
154
+ Some("explicit_claim") | Some(FRESH_CALLER_SCOPE_AUTHORITY)
155
+ ) {
147
156
  return false;
148
157
  }
149
158
  let Some(authorized_workspace) = string_field(receiver, "authorized_team_workspace") else {
@@ -653,12 +653,30 @@ pub fn recover_watchers_for_incident(
653
653
  /// row for watcher-backed messages. A `submitted_pending_acceptance` row has
654
654
  /// already crossed the transport boundary and is claim-immutable. Any future
655
655
  /// retry belongs to a separate typed recovery arm, not attach/claim convergence.
656
+ /// Compatibility helper retained for callers that only need the total.
657
+ /// The typed recovery path below is the single writer; this wrapper performs
658
+ /// no second requeue and only maps the first result to `total()`.
656
659
  pub(crate) fn requeue_blocked_leader_messages(
657
660
  store: &MessageStore,
658
661
  event_log: &EventLog,
659
662
  owner_team_id: &TeamKey,
660
663
  claimed_pane_id: &PaneId,
661
664
  ) -> Result<usize, MessagingError> {
665
+ requeue_blocked_leader_messages_with_counts(
666
+ store,
667
+ event_log,
668
+ owner_team_id,
669
+ claimed_pane_id,
670
+ )
671
+ .map(crate::message_store::BlockedLeaderRequeueCounts::total)
672
+ }
673
+
674
+ pub(crate) fn requeue_blocked_leader_messages_with_counts(
675
+ store: &MessageStore,
676
+ event_log: &EventLog,
677
+ owner_team_id: &TeamKey,
678
+ claimed_pane_id: &PaneId,
679
+ ) -> Result<crate::message_store::BlockedLeaderRequeueCounts, MessagingError> {
662
680
  // E6 (0.5.9 offline-mailbox §6.5): also requeue rows that a third-party
663
681
  // sender left in `queued_until_leader_attach` via the leader mailbox.
664
682
  // Same idempotent requeue funnel (row/message_id/leader_notification_log
@@ -690,7 +708,7 @@ pub(crate) fn requeue_blocked_leader_messages(
690
708
  }),
691
709
  )?;
692
710
  }
693
- Ok(requeued)
711
+ Ok(counts)
694
712
  }
695
713
 
696
714
  /// `requeue_delivery_exhausted_watchers`: attach-leader 成功后把已经耗尽投递
@@ -702,12 +720,33 @@ pub(crate) fn requeue_blocked_leader_messages(
702
720
  /// `deliver_pending_messages` never re-picks — the new leader would never see the
703
721
  /// pending notification.
704
722
  pub fn requeue_delivery_exhausted_watchers(
705
- _workspace: &Path,
723
+ workspace: &Path,
706
724
  store: &MessageStore,
707
725
  event_log: &EventLog,
708
726
  owner_team_id: &TeamKey,
709
727
  claimed_pane_id: &PaneId,
710
728
  ) -> Result<Vec<WatcherNotice>, MessagingError> {
729
+ requeue_delivery_exhausted_watchers_with_counts(
730
+ workspace,
731
+ store,
732
+ event_log,
733
+ owner_team_id,
734
+ claimed_pane_id,
735
+ )
736
+ .map(|(notices, _)| notices)
737
+ }
738
+
739
+ /// Attach-leader's typed recovery result. The counts come from the same
740
+ /// owner-team scoped attach-recovery call (the watcher and message updates
741
+ /// remain their existing transactions), so an attach caller can expose only
742
+ /// this operation's debt rather than scanning arbitrary historical events.
743
+ pub(crate) fn requeue_delivery_exhausted_watchers_with_counts(
744
+ _workspace: &Path,
745
+ store: &MessageStore,
746
+ event_log: &EventLog,
747
+ owner_team_id: &TeamKey,
748
+ claimed_pane_id: &PaneId,
749
+ ) -> Result<(Vec<WatcherNotice>, crate::message_store::BlockedLeaderRequeueCounts), MessagingError> {
711
750
  let conn = crate::db::schema::open_db(store.db_path())?;
712
751
  let mut stmt = conn.prepare(
713
752
  "select watcher_id, result_id, status from result_watchers
@@ -748,8 +787,13 @@ pub fn requeue_delivery_exhausted_watchers(
748
787
  )?;
749
788
  }
750
789
  drop(stmt);
751
- let _ = requeue_blocked_leader_messages(store, event_log, owner_team_id, claimed_pane_id)?;
752
- Ok(out)
790
+ let counts = requeue_blocked_leader_messages_with_counts(
791
+ store,
792
+ event_log,
793
+ owner_team_id,
794
+ claimed_pane_id,
795
+ )?;
796
+ Ok((out, counts))
753
797
  }
754
798
 
755
799
  /// `delivered_result_message` (`result_delivery.py:394`):内容级去重 —— 查某 result_id 是否已有
@@ -27,7 +27,7 @@
27
27
  use std::path::{Path, PathBuf};
28
28
 
29
29
  use crate::model::paths::{runtime_dir, runtime_spec_path};
30
- use crate::state::persist::load_runtime_state;
30
+ use crate::state::persist::{load_runtime_state, load_runtime_state_without_migrations};
31
31
  use crate::state::projection::team_state_candidates;
32
32
 
33
33
  /// The identity of a team within a workspace. Carries the *workspace root*
@@ -193,10 +193,25 @@ impl CommandScope {
193
193
  /// On any I/O error the empty case is returned — destructive commands
194
194
  /// will run their own selector and surface the real error.
195
195
  pub fn resolve(workspace: &Path, requested_team: Option<&str>) -> Self {
196
+ Self::resolve_from_state(requested_team, load_runtime_state(workspace))
197
+ }
198
+
199
+ /// Ambiguity gate for read-only commands. Must not migrate or persist state.
200
+ pub fn resolve_readonly(workspace: &Path, requested_team: Option<&str>) -> Self {
201
+ Self::resolve_from_state(
202
+ requested_team,
203
+ load_runtime_state_without_migrations(workspace),
204
+ )
205
+ }
206
+
207
+ fn resolve_from_state(
208
+ requested_team: Option<&str>,
209
+ loaded: Result<serde_json::Value, crate::state::StateError>,
210
+ ) -> Self {
196
211
  if let Some(team) = requested_team.filter(|t| !t.is_empty()) {
197
212
  return Self::Resolved(team.to_string());
198
213
  }
199
- let Ok(state) = load_runtime_state(workspace) else {
214
+ let Ok(state) = loaded else {
200
215
  return Self::EmptyWorkspace;
201
216
  };
202
217
  let alive = team_state_candidates(&state);