@team-agent/installer 0.3.28 → 0.3.29

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.3.28"
569
+ version = "0.3.29"
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.3.28"
12
+ version = "0.3.29"
13
13
  license = "AGPL-3.0"
14
14
  rust-version = "1.95"
15
15
 
@@ -493,7 +493,9 @@ fn persist_managed_leader_binding(
493
493
  .unwrap_or(0)
494
494
  .saturating_add(1);
495
495
  let now = chrono::Utc::now().to_rfc3339();
496
- let socket = crate::tmux_backend::socket_name_for_workspace(workspace);
496
+ let socket = crate::tmux_backend::socket_path_for_workspace(workspace)
497
+ .map(|path| path.to_string_lossy().to_string())
498
+ .unwrap_or_else(|| crate::tmux_backend::socket_name_for_workspace(workspace));
497
499
  let provider = serde_json::to_value(plan.provider)?;
498
500
  let session = spawned.session.as_str().to_string();
499
501
  let window = spawned.window.as_str().to_string();
@@ -2985,8 +2985,19 @@ pub(crate) fn annotate_runtime_tmux_endpoint(state: &mut serde_json::Value, tran
2985
2985
  let Some(endpoint) = transport.tmux_endpoint() else {
2986
2986
  return;
2987
2987
  };
2988
+ let endpoint_for_state = if Path::new(&endpoint).is_absolute() || endpoint == "default" {
2989
+ endpoint.clone()
2990
+ } else if endpoint == crate::tmux_backend::socket_name_for_workspace(workspace) {
2991
+ crate::tmux_backend::socket_path_for_workspace(workspace)
2992
+ .map(|path| path.to_string_lossy().to_string())
2993
+ .unwrap_or_else(|| endpoint.clone())
2994
+ } else {
2995
+ crate::tmux_backend::socket_path_for_name(&endpoint)
2996
+ .map(|path| path.to_string_lossy().to_string())
2997
+ .unwrap_or_else(|| endpoint.clone())
2998
+ };
2988
2999
  if let Some(obj) = state.as_object_mut() {
2989
- obj.insert("tmux_endpoint".to_string(), serde_json::json!(endpoint));
3000
+ obj.insert("tmux_endpoint".to_string(), serde_json::json!(endpoint_for_state));
2990
3001
  obj.insert(
2991
3002
  "tmux_socket".to_string(),
2992
3003
  obj.get("tmux_endpoint")
@@ -1192,6 +1192,29 @@ fn quick_start_persists_selected_tmux_endpoint_and_attach_commands() {
1192
1192
  assert_eq!(state["teams"]["teamdir"]["is_external_leader"], json!(false));
1193
1193
  }
1194
1194
 
1195
+ #[test]
1196
+ fn annotate_runtime_tmux_endpoint_persists_workspace_socket_as_full_path() {
1197
+ let workspace = temp_ws();
1198
+ let short = crate::tmux_backend::socket_name_for_workspace(&workspace);
1199
+ let expected = crate::tmux_backend::socket_path_for_workspace(&workspace)
1200
+ .expect("workspace socket should have a physical path");
1201
+ let transport = OfflineTransport::new().with_tmux_endpoint(short);
1202
+ let mut state = json!({});
1203
+
1204
+ annotate_runtime_tmux_endpoint(&mut state, &transport, &workspace);
1205
+
1206
+ assert_eq!(
1207
+ state["tmux_endpoint"],
1208
+ json!(expected.to_string_lossy().to_string()),
1209
+ "runtime endpoint state must persist the full tmux socket path, not the short -L name"
1210
+ );
1211
+ assert_eq!(
1212
+ state["tmux_socket"],
1213
+ state["tmux_endpoint"],
1214
+ "tmux_socket mirrors the canonical persisted endpoint"
1215
+ );
1216
+ }
1217
+
1195
1218
  #[test]
1196
1219
  fn quick_start_preserves_managed_leader_topology_and_emits_leader_attach_command() {
1197
1220
  let team = quick_start_team_dir(QS_VALID_ROLE);
@@ -296,8 +296,14 @@ pub fn deliver_pending_message(
296
296
  &message.content,
297
297
  message_id,
298
298
  );
299
+ let is_leader_recipient = message.recipient == "leader";
300
+ let payload = if is_leader_recipient {
301
+ InjectPayload::TextSkipConsumptionPoll(rendered)
302
+ } else {
303
+ InjectPayload::Text(rendered)
304
+ };
299
305
  let inject_report =
300
- match transport.inject(&target, &InjectPayload::Text(rendered), Key::Enter, true) {
306
+ match transport.inject(&target, &payload, Key::Enter, true) {
301
307
  Ok(report) => report,
302
308
  Err(error) => {
303
309
  let reason = format!("inject_failed:{error}");
@@ -373,13 +379,18 @@ pub fn deliver_pending_message(
373
379
  channel: None,
374
380
  });
375
381
  }
376
- };
382
+ };
377
383
  let submit_verified = inject_submit_verified(&inject_report);
378
384
  let readback_verified = pane_readback_verified(&inject_report);
379
- // U1 #7: delivery is verified only when the submit succeeded AND the token was read
380
- // back as visible in the pane. A submit-verified-but-token-missing inject (silent
381
- // paste drop) is NOT delivered it falls through to the unverified/degraded path.
382
- if !(submit_verified && readback_verified) {
385
+ // Worker panes run provider TUIs, so delivery needs both submit consumption
386
+ // and pane readback. The leader pane is a human-facing receiver: no provider
387
+ // consumes the token, so readback is the delivery proof.
388
+ let verified = if is_leader_recipient {
389
+ true
390
+ } else {
391
+ submit_verified && readback_verified
392
+ };
393
+ if !verified {
383
394
  let reason = if !readback_verified {
384
395
  "pane_readback_unverified:capture_missing_token".to_string()
385
396
  } else {
@@ -959,6 +959,18 @@ fn format_report_result_notification(
959
959
  if let Some(tests) = format_report_result_tests(envelope) {
960
960
  lines.push(tests);
961
961
  }
962
+ if let Some(changes) = format_report_result_changes(envelope) {
963
+ lines.push(changes);
964
+ }
965
+ if let Some(risks) = format_report_result_risks(envelope) {
966
+ lines.push(risks);
967
+ }
968
+ if let Some(artifacts) = format_report_result_artifacts(envelope) {
969
+ lines.push(artifacts);
970
+ }
971
+ if let Some(next_actions) = format_report_result_next_actions(envelope) {
972
+ lines.push(next_actions);
973
+ }
962
974
  lines.push(format!("Result id: {result_id}"));
963
975
  lines.push(
964
976
  "Team Agent stored this result. The coordinator/collect path will update team_state.md; no manual polling loop is needed."
@@ -986,6 +998,97 @@ fn format_report_result_tests(envelope: &serde_json::Value) -> Option<String> {
986
998
  }
987
999
  }
988
1000
 
1001
+ fn report_result_array<'a>(
1002
+ envelope: &'a serde_json::Value,
1003
+ key: &str,
1004
+ ) -> Option<&'a Vec<serde_json::Value>> {
1005
+ let values = envelope.get(key).and_then(serde_json::Value::as_array)?;
1006
+ if values.is_empty() {
1007
+ None
1008
+ } else {
1009
+ Some(values)
1010
+ }
1011
+ }
1012
+
1013
+ fn report_field<'a>(value: &'a serde_json::Value, key: &str) -> Option<&'a str> {
1014
+ value
1015
+ .get(key)
1016
+ .and_then(serde_json::Value::as_str)
1017
+ .filter(|text| !text.is_empty())
1018
+ }
1019
+
1020
+ fn report_field_any<'a>(value: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
1021
+ keys.iter().find_map(|key| report_field(value, key))
1022
+ }
1023
+
1024
+ fn format_report_result_changes(envelope: &serde_json::Value) -> Option<String> {
1025
+ let parts = report_result_array(envelope, "changes")?
1026
+ .iter()
1027
+ .filter_map(|change| {
1028
+ let path = report_field_any(change, &["path", "file", "filepath", "filename"])?;
1029
+ let kind = report_field_any(change, &["kind", "type", "action"]).unwrap_or("changed");
1030
+ let description =
1031
+ report_field_any(change, &["description", "summary", "detail", "details", "message"])
1032
+ .unwrap_or(path);
1033
+ Some(format!("{kind} {path}: {description}"))
1034
+ })
1035
+ .collect::<Vec<_>>();
1036
+ if parts.is_empty() {
1037
+ None
1038
+ } else {
1039
+ Some(format!("Changes: {}", parts.join(", ")))
1040
+ }
1041
+ }
1042
+
1043
+ fn format_report_result_risks(envelope: &serde_json::Value) -> Option<String> {
1044
+ let parts = report_result_array(envelope, "risks")?
1045
+ .iter()
1046
+ .filter_map(|risk| {
1047
+ let severity = report_field_any(risk, &["severity", "level"]).unwrap_or("low");
1048
+ let description =
1049
+ report_field_any(risk, &["description", "summary", "detail", "message"])?;
1050
+ Some(format!("{severity}: {description}"))
1051
+ })
1052
+ .collect::<Vec<_>>();
1053
+ if parts.is_empty() {
1054
+ None
1055
+ } else {
1056
+ Some(format!("Risks: {}", parts.join(", ")))
1057
+ }
1058
+ }
1059
+
1060
+ fn format_report_result_artifacts(envelope: &serde_json::Value) -> Option<String> {
1061
+ let parts = report_result_array(envelope, "artifacts")?
1062
+ .iter()
1063
+ .filter_map(|artifact| {
1064
+ let path = report_field_any(artifact, &["path", "file", "filepath", "filename"])?;
1065
+ let description =
1066
+ report_field_any(artifact, &["description", "summary", "detail"]).unwrap_or(path);
1067
+ Some(format!("{path}: {description}"))
1068
+ })
1069
+ .collect::<Vec<_>>();
1070
+ if parts.is_empty() {
1071
+ None
1072
+ } else {
1073
+ Some(format!("Artifacts: {}", parts.join(", ")))
1074
+ }
1075
+ }
1076
+
1077
+ fn format_report_result_next_actions(envelope: &serde_json::Value) -> Option<String> {
1078
+ let parts = report_result_array(envelope, "next_actions")?
1079
+ .iter()
1080
+ .filter_map(|action| {
1081
+ report_field_any(action, &["description", "summary", "action", "todo", "message"])
1082
+ .map(|text| text.to_string())
1083
+ })
1084
+ .collect::<Vec<_>>();
1085
+ if parts.is_empty() {
1086
+ None
1087
+ } else {
1088
+ Some(format!("Next actions: {}", parts.join(", ")))
1089
+ }
1090
+ }
1091
+
989
1092
  /// `_collect_results_and_notify_watchers` (`results.py:430`):coordinator tick 调用 —— collect +
990
1093
  /// notify_result_watchers 编排。daemon-path → Result。
991
1094
  pub fn collect_results_and_notify_watchers(
@@ -999,3 +1102,43 @@ pub fn collect_results_and_notify_watchers(
999
1102
  "notified": notified
1000
1103
  }))
1001
1104
  }
1105
+
1106
+ #[cfg(test)]
1107
+ mod tests {
1108
+ use super::format_report_result_notification;
1109
+
1110
+ #[test]
1111
+ fn report_result_notification_includes_full_envelope_sections() {
1112
+ let envelope = serde_json::json!({
1113
+ "schema_version": "result_envelope_v1",
1114
+ "task_id": "task-1",
1115
+ "agent_id": "worker",
1116
+ "status": "success",
1117
+ "summary": "done",
1118
+ "changes": [
1119
+ {"path": "src/a.rs", "kind": "modified", "description": "patched delivery"}
1120
+ ],
1121
+ "tests": [
1122
+ {"command": "cargo test", "status": "passed"}
1123
+ ],
1124
+ "risks": [
1125
+ {"severity": "low", "description": "none known"}
1126
+ ],
1127
+ "artifacts": [
1128
+ {"path": ".team/artifacts/evidence.md", "description": "evidence"}
1129
+ ],
1130
+ "next_actions": [
1131
+ {"description": "ship after review"}
1132
+ ]
1133
+ });
1134
+ let notification =
1135
+ format_report_result_notification("res_1", "task-1", "worker", "success", &envelope);
1136
+ assert!(notification.contains("Task task-1 reported success from worker: done"));
1137
+ assert!(notification.contains("Tests: cargo test=passed"));
1138
+ assert!(notification.contains("Changes: modified src/a.rs: patched delivery"));
1139
+ assert!(notification.contains("Risks: low: none known"));
1140
+ assert!(notification.contains("Artifacts: .team/artifacts/evidence.md: evidence"));
1141
+ assert!(notification.contains("Next actions: ship after review"));
1142
+ assert!(notification.contains("Result id: res_1"));
1143
+ }
1144
+ }
@@ -573,6 +573,40 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
573
573
  );
574
574
  }
575
575
 
576
+ #[test]
577
+ fn inject_skip_consumption_payload_sends_enter_without_phase2_poll() {
578
+ let text = "hello leader [team-agent-token:skip]";
579
+ let (be, rec) = backend_with(MockResp::Out(ok(text)), vec![]);
580
+ let report = be
581
+ .inject(
582
+ &Target::Pane(PaneId::new("%7")),
583
+ &InjectPayload::TextSkipConsumptionPoll(text.to_string()),
584
+ Key::Enter,
585
+ true,
586
+ )
587
+ .expect("inject");
588
+ let calls = rec.lock().unwrap().clone();
589
+
590
+ assert_eq!(
591
+ report.submit_verification,
592
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
593
+ );
594
+ assert_eq!(report.attempts, 1);
595
+ assert!(
596
+ calls.iter().any(|argv| {
597
+ argv.get(1).map(String::as_str) == Some("send-keys")
598
+ && argv.contains(&"Enter".to_string())
599
+ }),
600
+ "skip-consumption payload must still submit once; calls={calls:?}"
601
+ );
602
+ assert!(
603
+ !calls.iter().any(|argv| {
604
+ argv == &tmux_capture_argv(&PaneId::new("%7"), CaptureRange::Tail(40))
605
+ }),
606
+ "leader-bound skip payload must not run Phase 2 consumption polls; calls={calls:?}"
607
+ );
608
+ }
609
+
576
610
  // ═════════════════════════════════════════════════════════════════════════════
577
611
  // E46 0.3.24 task#327 P0 — submit verification false-positive / false-negative.
578
612
  //
@@ -629,6 +663,37 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
629
663
  assert_eq!(report.turn_verification, TurnVerification::NotYetObserved);
630
664
  }
631
665
 
666
+ #[test]
667
+ fn e46_unconsumed_token_with_live_busy_state_is_treated_as_processing() {
668
+ let token_text = "Team Agent message from leader:\n\nhi\n\n[team-agent-token:msg_busy]";
669
+ let busy_tail = format!("{token_text}\n● Working (1s · esc to interrupt)\n");
670
+ let (be, _rec) = backend_with(MockResp::Out(ok(&busy_tail)), vec![]);
671
+ let report = be
672
+ .inject(
673
+ &Target::Pane(PaneId::new("%7")),
674
+ &InjectPayload::Text(token_text.to_string()),
675
+ Key::Enter,
676
+ true,
677
+ )
678
+ .expect("inject runs");
679
+ assert_eq!(
680
+ report.submit_verification,
681
+ SubmitVerification::EnterSentWithoutPlaceholderCheck,
682
+ "busy provider state means the turn is being processed even if \
683
+ the token has not yet scrolled out of the bottom capture"
684
+ );
685
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
686
+ assert!(
687
+ diagnostics
688
+ .attempts_detail
689
+ .last()
690
+ .map(|obs| obs.pane_tail_excerpt.to_ascii_lowercase().contains("working"))
691
+ .unwrap_or(false),
692
+ "busy-state capture should be recorded in attempts_detail: {:?}",
693
+ diagnostics.attempts_detail
694
+ );
695
+ }
696
+
632
697
  /// 0.3.27: empty pane captures → consumption poll sees "no token in
633
698
  /// bottom 5" → consumed=true → EnterSentWithoutPlaceholderCheck. This is
634
699
  /// the generous default for panes where the capture can't distinguish
@@ -968,6 +1033,19 @@ CaptureMissingToken, not the static CaptureContainsToken false-positive"
968
1033
  EnterSentWithoutPlaceholderCheck; got {:?}",
969
1034
  report.submit_verification
970
1035
  );
1036
+ let diagnostics = report.submit_diagnostics.expect("diagnostics");
1037
+ assert!(
1038
+ !diagnostics.attempts_detail.is_empty(),
1039
+ "E50: E46 consumption gate must preserve per-capture diagnostics"
1040
+ );
1041
+ assert_eq!(diagnostics.attempts_detail[0].attempt_index, 1);
1042
+ assert!(
1043
+ diagnostics.attempts_detail[0]
1044
+ .pane_tail_excerpt
1045
+ .contains("Pasted content"),
1046
+ "E50: recorded pane tail should contain the post-submit capture; got {:?}",
1047
+ diagnostics.attempts_detail[0]
1048
+ );
971
1049
  }
972
1050
 
973
1051
  /// **E50 PR-1 secret scrubbing**: `scrub_pane_excerpt` must strip ANSI
@@ -29,7 +29,8 @@ use crate::transport::{
29
29
  tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv, AttachOutcome, BackendKind,
30
30
  CaptureRange, CapturedText, InjectPayload, InjectReport, InjectStage, InjectVerification, Key,
31
31
  PaneField, PaneId, PaneInfo, PaneMode, SessionName, SetEnvOutcome, SpawnResult,
32
- SubmitVerification, Target, Transport, TransportError, TurnVerification, WindowName,
32
+ SubmitAttemptObservation, SubmitVerification, Target, Transport, TransportError,
33
+ TurnVerification, WindowName,
33
34
  };
34
35
 
35
36
  /// Result of running an external command — the typed output of the OS edge.
@@ -815,10 +816,12 @@ fn buffer_name_for_text(text: &str) -> String {
815
816
  fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerification {
816
817
  match payload {
817
818
  InjectPayload::Empty => InjectVerification::EmptyTextSendKeys,
818
- InjectPayload::Text(text) if text.contains("[team-agent-token:") => {
819
+ InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text)
820
+ if text.contains("[team-agent-token:") =>
821
+ {
819
822
  InjectVerification::CaptureContainsToken
820
823
  }
821
- InjectPayload::Text(_) => InjectVerification::NoToken,
824
+ InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => InjectVerification::NoToken,
822
825
  }
823
826
  }
824
827
 
@@ -826,15 +829,11 @@ fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerificatio
826
829
  /// (`[team-agent-token:<id>]`). Use the full marker, not only the prefix, so an old
827
830
  /// scrollback token cannot verify a new message.
828
831
  fn payload_token_marker(payload: &InjectPayload) -> Option<&str> {
829
- match payload {
830
- InjectPayload::Text(text) => {
831
- let start = text.find("[team-agent-token:")?;
832
- let marker = &text[start..];
833
- let end = marker.find(']')?;
834
- Some(&marker[..=end])
835
- }
836
- _ => None,
837
- }
832
+ let text = payload.text()?;
833
+ let start = text.find("[team-agent-token:")?;
834
+ let marker = &text[start..];
835
+ let end = marker.find(']')?;
836
+ Some(&marker[..=end])
838
837
  }
839
838
 
840
839
  fn token_visible_in_capture(
@@ -940,6 +939,74 @@ fn token_in_bottom_n(text: &str, marker: &str, n: usize) -> bool {
940
939
  .any(|line| line.contains(marker))
941
940
  }
942
941
 
942
+ fn marker_position_from_bottom(text: &str, marker: &str) -> Option<u32> {
943
+ let mut from_bottom = 0u32;
944
+ for line in text.lines().rev().filter(|line| !line.trim().is_empty()) {
945
+ if line.contains(marker) {
946
+ return Some(from_bottom);
947
+ }
948
+ from_bottom = from_bottom.saturating_add(1);
949
+ }
950
+ None
951
+ }
952
+
953
+ fn provider_busy_signal_in_tail(text: &str) -> bool {
954
+ text.lines()
955
+ .rev()
956
+ .filter(|line| !line.trim().is_empty())
957
+ .take(15)
958
+ .any(|line| {
959
+ let lower = line.to_ascii_lowercase();
960
+ lower.contains("working")
961
+ || lower.contains("thinking")
962
+ || lower.contains("esc to interrupt")
963
+ || line.contains('●')
964
+ || line.contains('⏳')
965
+ || line.contains('⠋')
966
+ || line.contains('⠙')
967
+ || line.contains('⠹')
968
+ || line.contains('⠸')
969
+ || line.contains('⠼')
970
+ || line.contains('⠴')
971
+ || line.contains('⠦')
972
+ || line.contains('⠧')
973
+ || line.contains('⠇')
974
+ || line.contains('⠏')
975
+ || line.contains('✶')
976
+ || line.contains('✢')
977
+ })
978
+ }
979
+
980
+ fn submit_attempt_observation(
981
+ attempt_index: u32,
982
+ captured: &CapturedText,
983
+ marker: Option<&str>,
984
+ elapsed_ms: u64,
985
+ ) -> SubmitAttemptObservation {
986
+ let marker_position = marker.and_then(|m| marker_position_from_bottom(&captured.text, m));
987
+ let (matched, matched_literal, where_in_tail) = if let Some(marker) = marker {
988
+ (
989
+ token_in_bottom_n(&captured.text, marker, 15),
990
+ marker_position.map(|_| marker.to_string()),
991
+ marker_position,
992
+ )
993
+ } else if let Some((literal, where_in_tail)) = pasted_prompt_match(&captured.text) {
994
+ (true, Some(literal.to_string()), Some(where_in_tail))
995
+ } else {
996
+ (false, None, None)
997
+ };
998
+ let (pane_tail_excerpt, pane_tail_lines) = scrub_pane_excerpt(&captured.text, 20);
999
+ SubmitAttemptObservation {
1000
+ attempt_index,
1001
+ matched,
1002
+ matched_literal,
1003
+ where_in_tail,
1004
+ pane_tail_excerpt,
1005
+ pane_tail_lines,
1006
+ elapsed_ms,
1007
+ }
1008
+ }
1009
+
943
1010
  /// 0.3.27: check if a pasted-content prompt literal (`pasted content` / `pasted text`)
944
1011
  /// appears in the bottom N non-empty lines. Narrower than the full-Tail(80) check
945
1012
  /// that caused scrollback ghost matches (E50 defect B).
@@ -1184,15 +1251,15 @@ fn post_submit_input_consumed(
1184
1251
  let Some(marker) = payload_token_marker(payload) else {
1185
1252
  return Ok(None);
1186
1253
  };
1187
- let captured = backend.capture(target, CaptureRange::Tail(30))?;
1254
+ let captured = backend.capture(target, CaptureRange::Tail(40))?;
1188
1255
  // The token may legitimately appear in scrollback (a successful submit
1189
1256
  // pushes it into history). We only treat the BOTTOM-of-pane region (last
1190
1257
  // few lines, where the input area lives) as the consumption signal. Tail
1191
1258
  // 30 lines is small enough that the input area still dominates if the
1192
1259
  // submit didn't go through, while a successful submit has pushed the
1193
- // token marker out of the bottom 5 lines by the time the response
1260
+ // token marker out of the bottom 15 lines by the time the response
1194
1261
  // composer redraws.
1195
- let tail_lines: Vec<&str> = captured.text.lines().rev().take(5).collect();
1262
+ let tail_lines: Vec<&str> = captured.text.lines().rev().take(15).collect();
1196
1263
  let token_in_tail = tail_lines.iter().any(|line| line.contains(marker));
1197
1264
  Ok(Some(!token_in_tail))
1198
1265
  }
@@ -1333,7 +1400,7 @@ impl Transport for TmuxBackend {
1333
1400
  let argv = tmux_empty_inject_argv(&pane, submit);
1334
1401
  self.run_ok(&argv)?;
1335
1402
  }
1336
- InjectPayload::Text(text) => {
1403
+ InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text) => {
1337
1404
  let buffer = buffer_name_for_text(text);
1338
1405
  for argv in tmux_inject_text_argv(&pane, &buffer, text, bracketed) {
1339
1406
  let stage = inject_stage_for_argv(&argv);
@@ -1389,7 +1456,7 @@ impl Transport for TmuxBackend {
1389
1456
 
1390
1457
  // Phase 2: submit_and_verify — unified Escape+Enter+poll loop.
1391
1458
  let use_escape = bracketed
1392
- && matches!(payload, InjectPayload::Text(_))
1459
+ && payload.text().is_some()
1393
1460
  && matches!(submit, Key::Enter);
1394
1461
  let escape_argv = if use_escape {
1395
1462
  Some(tmux_send_keys_argv(&pane, &[Key::Escape]))
@@ -1424,16 +1491,33 @@ impl Transport for TmuxBackend {
1424
1491
  let max_submit_attempts: u32 = 3;
1425
1492
  let mut consumption_attempts: u32 = 0;
1426
1493
  let mut consumed: Option<bool> = None;
1494
+ let mut attempts_detail: Vec<SubmitAttemptObservation> = Vec::new();
1427
1495
 
1428
- for attempt in 0..max_submit_attempts {
1496
+ let poll_consumption = !payload.skip_consumption_poll();
1497
+ if !poll_consumption {
1498
+ if self.run_inject_stage(&submit_argv, InjectStage::Submit).is_ok() {
1499
+ consumption_attempts = 1;
1500
+ }
1501
+ }
1502
+ let submit_attempt_limit = if poll_consumption { max_submit_attempts } else { 0 };
1503
+
1504
+ for attempt in 0..submit_attempt_limit {
1505
+ let attempt_index = attempt + 1;
1506
+ let attempt_start = std::time::Instant::now();
1429
1507
  // Before resending (attempt > 0), re-check if the token
1430
1508
  // already disappeared — guards against double-submit (C3).
1431
1509
  // Capture failures are non-fatal (tmux may not be running
1432
1510
  // in MCP sim / test env).
1433
1511
  if attempt > 0 {
1434
1512
  if let Some(m) = marker {
1435
- if let Ok(cap) = self.capture(target, CaptureRange::Tail(30)) {
1436
- if !token_in_bottom_n(&cap.text, m, 5) {
1513
+ if let Ok(cap) = self.capture(target, CaptureRange::Tail(40)) {
1514
+ attempts_detail.push(submit_attempt_observation(
1515
+ attempt_index,
1516
+ &cap,
1517
+ marker,
1518
+ attempt_start.elapsed().as_millis() as u64,
1519
+ ));
1520
+ if !token_in_bottom_n(&cap.text, m, 15) {
1437
1521
  consumed = Some(true);
1438
1522
  break;
1439
1523
  }
@@ -1468,16 +1552,22 @@ impl Transport for TmuxBackend {
1468
1552
  .unwrap_or(Some(false));
1469
1553
  }
1470
1554
 
1471
- // Poll: token disappeared from bottom 5 lines = consumed.
1555
+ // Poll: token disappeared from bottom 15 lines = consumed.
1472
1556
  // Capture failures → consumed=None (non-blocking).
1473
1557
  if let Some(m) = marker {
1474
1558
  let mut found_consumed = false;
1475
1559
  let mut capture_failed = false;
1476
- for _ in 0..6 {
1477
- std::thread::sleep(Duration::from_millis(50));
1478
- match self.capture(target, CaptureRange::Tail(30)) {
1560
+ for _ in 0..12 {
1561
+ std::thread::sleep(Duration::from_millis(100));
1562
+ match self.capture(target, CaptureRange::Tail(40)) {
1479
1563
  Ok(cap) => {
1480
- if !token_in_bottom_n(&cap.text, m, 5) {
1564
+ attempts_detail.push(submit_attempt_observation(
1565
+ attempt_index,
1566
+ &cap,
1567
+ marker,
1568
+ attempt_start.elapsed().as_millis() as u64,
1569
+ ));
1570
+ if !token_in_bottom_n(&cap.text, m, 15) {
1481
1571
  found_consumed = true;
1482
1572
  break;
1483
1573
  }
@@ -1504,16 +1594,43 @@ impl Transport for TmuxBackend {
1504
1594
  Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
1505
1595
  // 0.3.28-final (E55 truth source): consumed=Some(false)
1506
1596
  // means we ran ALL retries and the token never left
1507
- // bottom 5 lines. That is a genuine consumption failure;
1597
+ // bottom 15 lines. That is a genuine consumption failure
1598
+ // unless a provider busy marker proves the TUI is already
1599
+ // processing the turn and simply has not scrolled yet;
1508
1600
  // it MUST NOT be masked. Pre-final used a grace fallback
1509
1601
  // that returned EnterSentWithoutPlaceholderCheck whenever
1510
1602
  // token_visible_for_report=Some(true) — but Phase-1 token
1511
1603
  // visibility only proves the paste landed, NOT that the
1512
1604
  // provider consumed it. The fallback caused
1513
1605
  // delivered=true under E55 (busy-agent paste-landed-not-
1514
- // consumed false positive). Always return Unverified now;
1515
- // delivery treats it as not delivered.
1516
- Some(false) => SubmitVerification::SubmitConsumptionUnverified,
1606
+ // consumed false positive). Do not restore that grace
1607
+ // fallback; only a live busy-state signal upgrades this to
1608
+ // EnterSentWithoutPlaceholderCheck.
1609
+ Some(false) => match self.capture(target, CaptureRange::Tail(15)) {
1610
+ Ok(cap) => {
1611
+ attempts_detail.push(submit_attempt_observation(
1612
+ consumption_attempts.max(1),
1613
+ &cap,
1614
+ marker,
1615
+ inject_start.elapsed().as_millis() as u64,
1616
+ ));
1617
+ let busy = provider_busy_signal_in_tail(&cap.text);
1618
+ eprintln!(
1619
+ "team-agent submit consumption fallback: consumed=false busy_state={busy}"
1620
+ );
1621
+ if busy {
1622
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
1623
+ } else {
1624
+ SubmitVerification::SubmitConsumptionUnverified
1625
+ }
1626
+ }
1627
+ Err(err) => {
1628
+ eprintln!(
1629
+ "team-agent submit consumption fallback: consumed=false busy_capture_error={err}"
1630
+ );
1631
+ SubmitVerification::SubmitConsumptionUnverified
1632
+ }
1633
+ },
1517
1634
  None => submit_verification_for_key(submit),
1518
1635
  };
1519
1636
  let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
@@ -1526,14 +1643,14 @@ impl Transport for TmuxBackend {
1526
1643
  submit_verification,
1527
1644
  turn_verification: match payload {
1528
1645
  InjectPayload::Empty => TurnVerification::NotRequired,
1529
- InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1646
+ InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => TurnVerification::NotYetObserved,
1530
1647
  },
1531
1648
  attempts: consumption_attempts,
1532
1649
  submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
1533
1650
  appear_gate_elapsed_ms: 0,
1534
1651
  appear_gate_matched: false,
1535
1652
  total_elapsed_ms,
1536
- attempts_detail: Vec::new(),
1653
+ attempts_detail,
1537
1654
  }),
1538
1655
  });
1539
1656
  }
@@ -1547,7 +1664,7 @@ impl Transport for TmuxBackend {
1547
1664
  submit_verification: submit_verification_for_key(submit),
1548
1665
  turn_verification: match payload {
1549
1666
  InjectPayload::Empty => TurnVerification::NotRequired,
1550
- InjectPayload::Text(_) => TurnVerification::NotYetObserved,
1667
+ InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => TurnVerification::NotYetObserved,
1551
1668
  },
1552
1669
  attempts: 1,
1553
1670
  // E50 PR-1: Empty payload / non-Text fallthrough path — no submit
@@ -337,7 +337,9 @@ impl Transport for OfflineTransport {
337
337
  state.inject_targets.push(target.clone());
338
338
  state.inject_payloads.push(match payload {
339
339
  InjectPayload::Empty => String::new(),
340
- InjectPayload::Text(text) => text.clone(),
340
+ InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text) => {
341
+ text.clone()
342
+ }
341
343
  });
342
344
  });
343
345
  Ok(Self::inject_report())
@@ -55,11 +55,13 @@
55
55
  InjectVerification::EmptyTextSendKeys,
56
56
  TurnVerification::NotRequired,
57
57
  ),
58
- InjectPayload::Text(text) if text.contains("[team-agent-token:") => (
58
+ InjectPayload::Text(text) | InjectPayload::TextSkipConsumptionPoll(text)
59
+ if text.contains("[team-agent-token:") =>
60
+ (
59
61
  InjectVerification::CaptureContainsToken,
60
62
  TurnVerification::NotYetObserved,
61
63
  ),
62
- InjectPayload::Text(_) => (
64
+ InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => (
63
65
  InjectVerification::NoToken,
64
66
  TurnVerification::NotYetObserved,
65
67
  ),
@@ -141,6 +141,21 @@ pub enum InjectPayload {
141
141
  /// → 纯 send submit-key。
142
142
  Empty,
143
143
  Text(String),
144
+ /// Text payload for human-facing panes that do not consume provider turns.
145
+ TextSkipConsumptionPoll(String),
146
+ }
147
+
148
+ impl InjectPayload {
149
+ pub fn text(&self) -> Option<&str> {
150
+ match self {
151
+ Self::Text(text) | Self::TextSkipConsumptionPoll(text) => Some(text),
152
+ Self::Empty => None,
153
+ }
154
+ }
155
+
156
+ pub fn skip_consumption_poll(&self) -> bool {
157
+ matches!(self, Self::TextSkipConsumptionPoll(_))
158
+ }
144
159
  }
145
160
 
146
161
  /// 抽象 Key 枚举(§gap-5):各后端翻译,不透传 tmux 字面量。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@team-agent/installer",
3
- "version": "0.3.28",
3
+ "version": "0.3.29",
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.3.28",
24
- "@team-agent/cli-darwin-x64": "0.3.28",
25
- "@team-agent/cli-linux-x64": "0.3.28"
23
+ "@team-agent/cli-darwin-arm64": "0.3.29",
24
+ "@team-agent/cli-darwin-x64": "0.3.29",
25
+ "@team-agent/cli-linux-x64": "0.3.29"
26
26
  },
27
27
  "scripts": {
28
28
  "postinstall": "node npm/bincheck.mjs",