@team-agent/installer 0.5.66 → 0.5.69

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 (178) hide show
  1. package/Cargo.lock +8 -1
  2. package/Cargo.toml +1 -1
  3. package/crates/team-agent/Cargo.toml +1 -0
  4. package/crates/team-agent/src/cli/adapters.rs +5 -0
  5. package/crates/team-agent/src/cli/diagnose.rs +156 -0
  6. package/crates/team-agent/src/cli/emit.rs +6 -5
  7. package/crates/team-agent/src/cli/grok_slot.rs +299 -0
  8. package/crates/team-agent/src/cli/leader.rs +22 -7
  9. package/crates/team-agent/src/cli/leaders.rs +125 -1
  10. package/crates/team-agent/src/cli/mod.rs +20 -0
  11. package/crates/team-agent/src/cli/send/presentation.rs +7 -1
  12. package/crates/team-agent/src/cli/spec.rs +3 -0
  13. package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
  14. package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
  15. package/crates/team-agent/src/cli/tests/base.rs +26 -3
  16. package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
  17. package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
  18. package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
  19. package/crates/team-agent/src/communication_mode/mod.rs +6 -4
  20. package/crates/team-agent/src/compiler.rs +121 -5
  21. package/crates/team-agent/src/coordinator/backoff.rs +55 -0
  22. package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
  23. package/crates/team-agent/src/coordinator/health.rs +173 -0
  24. package/crates/team-agent/src/coordinator/mod.rs +31 -0
  25. package/crates/team-agent/src/coordinator/orphan.rs +31 -0
  26. package/crates/team-agent/src/coordinator/runtime_detectors.rs +31 -0
  27. package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
  28. package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
  29. package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
  30. package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
  31. package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
  32. package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
  33. package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
  34. package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
  35. package/crates/team-agent/src/coordinator/tick.rs +133 -22
  36. package/crates/team-agent/src/coordinator/types.rs +49 -0
  37. package/crates/team-agent/src/db/message_store.rs +39 -5
  38. package/crates/team-agent/src/layout/worker_env.rs +20 -3
  39. package/crates/team-agent/src/layout/worker_window_helpers.rs +3 -0
  40. package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
  41. package/crates/team-agent/src/leader/registry.rs +65 -0
  42. package/crates/team-agent/src/leader/start.rs +973 -75
  43. package/crates/team-agent/src/lifecycle/display.rs +56 -0
  44. package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
  45. package/crates/team-agent/src/lifecycle/launch/add_agent.rs +398 -26
  46. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +80 -0
  47. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +60 -0
  48. package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
  49. package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
  50. package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +548 -0
  51. package/crates/team-agent/src/lifecycle/launch/cursor_mcp_iso.rs +120 -0
  52. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +208 -456
  53. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
  54. package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
  55. package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
  56. package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
  57. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
  58. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +482 -1
  59. package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
  60. package/crates/team-agent/src/lifecycle/launch/pi_mcp.rs +830 -0
  61. package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
  62. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
  63. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +87 -3
  64. package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
  65. package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
  66. package/crates/team-agent/src/lifecycle/launch/spawn.rs +127 -25
  67. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +140 -0
  68. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
  69. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
  70. package/crates/team-agent/src/lifecycle/launch.rs +107 -29
  71. package/crates/team-agent/src/lifecycle/lock.rs +42 -1
  72. package/crates/team-agent/src/lifecycle/mod.rs +11 -0
  73. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
  74. package/crates/team-agent/src/lifecycle/profile_launch.rs +99 -0
  75. package/crates/team-agent/src/lifecycle/profile_smoke.rs +52 -1
  76. package/crates/team-agent/src/lifecycle/restart/agent.rs +214 -160
  77. package/crates/team-agent/src/lifecycle/restart/common.rs +513 -37
  78. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
  79. package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
  80. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
  81. package/crates/team-agent/src/lifecycle/restart/remove.rs +233 -6
  82. package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
  83. package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
  84. package/crates/team-agent/src/lifecycle/restart.rs +29 -0
  85. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
  86. package/crates/team-agent/src/lifecycle/tests/claude_profile_launch_red.rs +24 -3
  87. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
  88. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
  89. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
  90. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
  91. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +464 -0
  92. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +247 -0
  93. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
  94. package/crates/team-agent/src/lifecycle/tests/fixtures/pi_list_models_g0.stdout.txt +222 -0
  95. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +787 -0
  96. package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
  97. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
  98. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +485 -0
  99. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
  100. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
  101. package/crates/team-agent/src/lifecycle/tests/harvest2_a_batch_red.rs +1 -1
  102. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
  103. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
  104. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
  105. package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
  106. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
  107. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
  108. package/crates/team-agent/src/lifecycle/tests/pi_catalog_red.rs +97 -0
  109. package/crates/team-agent/src/lifecycle/tests/pi_command_red.rs +128 -0
  110. package/crates/team-agent/src/lifecycle/tests/pi_compiler_red.rs +207 -0
  111. package/crates/team-agent/src/lifecycle/tests/pi_dual_entry_red.rs +838 -0
  112. package/crates/team-agent/src/lifecycle/tests/pi_executable_mcp_red.rs +320 -0
  113. package/crates/team-agent/src/lifecycle/tests/pi_lifecycle_doctor_red.rs +98 -0
  114. package/crates/team-agent/src/lifecycle/tests/pi_session_delivery_red.rs +216 -0
  115. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +3 -0
  116. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
  117. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
  118. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
  119. package/crates/team-agent/src/lifecycle/tests.rs +30 -13
  120. package/crates/team-agent/src/lifecycle/types.rs +61 -0
  121. package/crates/team-agent/src/lifecycle/worker_command_context.rs +143 -12
  122. package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
  123. package/crates/team-agent/src/mcp_server/tests.rs +196 -2
  124. package/crates/team-agent/src/messaging/delivery.rs +430 -41
  125. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  126. package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
  127. package/crates/team-agent/src/messaging/results.rs +4 -0
  128. package/crates/team-agent/src/messaging/send.rs +34 -0
  129. package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
  130. package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
  131. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
  132. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  133. package/crates/team-agent/src/messaging/types.rs +4 -1
  134. package/crates/team-agent/src/model/enums.rs +17 -6
  135. package/crates/team-agent/src/model/permissions.rs +3 -0
  136. package/crates/team-agent/src/os_probe.rs +73 -2
  137. package/crates/team-agent/src/provider/adapter.rs +259 -6
  138. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +89 -0
  139. package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
  140. package/crates/team-agent/src/provider/adapters/mod.rs +3 -0
  141. package/crates/team-agent/src/provider/adapters/pi.rs +152 -0
  142. package/crates/team-agent/src/provider/bypass_flags.rs +47 -2
  143. package/crates/team-agent/src/provider/classify.rs +4 -2
  144. package/crates/team-agent/src/provider/faults.rs +2 -1
  145. package/crates/team-agent/src/provider/mod.rs +11 -0
  146. package/crates/team-agent/src/provider/session/capture.rs +158 -20
  147. package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
  148. package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
  149. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
  150. package/crates/team-agent/src/provider/session/context_fork.rs +78 -3
  151. package/crates/team-agent/src/provider/session/mod.rs +18 -0
  152. package/crates/team-agent/src/provider/session/resume.rs +57 -0
  153. package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
  154. package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
  155. package/crates/team-agent/src/provider/session_scan/common.rs +205 -2
  156. package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
  157. package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
  158. package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
  159. package/crates/team-agent/src/provider/session_scan/pi.rs +172 -0
  160. package/crates/team-agent/src/provider/session_scan.rs +12 -0
  161. package/crates/team-agent/src/provider/submit_now.rs +94 -0
  162. package/crates/team-agent/src/provider/tests/adapter.rs +384 -0
  163. package/crates/team-agent/src/provider/types.rs +4 -2
  164. package/crates/team-agent/src/provider/wire.rs +29 -1
  165. package/crates/team-agent/src/state/persist.rs +301 -8
  166. package/crates/team-agent/src/state/repository.rs +5 -0
  167. package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
  168. package/crates/team-agent/src/tmux_backend.rs +1116 -75
  169. package/crates/team-agent/src/transport/tests/wire.rs +28 -2
  170. package/crates/team-agent/src/transport.rs +248 -1
  171. package/npm/install.mjs +129 -73
  172. package/package.json +4 -4
  173. package/skills/team-agent/SKILL.md +32 -239
  174. package/skills/team-agent/command-coverage.json +31 -0
  175. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
  176. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
  177. package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
  178. package/crates/team-agent/src/lifecycle/launch/fork_state.rs +0 -447
@@ -1,3 +1,31 @@
1
+ //! ---
2
+ //! purpose: 通过 tmux argv 执行 spawn/inject/capture
3
+ //! contract:
4
+ //! provides:
5
+ //! - name: TmuxBackend
6
+ //! what: Transport 的 tmux 实现,inject/send_keys 取 pane 输入锁
7
+ //! - name: submit-consumption-verdict
8
+ //! what: token 见过再消失且本次占位符身份(#N / grok 行数或 KB)已离开 composer ⇒ 已消费;Gone 时占位符仍在 ⇒ 未消费;无身份时只按一次回车;consumed=None(capture 失败/读数拿不到)⇒ SubmitConsumptionUnverified,不得与 Some(true) 同值
9
+ //! - name: token-sighting
10
+ //! what: Visible / Gone / NeverSeen 三态接到判定(不只进报告)
11
+ //! - name: pre-submit-copy-mode-cancel
12
+ //! what: 统一 prepare_pane_for_submit:Enter 前 cancel 一切非 0 pane_mode(按真实 mode 分派);⛔ 不发 ESC 也不发其替代品(含字面 CSI 201~,用户裁定 2026-08-23);A1 Empty / A3 skip-poll / A4 Phase2 / A6 send_keys(含 Enter) 都走这里
13
+ //! - name: turn-inbox-vs-run
14
+ //! what: busy ⇒ Verified(开跑);composer 仍有本次粘贴且无 busy ⇒ Missing(没开跑);其余 NotYetObserved(不知道)
15
+ //! - name: capture-fail-retry
16
+ //! what: post-Enter capture 失败只重读 capture,计入 attempts,不重粘、不加 Enter
17
+ //! - name: cursor-single-enter
18
+ //! what: TLS 打开时,busy 或 token 不在输入区(底部 5 非空行)则不重按 Enter;transcript 里的 pasted #N 不算输入框
19
+ //! - name: unverified-composer-resend
20
+ //! what: SubmitConsumptionUnverified 且本次身份仍在 composer 且 A 的 should_resubmit_enter 为假(折行拼接缺口)⇒ 只补一颗 C-m;A 因 latch/底15 单行 token 已能重试(含打满 cap)时 B 不介入;consumed=None 不补;达上限 1 或身份消失或已消费或 busy 则停。不认提示符皮肤。
21
+ //! boundary:
22
+ //! - 不把 fire-and-forget 报成 delivered
23
+ //! - 不把「粘贴命中」(any_attempt_matched) 当成已提交
24
+ //! - 不把「没能判断」(consumed=None) 落到成功值
25
+ //! - turn_verification 不是投递闸门;分辨不出不得报开跑
26
+ //! - 补发只重按回车,不重粘、不用 Escape/C-c;闸是折行缺口(A 看不到、拼接身份还在),不是提示符字符串、也不是 pane 忙闲;A 已覆盖的 latch 滞留不走 B
27
+ //! maturity: wired
28
+ //! ---
1
29
  //!
2
30
  //! Concrete tmux `Transport` backend (SKELETON) — the real executor that runs `tmux <argv>`.
3
31
  //!
@@ -25,6 +53,7 @@ use std::io::{Read, Write};
25
53
  // "tmux" shellouts that compile on Windows but return runtime errors
26
54
  // honestly (tmux binary absent → typed subprocess error).
27
55
  // Truth source: `.team/artifacts/0.5.x-windows-portability-survey-design.md` §Batch 1.
56
+ use std::cell::Cell;
28
57
  #[cfg(unix)]
29
58
  use std::os::unix::fs::FileTypeExt;
30
59
  use std::path::{Path, PathBuf};
@@ -34,10 +63,10 @@ use std::time::{Duration, Instant};
34
63
  use crate::model::enums::PaneLiveness;
35
64
  use crate::transport::{
36
65
  normalize_capture, tmux_capture_argv, tmux_empty_inject_argv, tmux_inject_text_argv,
37
- tmux_query_argv, tmux_send_keys_argv, tmux_spawn_argv, AttachOutcome, BackendKind,
38
- CaptureRange, CapturedText, InjectPayload, InjectReport, InjectStage, InjectVerification, Key,
39
- PaneField, PaneId, PaneInfo, PaneMode, SessionName, SetEnvOutcome, SpawnResult,
40
- SubmitAttemptObservation, SubmitObserver, SubmitVerification, Target, Transport,
66
+ tmux_query_argv, tmux_send_keys_argv, tmux_send_submit_argv, tmux_spawn_argv, AttachOutcome,
67
+ BackendKind, CaptureRange, CapturedText, InjectPayload, InjectReport, InjectStage,
68
+ InjectVerification, Key, PaneField, PaneId, PaneInfo, PaneMode, SessionName, SetEnvOutcome,
69
+ SpawnResult, SubmitAttemptObservation, SubmitObserver, SubmitVerification, Target, Transport,
41
70
  TransportError, TurnVerification, WindowName,
42
71
  };
43
72
 
@@ -211,14 +240,23 @@ impl TmuxSocketEndpoint {
211
240
  pub(crate) enum RuntimeTmuxEndpointSource {
212
241
  StateTmuxEndpoint,
213
242
  StateTmuxSocket,
243
+ LeaderReceiverTmuxEndpoint,
244
+ LeaderReceiverTmuxSocket,
214
245
  WorkspaceFallback,
215
246
  }
216
247
 
217
248
  impl RuntimeTmuxEndpointSource {
249
+ /// ---
250
+ /// purpose: endpoint 来源的稳定诊断拼写,让日志能分清 endpoint 是从 state 读的还是 workspace 兜底算的
251
+ /// returns: state.tmux_endpoint / state.tmux_socket / workspace_fallback 三者之一
252
+ /// boundary: 只标来源,不表示该 endpoint 上真的有 tmux server
253
+ /// ---
218
254
  pub(crate) fn as_str(self) -> &'static str {
219
255
  match self {
220
256
  Self::StateTmuxEndpoint => "state.tmux_endpoint",
221
257
  Self::StateTmuxSocket => "state.tmux_socket",
258
+ Self::LeaderReceiverTmuxEndpoint => "leader_receiver.tmux_endpoint",
259
+ Self::LeaderReceiverTmuxSocket => "leader_receiver.tmux_socket",
222
260
  Self::WorkspaceFallback => "workspace_fallback",
223
261
  }
224
262
  }
@@ -233,6 +271,11 @@ pub(crate) struct RuntimeTmuxBackendSelection {
233
271
  impl TmuxBackend {
234
272
  /// Backend bound to the real `tmux` subprocess on the SHARED default socket (no `-L`).
235
273
  /// Non-team callers + existing argv/unit tests stay unaffected.
274
+ /// ---
275
+ /// purpose: 构造绑在共享默认 socket 上的真实 tmux 后端(argv 不带 -L / -S)
276
+ /// returns: 用 RealCommandRunner 执行、无 socket、无事件 workspace 的后端
277
+ /// boundary: 不属于任何 team,故 kill_server 对它是 no-op;不写 events.jsonl(没有 workspace)
278
+ /// ---
236
279
  pub fn new() -> Self {
237
280
  Self {
238
281
  runner: Box::new(RealCommandRunner),
@@ -244,6 +287,13 @@ impl TmuxBackend {
244
287
  /// CP-1 team backend: bound to the real `tmux` subprocess on a PER-WORKSPACE socket, derived
245
288
  /// deterministically from the canonicalized workspace path so the leader CLI, the daemon, and
246
289
  /// every later op (spawn / inject / has_session / kill) hit the SAME `tmux -L <socket>` server.
290
+ /// ---
291
+ /// purpose: 构造绑在「按 workspace 派生的专属 socket」上的真实 tmux 后端
292
+ /// params:
293
+ /// workspace: 工作区路径;socket 名由它经 socket_name_for_workspace 确定性派生
294
+ /// returns: socket 为 Name(ta-xxxxxxxxxxxx)、事件写入该 workspace 的后端
295
+ /// boundary: 只决定「连哪台 server」,不创建 server、不验证 socket 是否存在(探测见 socket_probe_missing_for_workspace)
296
+ /// ---
247
297
  pub fn for_workspace(workspace: &Path) -> Self {
248
298
  Self {
249
299
  runner: Box::new(RealCommandRunner),
@@ -254,6 +304,13 @@ impl TmuxBackend {
254
304
  }
255
305
  }
256
306
 
307
+ /// ---
308
+ /// purpose: 按给定短 socket 名构造真实 tmux 后端
309
+ /// params:
310
+ /// socket: 短 socket 名;空串或 "default" 视为「用共享默认 socket」
311
+ /// returns: socket 名非空且非 default 时绑 Name(socket),否则等价于 new()
312
+ /// boundary: 不识别绝对路径(会被原样当短名走 -L);要按路径寻址请用 for_tmux_endpoint;不设事件 workspace
313
+ /// ---
257
314
  pub(crate) fn for_socket_name(socket: &str) -> Self {
258
315
  if socket.is_empty() || socket == "default" {
259
316
  Self::new()
@@ -266,6 +323,13 @@ impl TmuxBackend {
266
323
  }
267
324
  }
268
325
 
326
+ /// ---
327
+ /// purpose: 按 endpoint 字符串构造真实 tmux 后端,自动分辨绝对路径与短名
328
+ /// params:
329
+ /// endpoint: 绝对路径 ⇒ 直接当 socket 路径;短名 ⇒ 先解析成已存在或默认根下的路径;空串 / "default" ⇒ 共享默认 socket
330
+ /// returns: 解析成功时绑 Path(...);短名解析不出路径时退回 new()
331
+ /// boundary: 只解析寻址,不探活、不创建 server;不设事件 workspace
332
+ /// ---
269
333
  pub(crate) fn for_tmux_endpoint(endpoint: &str) -> Self {
270
334
  if endpoint.is_empty() || endpoint == "default" {
271
335
  Self::new()
@@ -288,6 +352,15 @@ impl TmuxBackend {
288
352
  }
289
353
  }
290
354
 
355
+ /// ---
356
+ /// purpose: 把 leader 认领的绑定 nonce 写进 pane 级 tmux 用户选项,给「这个 pane 实例是不是我绑的那个」留可核凭据
357
+ /// params:
358
+ /// pane: 目标 pane 标识
359
+ /// nonce: 本次绑定的凭据串,原样写入,不做格式校验
360
+ /// returns: 写入命令成功即 Ok
361
+ /// errors: tmux set-option 非 0 或子进程失败时返回 TransportError
362
+ /// boundary: 只写不读、不比对;写的是 pane 级(-p)选项,随 pane 消亡,不跨 socket;不判断该 nonce 是否已被别人占用
363
+ /// ---
291
364
  pub(crate) fn set_pane_binding_nonce(
292
365
  &self,
293
366
  pane: &PaneId,
@@ -306,6 +379,13 @@ impl TmuxBackend {
306
379
  }
307
380
 
308
381
  /// Backend with an injected runner (tests: canned/recording tmux output). Shared default socket.
382
+ /// ---
383
+ /// purpose: 用注入的 runner 构造后端(测试用录制/罐装 tmux 输出),绑共享默认 socket
384
+ /// params:
385
+ /// runner: 替代 RealCommandRunner 的执行 seam
386
+ /// returns: 无 socket、无事件 workspace 的后端
387
+ /// boundary: runner 注入是本文件唯一的 OS 边界替换 seam(with_runner_for_workspace / with_runner_for_tmux_endpoint 同用);不改变任何 argv 构造逻辑
388
+ /// ---
309
389
  pub fn with_runner(runner: Box<dyn CommandRunner>) -> Self {
310
390
  Self {
311
391
  runner,
@@ -316,6 +396,14 @@ impl TmuxBackend {
316
396
 
317
397
  /// Backend with an injected runner bound to a per-workspace socket (tests: assert the `-L` is in
318
398
  /// the recorded argv for a workspace-bound backend).
399
+ /// ---
400
+ /// purpose: 用注入的 runner 构造绑 workspace 专属 socket 的后端(测试断言 -L 确实进了 argv)
401
+ /// params:
402
+ /// runner: 执行 seam
403
+ /// workspace: socket 名与事件落盘位置的来源
404
+ /// returns: socket 为 Name(派生名)、事件写入该 workspace 的后端
405
+ /// boundary: socket 名派生与 for_workspace 完全同源,不允许出现第二套派生规则
406
+ /// ---
319
407
  pub fn with_runner_for_workspace(runner: Box<dyn CommandRunner>, workspace: &Path) -> Self {
320
408
  Self {
321
409
  runner,
@@ -326,10 +414,15 @@ impl TmuxBackend {
326
414
  }
327
415
  }
328
416
 
329
- pub(crate) fn with_runner_for_tmux_endpoint(
330
- runner: Box<dyn CommandRunner>,
331
- endpoint: &str,
332
- ) -> Self {
417
+ /// ---
418
+ /// purpose: 用注入的 runner 构造按 endpoint 寻址的后端
419
+ /// params:
420
+ /// runner: 执行 seam
421
+ /// endpoint: 绝对路径 ⇒ Path(...);空串 / "default" ⇒ 无 socket;短名 ⇒ 解析成路径,解析不出则无 socket
422
+ /// returns: 按上述分支绑定 socket 的后端;不设事件 workspace
423
+ /// boundary: 分支与 for_tmux_endpoint 语义一致但顺序不同(此处先判绝对路径),两者都不探活
424
+ /// ---
425
+ pub fn with_runner_for_tmux_endpoint(runner: Box<dyn CommandRunner>, endpoint: &str) -> Self {
333
426
  if Path::new(endpoint).is_absolute() {
334
427
  Self {
335
428
  runner,
@@ -360,6 +453,14 @@ impl TmuxBackend {
360
453
  }
361
454
 
362
455
  /// Build the exact argv that a workspace-bound tmux backend will execute.
456
+ /// ---
457
+ /// purpose: 算出「workspace 绑定后端实际会执行的那条 argv」,供诊断与提示文案复用
458
+ /// params:
459
+ /// workspace: 决定注入哪个 -L socket
460
+ /// argv: 原始 argv;首元素为 "tmux" 时才会被插入 socket 参数
461
+ /// returns: 插好 socket 参数的 argv;非 tmux argv 原样返回
462
+ /// boundary: 只构造不执行;结果必须与真实执行走的 tmux_argv 完全一致,否则提示会骗人
463
+ /// ---
363
464
  pub fn argv_for_workspace(workspace: &Path, argv: &[String]) -> Vec<String> {
364
465
  Self::for_workspace(workspace).tmux_argv(argv)
365
466
  }
@@ -392,6 +493,13 @@ impl TmuxBackend {
392
493
  /// `tmux -L <socket> kill-server` (CP-1 cleanup): best-effort teardown of the per-team server on
393
494
  /// shutdown so per-team sockets do not orphan. No-op (and never errors) for a default-socket
394
495
  /// backend, and a "no server" failure is ignored.
496
+ /// ---
497
+ /// purpose: 收尾时尽力拆掉本 team 专属的 tmux server,避免 socket 变孤儿
498
+ /// boundary:
499
+ /// - 默认 socket 后端直接返回,绝不拆共享 server
500
+ /// - 只在 list_targets 证明 server 为空时才拆;列举失败按「未证明所有权」处理,倒向不拆;有事件 workspace 时才写 tmux.kill_server_skipped_nonempty_or_unknown 事件(按 endpoint / 短名构造的后端没有,静默跳过)
501
+ /// - 尽力而为:执行结果被丢弃,不返回错误、不重试
502
+ /// ---
395
503
  pub fn kill_server(&self) {
396
504
  if self.socket.is_none() {
397
505
  return;
@@ -422,6 +530,14 @@ impl TmuxBackend {
422
530
  }
423
531
  }
424
532
 
533
+ /// ---
534
+ /// purpose: 选出运行期该用的 tmux 后端——优先跟随 state 里持久化的 endpoint,没有才按 workspace 派生
535
+ /// params:
536
+ /// workspace: 兜底派生 socket 的来源
537
+ /// state: 运行期状态 JSON;从中读 tmux_endpoint,其次 tmux_socket
538
+ /// returns: 后端 + 实际使用的 endpoint + 来源标记(三者一并返回,便于日志说清为什么连这台)
539
+ /// boundary: 只做选择,不探活、不创建 server;state 里的 endpoint 即便已失效也照选,失败留给后续命令暴露
540
+ /// ---
425
541
  pub(crate) fn tmux_backend_for_runtime_state_or_workspace(
426
542
  workspace: &Path,
427
543
  state: Option<&serde_json::Value>,
@@ -442,6 +558,16 @@ pub(crate) fn tmux_backend_for_runtime_state_or_workspace(
442
558
  }
443
559
  }
444
560
 
561
+ /// ---
562
+ /// purpose: 同 tmux_backend_for_runtime_state_or_workspace,但用注入的 runner,给测试断言选择逻辑
563
+ /// params:
564
+ /// runner: 执行 seam
565
+ /// workspace: 兜底派生 socket 的来源
566
+ /// state: 运行期状态 JSON
567
+ /// returns: 后端 + 实际使用的 endpoint + 来源标记
568
+ /// cfg: test
569
+ /// boundary: 仅测试编译;选择分支必须与生产版本同构,否则测的不是同一件事
570
+ /// ---
445
571
  #[cfg(test)]
446
572
  pub(crate) fn tmux_backend_with_runner_for_runtime_state_or_workspace(
447
573
  runner: Box<dyn CommandRunner>,
@@ -486,6 +612,50 @@ fn runtime_tmux_endpoint_from_state(
486
612
  })
487
613
  }
488
614
 
615
+ /// ---
616
+ /// purpose: 读取 selected team's persisted leader receiver endpoint
617
+ /// params:
618
+ /// state: 运行期全量或 selected-team 投影 JSON
619
+ /// returns: nested selected receiver 优先,其次投影顶层 receiver;二者都缺则 None
620
+ /// boundary: 只读 owning receiver,不读取普通 tmux_endpoint,不探活
621
+ /// ---
622
+ pub(crate) fn owning_tmux_endpoint_from_state(
623
+ state: &serde_json::Value,
624
+ ) -> Option<(&str, RuntimeTmuxEndpointSource)> {
625
+ fn receiver_endpoint(state: &serde_json::Value) -> Option<(&str, RuntimeTmuxEndpointSource)> {
626
+ state
627
+ .pointer("/leader_receiver/tmux_endpoint")
628
+ .and_then(serde_json::Value::as_str)
629
+ .filter(|endpoint| !endpoint.is_empty())
630
+ .map(|endpoint| {
631
+ (
632
+ endpoint,
633
+ RuntimeTmuxEndpointSource::LeaderReceiverTmuxEndpoint,
634
+ )
635
+ })
636
+ .or_else(|| {
637
+ state
638
+ .pointer("/leader_receiver/tmux_socket")
639
+ .and_then(serde_json::Value::as_str)
640
+ .filter(|endpoint| !endpoint.is_empty())
641
+ .map(|endpoint| {
642
+ (
643
+ endpoint,
644
+ RuntimeTmuxEndpointSource::LeaderReceiverTmuxSocket,
645
+ )
646
+ })
647
+ })
648
+ }
649
+
650
+ state
651
+ .get("active_team_key")
652
+ .and_then(serde_json::Value::as_str)
653
+ .filter(|team_key| !team_key.is_empty())
654
+ .and_then(|team_key| state.get("teams").and_then(|teams| teams.get(team_key)))
655
+ .and_then(receiver_endpoint)
656
+ .or_else(|| receiver_endpoint(state))
657
+ }
658
+
489
659
  /// CP-1 socket name: SHORT + DETERMINISTIC per canonical workspace path. `ta-` + 12 hex chars of a
490
660
  /// stable FNV-1a hash over the canonicalized path. AF_UNIX `sun_path` is ~104 chars and the socket
491
661
  /// lives at `/tmp/tmux-<uid>/<name>`, so we must NOT use the (~88-char) session name. §10: a
@@ -496,6 +666,13 @@ fn runtime_tmux_endpoint_from_state(
496
666
  /// identical workspace identity. Adding a wrapper (not calling
497
667
  /// `socket_name_for_workspace` directly outside this file) keeps the
498
668
  /// existing internal API stable.
669
+ /// ---
670
+ /// purpose: 对外暴露「workspace 身份短哈希」,让 tmux 与 ConPTY 两个后端看到同一个 workspace 身份
671
+ /// params:
672
+ /// workspace: 工作区路径;先 canonicalize 再哈希
673
+ /// returns: 12 位十六进制串,即 socket 名去掉 ta- 前缀的部分
674
+ /// boundary: 与 socket_name_for_workspace 同源,禁止另起一套哈希;canonicalize 失败时退回原始路径参与哈希,故未落盘的路径可能与落盘后不同值
675
+ /// ---
499
676
  pub fn workspace_short_hash_pub(workspace: &Path) -> String {
500
677
  // The tmux short-socket name is `ta-<12 hex>`; the workspace-hash
501
678
  // used by the ConPTY workspace identity is the raw 12-hex portion
@@ -508,6 +685,13 @@ pub fn workspace_short_hash_pub(workspace: &Path) -> String {
508
685
  /// Public re-export of the crate-private `runtime_tmux_endpoint_from_state`
509
686
  /// probe. `transport_factory` uses this to spot the legacy tmux endpoint
510
687
  /// in `state` without duplicating the field-precedence logic.
688
+ /// ---
689
+ /// purpose: 对外暴露 state 里 tmux endpoint 的读取与字段优先级,避免调用方各写一份
690
+ /// params:
691
+ /// state: 运行期状态 JSON
692
+ /// returns: Some((endpoint, 来源标记)) 当 tmux_endpoint 或 tmux_socket 非空;两者都缺或为空则 None
693
+ /// boundary: 只读字段不探活;返回值里不会出现 workspace_fallback(那是选后端时才产生的来源)
694
+ /// ---
511
695
  pub fn runtime_tmux_endpoint_from_state_pub(
512
696
  state: Option<&serde_json::Value>,
513
697
  ) -> Option<(String, &'static str)> {
@@ -515,12 +699,26 @@ pub fn runtime_tmux_endpoint_from_state_pub(
515
699
  let src = match source {
516
700
  RuntimeTmuxEndpointSource::StateTmuxEndpoint => "state.tmux_endpoint",
517
701
  RuntimeTmuxEndpointSource::StateTmuxSocket => "state.tmux_socket",
702
+ RuntimeTmuxEndpointSource::LeaderReceiverTmuxEndpoint => {
703
+ "leader_receiver.tmux_endpoint"
704
+ }
705
+ RuntimeTmuxEndpointSource::LeaderReceiverTmuxSocket => "leader_receiver.tmux_socket",
518
706
  RuntimeTmuxEndpointSource::WorkspaceFallback => "workspace_fallback",
519
707
  };
520
708
  (endpoint.to_string(), src)
521
709
  })
522
710
  }
523
711
 
712
+ /// ---
713
+ /// purpose: 由 workspace 路径确定性派生短 socket 名,保证 CLI、daemon 与后续每条命令都落到同一台 tmux server
714
+ /// params:
715
+ /// workspace: 工作区路径;先 canonicalize,失败则退回原始路径
716
+ /// returns: ta- 加 12 位十六进制(FNV-1a 低 48 位)
717
+ /// boundary:
718
+ /// - 必须短:AF_UNIX 路径长度有限,不能拿 session 名当 socket 名
719
+ /// - 用固定 FNV-1a 而非 std 默认哈希(后者跨版本不稳定)
720
+ /// - 只算名字,不判断该 socket 是否存在
721
+ /// ---
524
722
  pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
525
723
  let canonical = workspace
526
724
  .canonicalize()
@@ -530,10 +728,24 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
530
728
  format!("ta-{:012x}", hasher.finish() & 0xffff_ffff_ffff)
531
729
  }
532
730
 
731
+ /// ---
732
+ /// purpose: 给出 workspace 专属 socket 的文件路径
733
+ /// params:
734
+ /// workspace: 工作区路径
735
+ /// returns: 已存在的 socket 路径优先;否则给默认根下的预期路径;Windows 上恒为 None
736
+ /// boundary: 返回 Some 不代表 socket 存在(可能只是「按约定应该在这」);要判存在用 socket_probe_missing_for_workspace
737
+ /// ---
533
738
  pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
534
739
  socket_path_for_name(&socket_name_for_workspace(workspace))
535
740
  }
536
741
 
742
+ /// ---
743
+ /// purpose: 探测 workspace 专属 socket 当前是否不存在
744
+ /// params:
745
+ /// workspace: 工作区路径
746
+ /// returns: 在已知 socket 根下都找不到该名字时为 true
747
+ /// boundary: 只看文件是否存在,不连不握手——socket 文件在但 server 已死也会报 false
748
+ /// ---
537
749
  pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
538
750
  existing_socket_path_for_workspace(workspace).is_none()
539
751
  }
@@ -542,6 +754,14 @@ fn existing_socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
542
754
  existing_socket_path_for_name(&socket_name_for_workspace(workspace))
543
755
  }
544
756
 
757
+ /// ---
758
+ /// purpose: 把短 socket 名解析成文件路径
759
+ /// params:
760
+ /// socket_name: 短名;空串、"default"、绝对路径三种输入都视为「无短名可解析」
761
+ /// returns: 已存在则返回实际路径;否则 Unix 上给 /tmp/tmux-<uid>/<name> 预期路径,Windows 上 None
762
+ /// cfg: unix 分支读 geteuid;not(unix) 分支恒 None
763
+ /// boundary: 不创建目录、不创建 socket;返回 Some 不代表文件存在
764
+ /// ---
545
765
  pub(crate) fn socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
546
766
  if socket_name.is_empty() || socket_name == "default" || Path::new(socket_name).is_absolute() {
547
767
  return None;
@@ -581,6 +801,13 @@ fn existing_socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
581
801
  None
582
802
  }
583
803
 
804
+ /// ---
805
+ /// purpose: socket 找不到时给操作者的可执行提示,把期望的 socket 名与找过的目录都写出来
806
+ /// params:
807
+ /// workspace: 工作区路径,用于派生 socket 名
808
+ /// returns: 含 socket 名、已搜索根目录列表与下一步命令的单行提示
809
+ /// boundary: 只生成文案,不重试、不修复、不创建 socket
810
+ /// ---
584
811
  pub(crate) fn socket_missing_hint_for_workspace(workspace: &Path) -> String {
585
812
  let socket_name = socket_name_for_workspace(workspace);
586
813
  let roots = tmux_socket_roots()
@@ -593,6 +820,15 @@ pub(crate) fn socket_missing_hint_for_workspace(workspace: &Path) -> String {
593
820
  )
594
821
  }
595
822
 
823
+ /// ---
824
+ /// purpose: 生成让操作者手工 attach 到指定窗口的 tmux 命令行(按 workspace 派生 socket)
825
+ /// params:
826
+ /// workspace: 决定 -S 后面的 socket 路径
827
+ /// session_name: attach 目标 session
828
+ /// window_name: attach 目标 window
829
+ /// returns: 完整命令字符串;socket 路径解析不出时 None
830
+ /// boundary: 只生成文案、不执行、不验证 session/window 是否存在;endpoint 已持久化在 state 里时应改用 attach_command_for_runtime_state_or_workspace,否则会指到错误的 socket
831
+ /// ---
596
832
  pub(crate) fn attach_command_for_workspace(
597
833
  workspace: &Path,
598
834
  session_name: &SessionName,
@@ -607,6 +843,14 @@ pub(crate) fn attach_command_for_workspace(
607
843
  ))
608
844
  }
609
845
 
846
+ /// ---
847
+ /// purpose: 同 attach_command_for_workspace,但只到 session 一级(不指定 window)
848
+ /// params:
849
+ /// workspace: 决定 -S 后面的 socket 路径
850
+ /// session_name: attach 目标 session
851
+ /// returns: 完整命令字符串;socket 路径解析不出时 None
852
+ /// boundary: 只生成文案、不执行、不验证 session 是否存在
853
+ /// ---
610
854
  pub(crate) fn attach_command_for_session(
611
855
  workspace: &Path,
612
856
  session_name: &SessionName,
@@ -619,6 +863,14 @@ pub(crate) fn attach_command_for_session(
619
863
  ))
620
864
  }
621
865
 
866
+ /// ---
867
+ /// purpose: 按 transport 自报的 endpoint 生成 attach 命令,保证提示指向它真正在用的那台 server
868
+ /// params:
869
+ /// transport: 提供 tmux_endpoint 的传输后端
870
+ /// session_name: attach 目标 session
871
+ /// returns: 完整命令字符串;后端报不出 endpoint(非 tmux / 测试替身)时 None
872
+ /// boundary: 只生成文案、不执行;endpoint 是绝对路径用 -S、短名用 -L,由 attach_command_for_endpoint_session 分辨
873
+ /// ---
622
874
  pub(crate) fn attach_command_for_transport_session(
623
875
  transport: &dyn crate::transport::Transport,
624
876
  session_name: &SessionName,
@@ -633,6 +885,16 @@ pub(crate) fn attach_command_for_transport_session(
633
885
  /// socket — otherwise operators are told to attach to a socket where the
634
886
  /// session does not exist. Falls back to workspace-hash when state has no
635
887
  /// persisted endpoint.
888
+ /// ---
889
+ /// purpose: 生成 attach 到指定窗口的命令,优先跟随 state 里持久化的 endpoint,没有才回落 workspace 派生 socket
890
+ /// params:
891
+ /// workspace: 回落路径的来源
892
+ /// state: 运行期状态 JSON;从中读 tmux_endpoint / tmux_socket
893
+ /// session_name: attach 目标 session
894
+ /// window_name: attach 目标 window
895
+ /// returns: 完整命令字符串;两条路都算不出 socket 时 None
896
+ /// boundary: 只生成文案、不执行、不验证目标存在;endpoint 是绝对路径用 -S、短名用 -L
897
+ /// ---
636
898
  pub(crate) fn attach_command_for_runtime_state_or_workspace(
637
899
  workspace: &Path,
638
900
  state: Option<&serde_json::Value>,
@@ -656,6 +918,15 @@ pub(crate) fn attach_command_for_runtime_state_or_workspace(
656
918
  attach_command_for_workspace(workspace, session_name, window_name)
657
919
  }
658
920
 
921
+ /// ---
922
+ /// purpose: 同 attach_command_for_runtime_state_or_workspace,但只到 session 一级
923
+ /// params:
924
+ /// workspace: 回落路径的来源
925
+ /// state: 运行期状态 JSON
926
+ /// session_name: attach 目标 session
927
+ /// returns: 完整命令字符串;两条路都算不出 socket 时 None
928
+ /// boundary: 只生成文案、不执行、不验证 session 存在
929
+ /// ---
659
930
  pub(crate) fn attach_command_for_runtime_state_session_or_workspace(
660
931
  workspace: &Path,
661
932
  state: Option<&serde_json::Value>,
@@ -676,6 +947,15 @@ fn attach_command_for_endpoint_session(endpoint: &str, session_name: &SessionNam
676
947
  format!("tmux {flag} {endpoint} attach -t {}", session_name.as_str())
677
948
  }
678
949
 
950
+ /// ---
951
+ /// purpose: 为一组窗口批量生成 attach 命令
952
+ /// params:
953
+ /// workspace: 决定 socket 路径
954
+ /// session_name: attach 目标 session
955
+ /// window_names: 待生成的窗口名序列
956
+ /// returns: 生成成功的命令列表;算不出 socket 的条目被静默丢弃,故长度可能短于入参
957
+ /// boundary: 只生成文案、不执行;丢弃发生在整体层面(socket 算不出就一条都没有),调用方不能假定下标与入参对齐
958
+ /// ---
679
959
  pub(crate) fn attach_commands_for_windows<'a>(
680
960
  workspace: &Path,
681
961
  session_name: &SessionName,
@@ -689,6 +969,12 @@ pub(crate) fn attach_commands_for_windows<'a>(
689
969
  .collect()
690
970
  }
691
971
 
972
+ /// ---
973
+ /// purpose: 列出本机可能放 tmux socket 的根目录
974
+ /// returns: Unix 上为 /tmp/tmux-<uid> 加 TMPDIR/tmux-<uid>(排序去重);Windows 上为空
975
+ /// cfg: unix 读 geteuid 与 TMPDIR;not(unix) 返回空表示「本平台无此约定」
976
+ /// boundary: 只列约定目录,不验证目录存在、不列举里面的 socket(那是 tmux_socket_endpoints)
977
+ /// ---
692
978
  pub(crate) fn tmux_socket_roots() -> Vec<PathBuf> {
693
979
  // Batch 1: `/tmp/tmux-<uid>` root enumeration is Unix-only tmux
694
980
  // convention. On Windows return empty so the caller loops zero
@@ -711,6 +997,12 @@ pub(crate) fn tmux_socket_roots() -> Vec<PathBuf> {
711
997
  }
712
998
  }
713
999
 
1000
+ /// ---
1001
+ /// purpose: 枚举本机所有已存在的 tmux socket 路径
1002
+ /// returns: 各 socket 根下真正是 socket 文件的绝对路径,排序去重;Windows 上为空
1003
+ /// cfg: unix 才用 is_socket 过滤;not(unix) 直接跳过(该平台 socket 根为空)
1004
+ /// boundary: 只看文件类型,不连不握手——列出的 socket 可能属于已死的 server;不做任何删除
1005
+ /// ---
714
1006
  pub(crate) fn tmux_socket_endpoints() -> Vec<String> {
715
1007
  let mut endpoints = Vec::new();
716
1008
  for root in tmux_socket_roots() {
@@ -747,6 +1039,11 @@ pub(crate) fn tmux_socket_endpoints() -> Vec<String> {
747
1039
  endpoints
748
1040
  }
749
1041
 
1042
+ /// ---
1043
+ /// purpose: 从当前进程所在的 tmux 环境($TMUX)反推它连的是哪个 socket
1044
+ /// returns: $TMUX 首段是绝对路径时返回该路径;$TMUX 未设 / 为空 / 首段非绝对路径时 None
1045
+ /// boundary: 读进程环境,只在自己就跑在 tmux 里时有意义;不校验该 socket 仍可用
1046
+ /// ---
750
1047
  pub(crate) fn socket_name_from_tmux_env() -> Option<String> {
751
1048
  let tmux = std::env::var("TMUX")
752
1049
  .ok()
@@ -1041,6 +1338,38 @@ impl TmuxBackend {
1041
1338
  Err(subprocess_error(argv, output))
1042
1339
  }
1043
1340
  }
1341
+
1342
+ /// 任意非 0 tmux mode。best-effort,查询失败 → None。
1343
+ ///
1344
+ /// Cherry-pick 866939b1 冲突裁定:本线没有 `inject_journal` 模块(modify/delete),
1345
+ /// 不复活整份 journal。`pane_mode_from_raw` 识别 copy/tree/view/client;
1346
+ /// 一切 Some(mode) 都算在 mode(头注释「非 0 先 cancel」)。
1347
+ fn pane_in_mode(&self, target: &Target) -> Option<bool> {
1348
+ let raw = self.query(target, PaneField::PaneMode).ok().flatten()?;
1349
+ Some(pane_mode_from_raw(Some(raw)).is_some())
1350
+ }
1351
+
1352
+ fn pane_mode(&self, target: &Target) -> Option<PaneMode> {
1353
+ let raw = self.query(target, PaneField::PaneMode).ok().flatten()?;
1354
+ pane_mode_from_raw(Some(raw))
1355
+ }
1356
+
1357
+ /// 发 Enter 前归零接收态。A1/A3/A4/A6 唯一入口。
1358
+ ///
1359
+ /// 1. 非 0 pane_mode → `tmux_cancel_mode_argv`(按真实 mode 分派,不是硬编码 Copy)
1360
+ /// cancel 失败不 fail 提交。E55:不送 Escape/C-c。
1361
+ ///
1362
+ /// 用户裁定 2026-08-23:**不发 ESC,也不发 ESC 的替代品**(含字面 CSI 201~)。
1363
+ /// 发 ESC/CSI 的唯一好处是让消息当场直接上屏;本项目只要求「下一次工具调用时能上屏」
1364
+ /// ⇒ 不存在需要它才能满足的场景,发它没有收益,只有污染 composer 输入的风险。
1365
+ fn prepare_pane_for_submit(&self, target: &Target, close_bracketed_paste: bool) {
1366
+ let _ = close_bracketed_paste;
1367
+ let pane = pane_from_target(target);
1368
+ if let Some(mode) = self.pane_mode(target) {
1369
+ let argv = crate::transport::tmux_cancel_mode_argv(&pane, mode);
1370
+ let _ = self.run_inject_stage(&argv, InjectStage::Submit);
1371
+ }
1372
+ }
1044
1373
  }
1045
1374
 
1046
1375
  fn subprocess_error(argv: Vec<String>, output: CommandOutput) -> TransportError {
@@ -1128,6 +1457,84 @@ fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerificatio
1128
1457
  /// U1 #7: the exact delivery-token marker a token payload carries
1129
1458
  /// (`[team-agent-token:<id>]`). Use the full marker, not only the prefix, so an old
1130
1459
  /// scrollback token cannot verify a new message.
1460
+ /// Measured cursor-agent 2026.08.11 floor: text and first Enter ≥1s apart
1461
+ /// (phase0 M2-5/M2-6; `.team/scripts/cursor_send.sh`).
1462
+ /// Grok uses the same 1s floor (悬案B:零地板时 Enter 落在折叠窗口内被吞).
1463
+ pub const CURSOR_PASTE_TO_SUBMIT_FLOOR: Duration = Duration::from_secs(1);
1464
+
1465
+ thread_local! {
1466
+ static PASTE_TO_SUBMIT_FLOOR: Cell<Duration> = const { Cell::new(Duration::ZERO) };
1467
+ static CURSOR_SINGLE_ENTER: Cell<bool> = const { Cell::new(false) };
1468
+ }
1469
+
1470
+ /// Run `f` with a paste→Enter floor. Delivery sets 1s for CursorAgent and Grok.
1471
+ /// Tests pass a small non-zero Duration to cover the sleep branch.
1472
+ /// Default is ZERO — claude/codex and unset callers pay nothing.
1473
+ /// This is not TEAM_AGENT_TEST_TMP (that var is a path, always set in seats).
1474
+ /// ---
1475
+ /// purpose: 在 f 执行期间设定「粘贴到回车之间至少等多久」的地板,退出时恢复原值
1476
+ /// params:
1477
+ /// floor: 本次生效的最小间隔;ZERO 表示不等
1478
+ /// f: 在该地板下执行的闭包
1479
+ /// returns: 原样返回 f 的返回值
1480
+ /// boundary: thread_local 作用域,不跨线程;只设阈值,真正的等待发生在 sleep_remaining_paste_to_submit_floor;默认 ZERO,只有 delivery 对 cursor 才设 1s
1481
+ /// ---
1482
+ pub fn with_paste_to_submit_floor<R>(floor: Duration, f: impl FnOnce() -> R) -> R {
1483
+ PASTE_TO_SUBMIT_FLOOR.with(|cell| {
1484
+ let previous = cell.replace(floor);
1485
+ let result = f();
1486
+ cell.set(previous);
1487
+ result
1488
+ })
1489
+ }
1490
+
1491
+ /// ---
1492
+ /// purpose: 读当前线程生效的粘贴到回车地板
1493
+ /// returns: 当前地板;未设过则为 ZERO
1494
+ /// boundary: 只读不改;ZERO 表示「不等」,不是「未配置」——两者在此不做区分
1495
+ /// ---
1496
+ pub(crate) fn current_paste_to_submit_floor() -> Duration {
1497
+ PASTE_TO_SUBMIT_FLOOR.with(Cell::get)
1498
+ }
1499
+
1500
+ /// ---
1501
+ /// purpose: cursor 注入单回车闸(delivery 仅对 CursorAgent 打开)
1502
+ /// contract: 默认关;打开后重试 Enter 仅当 token 仍在输入区且无 busy
1503
+ /// boundary: 不改 claude/grok/codex 默认重试;测试隔离下默认关
1504
+ /// ---
1505
+ pub fn with_cursor_single_enter<R>(enabled: bool, f: impl FnOnce() -> R) -> R {
1506
+ CURSOR_SINGLE_ENTER.with(|cell| {
1507
+ let previous = cell.replace(enabled);
1508
+ let result = f();
1509
+ cell.set(previous);
1510
+ result
1511
+ })
1512
+ }
1513
+
1514
+ /// ---
1515
+ /// purpose: 读当前线程的 cursor 单回车闸开关
1516
+ /// returns: 打开为 true;默认关
1517
+ /// boundary: 只读不改;开关只影响重按 Enter 的判据来源,不改变首次 Enter
1518
+ /// ---
1519
+ pub(crate) fn cursor_single_enter_enabled() -> bool {
1520
+ CURSOR_SINGLE_ENTER.with(Cell::get)
1521
+ }
1522
+
1523
+ /// ---
1524
+ /// purpose: 补齐粘贴到回车之间还差的等待时间
1525
+ /// params:
1526
+ /// pasted_at: 粘贴完成的时刻
1527
+ /// floor: 要求的最小间隔
1528
+ /// returns: 无返回值;已过地板则立即返回,否则阻塞剩余时长
1529
+ /// boundary: 阻塞当前线程;只保证「不早于」,不保证 TUI 真的处理完粘贴
1530
+ /// ---
1531
+ pub(crate) fn sleep_remaining_paste_to_submit_floor(pasted_at: Instant, floor: Duration) {
1532
+ let remain = floor.saturating_sub(pasted_at.elapsed());
1533
+ if !remain.is_zero() {
1534
+ std::thread::sleep(remain);
1535
+ }
1536
+ }
1537
+
1131
1538
  fn payload_token_marker(payload: &InjectPayload) -> Option<&str> {
1132
1539
  let text = payload.text()?;
1133
1540
  let start = text.find("[team-agent-token:")?;
@@ -1239,6 +1646,326 @@ fn token_in_bottom_n(text: &str, marker: &str, n: usize) -> bool {
1239
1646
  .any(|line| line.contains(marker))
1240
1647
  }
1241
1648
 
1649
+ /// purpose: token 在本轮注入中的三态观测
1650
+ /// contract:
1651
+ /// - Visible: 当前 capture 可见
1652
+ /// - Gone: 本轮见过,当前不可见(「见过又消失」才是 token 正信号)
1653
+ /// - NeverSeen: 本轮从未出现 — 不得把缺席写成 consumed
1654
+ /// boundary: 观测枚举,不是 SubmitVerification 裁定
1655
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1656
+ pub(crate) enum TokenSighting {
1657
+ Visible,
1658
+ Gone,
1659
+ NeverSeen,
1660
+ }
1661
+
1662
+ /// purpose: composer 区折叠占位符(含可选 #N 或 grok 行数)
1663
+ /// contract: 只看底部 n 非空行;id 来自 `pasted text #N` / `pasted content #N`;
1664
+ /// grok `[Pasted: N lines]` 无 #N,用 line_count 当身份,不得把「有占位符」当成同一次
1665
+ /// boundary: scrollback 里的旧占位符不算
1666
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1667
+ pub(crate) struct ComposerPastedPrompt {
1668
+ pub literal: &'static str,
1669
+ pub id: Option<u32>,
1670
+ pub line_count: Option<u32>,
1671
+ pub from_bottom: u32,
1672
+ }
1673
+
1674
+ /// purpose: 本次粘贴在 composer 上的可锁定身份
1675
+ /// contract: HashId = claude/codex `#N`;GrokLineCount = grok `[Pasted: N lines|N KB]` 的 N
1676
+ /// boundary: 无 #N 时禁止用「任意占位符」冒充 HashId
1677
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1678
+ pub(crate) enum PasteLatch {
1679
+ HashId(u32),
1680
+ GrokLineCount(u32),
1681
+ }
1682
+
1683
+ fn parse_pasted_hash_id(lower_line: &str) -> Option<u32> {
1684
+ for prefix in ["pasted text #", "pasted content #"] {
1685
+ if let Some(idx) = lower_line.find(prefix) {
1686
+ let rest = &lower_line[idx + prefix.len()..];
1687
+ let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1688
+ if digits.is_empty() {
1689
+ continue;
1690
+ }
1691
+ if let Ok(id) = digits.parse::<u32>() {
1692
+ return Some(id);
1693
+ }
1694
+ }
1695
+ }
1696
+ None
1697
+ }
1698
+
1699
+ fn parse_grok_pasted_line_count(lower_line: &str) -> Option<u32> {
1700
+ // grok: `[pasted: 42 lines]` or `[pasted: 13 kb]` — 冒号紧跟 pasted,没有 `#N`
1701
+ let idx = lower_line.find("pasted:")?;
1702
+ let rest = lower_line.get(idx.saturating_add("pasted:".len())..)?;
1703
+ let rest = rest.trim_start();
1704
+ let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1705
+ if digits.is_empty() {
1706
+ return None;
1707
+ }
1708
+ let after = rest.get(digits.len()..)?.trim_start();
1709
+ let unit_ok = after.starts_with("lines")
1710
+ || after.starts_with("line")
1711
+ || after.starts_with("kb")
1712
+ || after.starts_with("mb")
1713
+ || after.starts_with('b');
1714
+ if unit_ok {
1715
+ digits.parse().ok()
1716
+ } else {
1717
+ None
1718
+ }
1719
+ }
1720
+
1721
+ fn pasted_literal_in_line(lower_line: &str) -> Option<&'static str> {
1722
+ if lower_line.contains("pasted content") {
1723
+ Some("pasted content")
1724
+ } else if lower_line.contains("pasted text") {
1725
+ Some("pasted text")
1726
+ } else if parse_grok_pasted_line_count(lower_line).is_some() {
1727
+ Some("pasted:")
1728
+ } else {
1729
+ None
1730
+ }
1731
+ }
1732
+
1733
+ /// ---
1734
+ /// purpose: 只在 composer(底部 n 非空行)认折叠占位符
1735
+ /// params:
1736
+ /// text: 已规范化的 pane 尾文本
1737
+ /// n: 只看倒数 n 个非空行;超出这一窗口的占位符视为 transcript 残留,不算
1738
+ /// returns: 命中则给出字面量、可选编号、可选 grok 行数、距底行数;否则 None
1739
+ /// contract: 返回字面量 + 可选编号;没有占位符则 None
1740
+ /// boundary: 不替代 token 路径;编号缺失时调用方必须保守,不得报已消费
1741
+ /// ---
1742
+ pub(crate) fn pasted_prompt_in_composer(text: &str, n: usize) -> Option<ComposerPastedPrompt> {
1743
+ let mut from_bottom = 0u32;
1744
+ for line in text
1745
+ .lines()
1746
+ .rev()
1747
+ .filter(|line| !line.trim().is_empty())
1748
+ .take(n)
1749
+ {
1750
+ let lower = line.to_ascii_lowercase();
1751
+ if let Some(literal) = pasted_literal_in_line(&lower) {
1752
+ return Some(ComposerPastedPrompt {
1753
+ literal,
1754
+ id: parse_pasted_hash_id(&lower),
1755
+ line_count: parse_grok_pasted_line_count(&lower),
1756
+ from_bottom,
1757
+ });
1758
+ }
1759
+ from_bottom = from_bottom.saturating_add(1);
1760
+ }
1761
+ None
1762
+ }
1763
+
1764
+ fn latch_paste(text: &str, current: Option<PasteLatch>) -> Option<PasteLatch> {
1765
+ if current.is_some() {
1766
+ return current;
1767
+ }
1768
+ let prompt = pasted_prompt_in_composer(text, 15)?;
1769
+ if let Some(id) = prompt.id {
1770
+ return Some(PasteLatch::HashId(id));
1771
+ }
1772
+ prompt.line_count.map(PasteLatch::GrokLineCount)
1773
+ }
1774
+
1775
+ fn token_sighting(token_now: bool, token_ever_visible: bool) -> TokenSighting {
1776
+ if token_now {
1777
+ TokenSighting::Visible
1778
+ } else if token_ever_visible {
1779
+ TokenSighting::Gone
1780
+ } else {
1781
+ TokenSighting::NeverSeen
1782
+ }
1783
+ }
1784
+
1785
+ fn consumption_from_placeholder(text: &str, tracked: Option<PasteLatch>) -> Option<bool> {
1786
+ let prompt = pasted_prompt_in_composer(text, 15);
1787
+ match (tracked, prompt) {
1788
+ (Some(PasteLatch::HashId(id)), Some(p)) if p.id == Some(id) => Some(false),
1789
+ (Some(PasteLatch::HashId(_)), Some(p)) if p.id.is_some() => Some(true),
1790
+ (Some(PasteLatch::HashId(_)), Some(_)) => Some(false),
1791
+ (Some(PasteLatch::HashId(_)), None) => Some(true),
1792
+ (Some(PasteLatch::GrokLineCount(n)), Some(p)) if p.line_count == Some(n) => Some(false),
1793
+ // 不同 N:不能把「上一贴走了」写成已消费(无 #N,换行数 ≠ 同一次)
1794
+ (Some(PasteLatch::GrokLineCount(_)), Some(_)) => Some(false),
1795
+ (Some(PasteLatch::GrokLineCount(_)), None) => Some(true),
1796
+ (None, _) => Some(false),
1797
+ }
1798
+ }
1799
+
1800
+ /// ---
1801
+ /// purpose: 无身份时禁止把「未证实」升级成连按回车
1802
+ /// params:
1803
+ /// text: 最近一次 pane 尾文本
1804
+ /// marker: 本条消息的投递 token 完整标记
1805
+ /// tracked: 本次粘贴已锁定的占位符身份(编号或 grok 行数);None 表示没锁到身份
1806
+ /// returns: true 才允许再按一次 Enter
1807
+ /// contract: 只有 token 仍在 composer 底 15 非空行,或锁定的占位符身份仍在,才重按
1808
+ /// boundary: 不重粘文本;不把不同 grok 行数当成可重按的同一次;tracked 为 None 时占位符路径一律不重按(token 仍在底 15 非空行的重按与 tracked 无关)
1809
+ /// ---
1810
+ pub(crate) fn should_resubmit_enter(text: &str, marker: &str, tracked: Option<PasteLatch>) -> bool {
1811
+ if token_in_bottom_n(text, marker, 15) {
1812
+ return true;
1813
+ }
1814
+ let Some(prompt) = pasted_prompt_in_composer(text, 15) else {
1815
+ return false;
1816
+ };
1817
+ match tracked {
1818
+ Some(PasteLatch::HashId(id)) => prompt.id == Some(id),
1819
+ Some(PasteLatch::GrokLineCount(n)) => prompt.line_count == Some(n),
1820
+ None => false,
1821
+ }
1822
+ }
1823
+
1824
+ fn composer_joined(text: &str, n: usize) -> String {
1825
+ let mut lines: Vec<&str> = text
1826
+ .lines()
1827
+ .rev()
1828
+ .filter(|line| !line.trim().is_empty())
1829
+ .take(n)
1830
+ .collect();
1831
+ lines.reverse();
1832
+ lines.concat()
1833
+ }
1834
+
1835
+ /// ---
1836
+ /// purpose: 本次注入身份是否还在 composer(复用 token / #N / grok 行数,不认提示符皮肤)
1837
+ /// contract: 单行 token、折行拼接 token、或锁定的 PasteLatch 仍在底部 ⇒ true
1838
+ /// boundary: payload 里的 ❯/`composer>`/`>` 不算身份;无身份不得据此连按回车
1839
+ /// ---
1840
+ pub(crate) fn this_paste_identity_in_composer(
1841
+ text: &str,
1842
+ marker: &str,
1843
+ tracked: Option<PasteLatch>,
1844
+ ) -> bool {
1845
+ should_resubmit_enter(text, marker, tracked) || composer_joined(text, 15).contains(marker)
1846
+ }
1847
+
1848
+ /// ---
1849
+ /// purpose: Unverified 之后要不要补一颗 C-m(本次身份仍在,不是提示符长什么样)
1850
+ /// contract: 无 busy 且本次身份仍在 composer ⇒ true;达上限由调用方停
1851
+ /// boundary: consumed=None 不在这里决定(调用方不补);不重粘;不把 payload 提示符字符当守卫
1852
+ /// ---
1853
+ pub(crate) fn should_resend_enter_after_unverified(
1854
+ text: &str,
1855
+ marker: &str,
1856
+ tracked: Option<PasteLatch>,
1857
+ ) -> bool {
1858
+ if provider_busy_signal_in_tail(text) {
1859
+ return false;
1860
+ }
1861
+ this_paste_identity_in_composer(text, marker, tracked)
1862
+ }
1863
+
1864
+ /// ---
1865
+ /// purpose: Unverified 补发 B 是否该介入——只补 A 的 should_resubmit_enter 看不到的折行缺口
1866
+ /// params:
1867
+ /// text: 最近一次 pane 尾文本
1868
+ /// marker: 本条消息的投递 token 完整标记
1869
+ /// tracked: 本次粘贴已锁定的占位符身份(编号或 grok 行数);None 表示没锁到身份
1870
+ /// returns: true 才允许 B 再按一次 Enter
1871
+ /// contract: !busy 且 this_paste_identity_in_composer 且 !should_resubmit_enter ⇒ true
1872
+ /// boundary: A 已因 latch / 底15 单行 token 为真时不介入(含 A 已打满 cap);不重粘;consumed=None 不在这里决定
1873
+ /// ---
1874
+ pub(crate) fn should_resend_unverified_wrap_gap(
1875
+ text: &str,
1876
+ marker: &str,
1877
+ tracked: Option<PasteLatch>,
1878
+ ) -> bool {
1879
+ if should_resubmit_enter(text, marker, tracked) {
1880
+ return false;
1881
+ }
1882
+ should_resend_enter_after_unverified(text, marker, tracked)
1883
+ }
1884
+
1885
+ /// ---
1886
+ /// purpose: cursor 重按 Enter 的核实:token 仍在输入框,且回合未在进行
1887
+ /// contract: busy ⇒ 不重按;token 不在底部 5 非空行 ⇒ 不重按
1888
+ /// boundary: 不把 transcript 里的 pasted #N 当输入框;不替代 claude/grok 的 should_resubmit_enter
1889
+ /// ---
1890
+ pub(crate) fn should_resubmit_enter_cursor(text: &str, marker: &str) -> bool {
1891
+ if provider_busy_signal_in_tail(text) {
1892
+ return false;
1893
+ }
1894
+ token_in_bottom_n(text, marker, 5)
1895
+ }
1896
+
1897
+ /// ---
1898
+ /// purpose: Phase 1 判定 TUI 侧注入已完成、可以按回车
1899
+ /// params:
1900
+ /// text: 最近一次 pane 尾文本
1901
+ /// marker: 本条消息的投递 token 完整标记
1902
+ /// returns: true 表示可以进入提交阶段
1903
+ /// contract: composer 已有折叠占位符 ⇒ 完成;短贴 token 可见且不是待折叠高块 ⇒ 完成
1904
+ /// boundary: token 出现在未折叠的高块里不算完成(按早了);只判「能不能按」,不判「按了有没有成」
1905
+ /// ---
1906
+ pub(crate) fn paste_ready_for_enter(text: &str, marker: &str) -> bool {
1907
+ if pasted_prompt_in_composer(text, 15).is_some() {
1908
+ return true;
1909
+ }
1910
+ text.contains(marker) && !raw_multiline_still_unfolded(text, marker)
1911
+ }
1912
+
1913
+ fn raw_multiline_still_unfolded(text: &str, marker: &str) -> bool {
1914
+ if pasted_prompt_in_composer(text, 15).is_some() {
1915
+ return false;
1916
+ }
1917
+ if !text.contains(marker) {
1918
+ return false;
1919
+ }
1920
+ text.lines().filter(|line| !line.trim().is_empty()).count() > 15
1921
+ }
1922
+
1923
+ /// ---
1924
+ /// purpose: 把 token 三态 + 本次 #N / grok 占位符合成消费判定
1925
+ /// params:
1926
+ /// text: 最近一次 pane 尾文本
1927
+ /// marker: 本条消息的投递 token 完整标记
1928
+ /// token_ever_visible: 本次注入期间是否曾在 pane 上见过该 token
1929
+ /// tracked: 本次粘贴锁定的占位符身份;None 表示没锁到
1930
+ /// returns: Some(true) 已消费;Some(false) 未消费;本函数不产生 None
1931
+ /// contract:
1932
+ /// Some(true)=已消费;Some(false)=未消费(含 NeverSeen 且无正信号 → 未证实走 false)
1933
+ /// Gone 必须先复核 PasteLatch 身份:占位符仍在 composer ⇒ 未消费
1934
+ /// None 不由本函数产生;调用方在 capture 失败时保持 None,必须落到 Unverified
1935
+ /// boundary: 不修 BUSY 入队;Working 信号由调用方在 Some(false) 之后看;None 不得当成功
1936
+ /// ---
1937
+ pub(crate) fn consumption_from_capture(
1938
+ text: &str,
1939
+ marker: &str,
1940
+ token_ever_visible: bool,
1941
+ tracked: Option<PasteLatch>,
1942
+ ) -> Option<bool> {
1943
+ let token_now = token_in_bottom_n(text, marker, 15);
1944
+ match token_sighting(token_now, token_ever_visible) {
1945
+ TokenSighting::Visible => Some(false),
1946
+ TokenSighting::Gone => gone_consumption(text, tracked),
1947
+ TokenSighting::NeverSeen => consumption_from_placeholder(text, tracked),
1948
+ }
1949
+ }
1950
+
1951
+ /// ---
1952
+ /// purpose: Gone 分支在判已消费前复核 composer 占位符(PasteLatch 身份)
1953
+ /// contract: 锁定身份仍在,或未锁定但 grok `[Pasted: …]` 仍在 ⇒ Some(false)
1954
+ /// boundary: 空 composer + 无 grok 占位符保持 Some(true)(claude 短贴路径不变)
1955
+ /// ---
1956
+ fn gone_consumption(text: &str, tracked: Option<PasteLatch>) -> Option<bool> {
1957
+ if tracked.is_some() {
1958
+ return match consumption_from_placeholder(text, tracked) {
1959
+ Some(false) => Some(false),
1960
+ _ => Some(true),
1961
+ };
1962
+ }
1963
+ match pasted_prompt_in_composer(text, 15) {
1964
+ Some(p) if p.literal == "pasted:" => Some(false),
1965
+ _ => Some(true),
1966
+ }
1967
+ }
1968
+
1242
1969
  fn marker_position_from_bottom(text: &str, marker: &str) -> Option<u32> {
1243
1970
  let mut from_bottom = 0u32;
1244
1971
  for line in text.lines().rev().filter(|line| !line.trim().is_empty()) {
@@ -1259,6 +1986,7 @@ fn provider_busy_signal_in_tail(text: &str) -> bool {
1259
1986
  let lower = line.to_ascii_lowercase();
1260
1987
  lower.contains("working")
1261
1988
  || lower.contains("thinking")
1989
+ || lower.contains("processing")
1262
1990
  || lower.contains("esc to interrupt")
1263
1991
  || line.contains('●')
1264
1992
  || line.contains('⏳')
@@ -1274,9 +2002,52 @@ fn provider_busy_signal_in_tail(text: &str) -> bool {
1274
2002
  || line.contains('⠏')
1275
2003
  || line.contains('✶')
1276
2004
  || line.contains('✢')
2005
+ || line.contains('✻')
2006
+ || line.contains('✽')
2007
+ || line.contains('✳')
1277
2008
  })
1278
2009
  }
1279
2010
 
2011
+ /// ---
2012
+ /// purpose: 入箱 vs 开跑(G4)。给定最近一次 pane 尾,回答席位是否因此开跑。
2013
+ /// params:
2014
+ /// text: 最近一次 pane 尾文本
2015
+ /// marker: 本条消息的投递 token 完整标记;None 表示本次载荷没有 token 可查
2016
+ /// returns: 三态观测值,绝不返回 NotRequired(那是空载荷路径由调用方直接给的)
2017
+ /// contract:
2018
+ /// - LeaderNewTurnBoundaryVerified = 底部 busy 信号
2019
+ /// - LeaderNewTurnBoundaryMissing = composer 仍有本次粘贴(占位符或 token)且无 busy
2020
+ /// - NotYetObserved = 其余(含 composer 已空但无 busy)→ 不知道,不得报开跑
2021
+ /// boundary:
2022
+ /// - 不是 SubmitVerification / 不是投递闸门;不许用过了 N 秒来判
2023
+ /// - busy 判据是底 15 非空行的宽子串匹配,正文或 transcript 残留可让它假阳,判定强度到此为止
2024
+ /// ---
2025
+ pub(crate) fn observe_turn_from_capture(text: &str, marker: Option<&str>) -> TurnVerification {
2026
+ if provider_busy_signal_in_tail(text) {
2027
+ return TurnVerification::LeaderNewTurnBoundaryVerified;
2028
+ }
2029
+ let paste_still = pasted_prompt_in_composer(text, 15).is_some();
2030
+ let token_still = marker
2031
+ .map(|m| token_in_bottom_n(text, m, 15))
2032
+ .unwrap_or(false);
2033
+ if paste_still || token_still {
2034
+ return TurnVerification::LeaderNewTurnBoundaryMissing;
2035
+ }
2036
+ TurnVerification::NotYetObserved
2037
+ }
2038
+
2039
+ fn turn_verification_for_payload(
2040
+ payload: &InjectPayload,
2041
+ last_text: Option<&str>,
2042
+ ) -> TurnVerification {
2043
+ match payload {
2044
+ InjectPayload::Empty => TurnVerification::NotRequired,
2045
+ InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => {
2046
+ observe_turn_from_capture(last_text.unwrap_or(""), payload_token_marker(payload))
2047
+ }
2048
+ }
2049
+ }
2050
+
1280
2051
  fn submit_attempt_observation(
1281
2052
  attempt_index: u32,
1282
2053
  captured: &CapturedText,
@@ -1284,12 +2055,24 @@ fn submit_attempt_observation(
1284
2055
  elapsed_ms: u64,
1285
2056
  ) -> SubmitAttemptObservation {
1286
2057
  let marker_position = marker.and_then(|m| marker_position_from_bottom(&captured.text, m));
2058
+ // marker 在时也走 pasted_prompt_match:折叠占位符是编排长粘贴的正信号,
2059
+ // 不能只在无 token 载荷上才认。matched 仍表示「composer 里还有待提交信号」。
1287
2060
  let (matched, matched_literal, where_in_tail) = if let Some(marker) = marker {
1288
- (
1289
- token_in_bottom_n(&captured.text, marker, 15),
1290
- marker_position.map(|_| marker.to_string()),
1291
- marker_position,
1292
- )
2061
+ if token_in_bottom_n(&captured.text, marker, 15) {
2062
+ (
2063
+ true,
2064
+ marker_position.map(|_| marker.to_string()),
2065
+ marker_position,
2066
+ )
2067
+ } else if let Some((literal, where_in_tail)) = pasted_prompt_match(&captured.text) {
2068
+ (
2069
+ pasted_prompt_in_composer(&captured.text, 15).is_some(),
2070
+ Some(literal.to_string()),
2071
+ Some(where_in_tail),
2072
+ )
2073
+ } else {
2074
+ (false, None, None)
2075
+ }
1293
2076
  } else if let Some((literal, where_in_tail)) = pasted_prompt_match(&captured.text) {
1294
2077
  (true, Some(literal.to_string()), Some(where_in_tail))
1295
2078
  } else {
@@ -1311,14 +2094,7 @@ fn submit_attempt_observation(
1311
2094
  /// appears in the bottom N non-empty lines. Narrower than the full-Tail(80) check
1312
2095
  /// that caused scrollback ghost matches (E50 defect B).
1313
2096
  fn pasted_prompt_in_bottom(text: &str, n: usize) -> bool {
1314
- text.lines()
1315
- .rev()
1316
- .filter(|line| !line.trim().is_empty())
1317
- .take(n)
1318
- .any(|line| {
1319
- let lower = line.to_ascii_lowercase();
1320
- lower.contains("pasted content") || lower.contains("pasted text")
1321
- })
2097
+ pasted_prompt_in_composer(text, n).is_some()
1322
2098
  }
1323
2099
 
1324
2100
  /// E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断): factor `capture_has_pasted_content_prompt`
@@ -1335,12 +2111,26 @@ fn pasted_prompt_in_bottom(text: &str, n: usize) -> bool {
1335
2111
  /// where it remains in the last 80 lines. The current matcher cannot
1336
2112
  /// distinguish that from a live placeholder; this fn surfaces the data
1337
2113
  /// the operator needs to see (PR-2 will USE it to fix the criterion).
2114
+ /// ---
2115
+ /// purpose: 在整段 pane 尾里找折叠占位符字面量,并给出它距底部多少非空行
2116
+ /// params:
2117
+ /// text: pane 尾文本;不限窗口,整段都找
2118
+ /// returns: Some((命中的字面量, 距底非空行数));无命中则 None;字面量只出现在被裁掉的空白行时保守记为 0
2119
+ /// boundary:
2120
+ /// - 只做取证不做判定:距底行数大说明多半在 transcript 而非 composer,但本函数不替调用方下结论
2121
+ /// - 与 pasted_prompt_in_composer 不同——后者限定底部窗口,是判定用;本函数不限窗口,是诊断用
2122
+ /// ---
1338
2123
  pub(crate) fn pasted_prompt_match(text: &str) -> Option<(&'static str, u32)> {
1339
2124
  let lower = text.to_ascii_lowercase();
1340
2125
  let lit = if lower.contains("pasted content") {
1341
2126
  "pasted content"
1342
2127
  } else if lower.contains("pasted text") {
1343
2128
  "pasted text"
2129
+ } else if text
2130
+ .lines()
2131
+ .any(|line| parse_grok_pasted_line_count(&line.to_ascii_lowercase()).is_some())
2132
+ {
2133
+ "pasted:"
1344
2134
  } else {
1345
2135
  return None;
1346
2136
  };
@@ -1351,7 +2141,13 @@ pub(crate) fn pasted_prompt_match(text: &str) -> Option<(&'static str, u32)> {
1351
2141
  .collect();
1352
2142
  let mut from_bottom: u32 = 0;
1353
2143
  for line in non_empty.iter().rev() {
1354
- if line.to_ascii_lowercase().contains(lit) {
2144
+ let line_lower = line.to_ascii_lowercase();
2145
+ let hit = if lit == "pasted:" {
2146
+ parse_grok_pasted_line_count(&line_lower).is_some()
2147
+ } else {
2148
+ line_lower.contains(lit)
2149
+ };
2150
+ if hit {
1355
2151
  return Some((lit, from_bottom));
1356
2152
  }
1357
2153
  from_bottom = from_bottom.saturating_add(1);
@@ -1369,6 +2165,17 @@ pub(crate) fn pasted_prompt_match(text: &str) -> Option<(&'static str, u32)> {
1369
2165
  /// 4. Cap at ~1200 bytes (UTF-8 safe truncation).
1370
2166
  /// Returns `(excerpt, line_count)`. Designed to be CHEAP — no regex crate
1371
2167
  /// dependency, simple byte scanning.
2168
+ /// ---
2169
+ /// purpose: 把 pane 抓屏加工成可安全写进 events.jsonl 的摘录
2170
+ /// params:
2171
+ /// raw: 原始抓屏文本
2172
+ /// tail_lines: 只保留倒数这么多非空行
2173
+ /// returns: (脱敏并截断后的摘录, 实际保留的行数)
2174
+ /// boundary:
2175
+ /// - 只做四件事:剥 ANSI、取尾部、脱敏常见密钥形状、按 1200 字节在字符边界截断
2176
+ /// - 脱敏是形状匹配,不是完备保证;非常见形状的凭据不会被识别
2177
+ /// - 输出仍是消息正文的一部分,只落本地事件文件,不外传
2178
+ /// ---
1372
2179
  pub(crate) fn scrub_pane_excerpt(raw: &str, tail_lines: usize) -> (String, u32) {
1373
2180
  let stripped = strip_ansi_escapes_inplace(raw);
1374
2181
  let lines: Vec<&str> = stripped
@@ -1530,6 +2337,11 @@ const PASTED_CONTENT_SUBMIT_ATTEMPTS: u32 = 3;
1530
2337
  /// submission when the first Enter was consumed but our readback was slow.
1531
2338
  const POST_SUBMIT_CONSUMPTION_ATTEMPTS: u32 = 3;
1532
2339
  const POST_SUBMIT_CONSUMPTION_POLL_MS: u64 = 60;
2340
+ /// post-Enter `capture()` 失败时只重读、不重按。计入 `InjectReport.attempts`。
2341
+ const POST_SUBMIT_CAPTURE_RETRY: u32 = 4;
2342
+ const POST_SUBMIT_CAPTURE_RETRY_MS: u64 = 20;
2343
+ /// Unverified 之后、本次身份仍在 composer 时,只补这一颗回车。
2344
+ const UNVERIFIED_COMPOSER_RESEND_MAX: u32 = 1;
1533
2345
 
1534
2346
  /// E46 (0.3.24 bug#5, C5 provider-agnostic detector): the pane's input region
1535
2347
  /// is "consumed" when the token text that was just visible BEFORE the Enter
@@ -1615,6 +2427,15 @@ pub const LEADER_PROVIDER_EXIT_MARKER_SUFFIX: &str = "exited with";
1615
2427
  /// 0.4.x (CR R6): build the leader exit marker text for `provider_label`.
1616
2428
  /// Used by both the shell wrapper (printf source) and the health check
1617
2429
  /// (capture substring) so they cannot drift.
2430
+ /// ---
2431
+ /// purpose: 单一来源地拼出 leader pane 的 provider 退出标记文本
2432
+ /// params:
2433
+ /// provider_label: 人类可读的 provider 名,原样嵌入标记
2434
+ /// returns: 前缀 + provider 名 + 后缀 拼成的标记(不含退出码,退出码由 shell 的 printf 补)
2435
+ /// boundary:
2436
+ /// - 写标记的 shell wrapper 与读标记的健康检查必须都用这一个函数,禁止各写一份字面量
2437
+ /// - 这是内容信号:pane 里出现同样文本(如 cat 日志)就会误触发,判定强度到此为止
2438
+ /// ---
1618
2439
  pub fn leader_provider_exit_marker(provider_label: &str) -> String {
1619
2440
  format!(
1620
2441
  "{LEADER_PROVIDER_EXIT_MARKER_PREFIX} {provider_label} {LEADER_PROVIDER_EXIT_MARKER_SUFFIX}"
@@ -1633,6 +2454,14 @@ pub fn leader_provider_exit_marker(provider_label: &str) -> String {
1633
2454
  ///
1634
2455
  /// Returns `(session_name, Some(pane_id))` when the ambient tmux
1635
2456
  /// responds, or `None` if `$TMUX` is unset / `display-message` fails.
2457
+ /// ---
2458
+ /// purpose: 探出当前 leader 进程正待在哪个环境 tmux 的哪个 session / pane 里
2459
+ /// returns: Some((session 名, 可选 pane 标识));TMUX 与 TMUX_PANE 都未设,或所有探测命令都失败或解析不出时 None(仅 TMUX_PANE 在设时仍会带 -t 探测)
2460
+ /// boundary:
2461
+ /// - 本文件唯一允许绕过 socket 绑定后端、直接 Command::new("tmux") 的例外,因为它的任务就是发现「我在哪」
2462
+ /// - 依次试 TMUX_PANE 定位与无 -t 的当前 pane,取第一个能解析出的结果;失败静默跳到下一条,不报错
2463
+ /// - 只读不写,不 attach、不改任何 tmux 状态
2464
+ /// ---
1636
2465
  pub fn probe_ambient_leader_pane_info() -> Option<(String, Option<String>)> {
1637
2466
  let pane = std::env::var("TMUX_PANE")
1638
2467
  .ok()
@@ -1712,6 +2541,15 @@ pub const WORKER_PROVIDER_EXIT_MARKER_SUFFIX: &str = "exited with";
1712
2541
  /// 0.5.39 Slice 2: build the worker exit marker text for `provider_label`.
1713
2542
  /// Used by both the worker shell wrapper (printf source) and future
1714
2543
  /// status/classifier code (capture substring) so they cannot drift.
2544
+ /// ---
2545
+ /// purpose: 单一来源地拼出 worker pane 的 provider 退出标记文本
2546
+ /// params:
2547
+ /// provider_label: 人类可读的 provider 名,原样嵌入标记
2548
+ /// returns: worker 专用前缀 + provider 名 + 后缀;与 leader 标记刻意不同,便于分清是哪种 pane 掉回 shell
2549
+ /// boundary:
2550
+ /// - 写标记的 worker shell wrapper 与读标记的分类代码必须都用这一个函数
2551
+ /// - 同为内容信号,pane 里出现同样文本即误触发
2552
+ /// ---
1715
2553
  pub fn worker_provider_exit_marker(provider_label: &str) -> String {
1716
2554
  format!(
1717
2555
  "{WORKER_PROVIDER_EXIT_MARKER_PREFIX} {provider_label} {WORKER_PROVIDER_EXIT_MARKER_SUFFIX}"
@@ -1725,6 +2563,20 @@ pub fn worker_provider_exit_marker(provider_label: &str) -> String {
1725
2563
  /// cascade into whole-server death). When the provider exits, the worker
1726
2564
  /// pane remains alive with an explicit worker exit marker, then runs an
1727
2565
  /// inert `sh` tail that does not read terminal input.
2566
+ /// ---
2567
+ /// purpose: 拼出 worker pane 的 shell 启动行,让 provider 作为子进程运行,退出后 pane 不塌成 [exited]
2568
+ /// params:
2569
+ /// argv: provider 启动命令,逐项做 shell 引用
2570
+ /// cwd: 进程工作目录
2571
+ /// env: 要导出的环境变量;键若出现在 env_unset 中则跳过,保证 unset 赢
2572
+ /// env_unset: 必须真正 unset 的键;先 unset 再导出
2573
+ /// provider_label: 嵌入退出标记的 provider 名
2574
+ /// returns: 单条 shell 命令行,依次为 cd、unset、env 导出与 provider(不带 exec)、printf 退出标记、exec 惰性 sh 尾巴
2575
+ /// boundary:
2576
+ /// - 只拼字符串,不执行、不 spawn
2577
+ /// - 与 leader 版的唯一实质差异是退出标记前缀;结构必须保持一致
2578
+ /// - 惰性尾巴不读 pane 标准输入,故该 pane 之后收到的注入不会被任何进程消费
2579
+ /// ---
1728
2580
  pub fn worker_shell_wrapper_command(
1729
2581
  argv: &[String],
1730
2582
  cwd: &Path,
@@ -1776,6 +2628,20 @@ pub fn worker_shell_wrapper_command(
1776
2628
  ///
1777
2629
  /// `provider_label` is a human-readable provider name (e.g. "claude",
1778
2630
  /// "codex") embedded in the exit marker for diagnostics.
2631
+ /// ---
2632
+ /// purpose: 拼出 leader pane 的 shell 启动行,让 provider 作为子进程运行,退出后 pane 保留并显示退出标记
2633
+ /// params:
2634
+ /// argv: provider 启动命令,逐项做 shell 引用
2635
+ /// cwd: 进程工作目录
2636
+ /// env: 要导出的环境变量;键若出现在 env_unset 中则跳过,保证 unset 赢
2637
+ /// env_unset: 必须真正 unset 的键;先 unset 再导出
2638
+ /// provider_label: 嵌入退出标记的 provider 名
2639
+ /// returns: 单条 shell 命令行,四段依次为 cd、unset、env 导出与 provider(刻意不带 exec)、printf 退出标记加 exec 惰性 sh 尾巴
2640
+ /// boundary:
2641
+ /// - 只拼字符串,不执行、不 spawn
2642
+ /// - 刻意不用 exec:用了 provider 就成了 pane 主进程,退出即塌成 [exited]
2643
+ /// - 退出标记文本必须来自 leader_provider_exit_marker,禁止在此写字面量
2644
+ /// ---
1779
2645
  pub fn leader_shell_wrapper_command(
1780
2646
  argv: &[String],
1781
2647
  cwd: &Path,
@@ -2020,10 +2886,18 @@ impl Transport for TmuxBackend {
2020
2886
  observer: Option<&dyn SubmitObserver>,
2021
2887
  ) -> Result<InjectReport, TransportError> {
2022
2888
  let pane = pane_from_target(target);
2889
+ let _pane_input = crate::lifecycle::pane_input_lock::acquire_or_proceed(
2890
+ crate::lifecycle::pane_input_lock::PaneInputLockRequest {
2891
+ workspace: self.event_workspace.as_deref(),
2892
+ target_key: pane.as_str(),
2893
+ operation: "inject",
2894
+ },
2895
+ );
2023
2896
  // U1 #7: pane readback signal for the non-pasted-prompt text path.
2024
2897
  let mut token_visible_for_report: Option<bool> = None;
2025
2898
  match payload {
2026
2899
  InjectPayload::Empty => {
2900
+ self.prepare_pane_for_submit(target, matches!(submit, Key::Enter));
2027
2901
  let argv = tmux_empty_inject_argv(&pane, submit);
2028
2902
  self.run_ok(&argv)?;
2029
2903
  notify_submit_observer(observer);
@@ -2053,9 +2927,12 @@ impl Transport for TmuxBackend {
2053
2927
  //
2054
2928
  // Design truth source: .team/artifacts/E55-delivery-architecture-design.html
2055
2929
  // Python parity: dynamic timeout max(2s, bytes/25000), poll 50ms.
2930
+ // Cursor Ink:文本与 Enter 必须分开发且间隔 ≥1s,否则第一次
2931
+ // Enter 被吞。token 可见性轮询的耗时算进这 1s;测试隔离下地板为 0。
2056
2932
  // ═══════════════════════════════════════════════════════════
2057
2933
  let inject_start = std::time::Instant::now();
2058
- let submit_argv = tmux_send_keys_argv(&pane, &[submit]);
2934
+ let pasted_at = inject_start;
2935
+ let submit_argv = tmux_send_submit_argv(&pane, submit);
2059
2936
 
2060
2937
  // Phase 1: token visibility poll — wait for the pasted text to
2061
2938
  // become visible in the pane before submitting. Dynamic timeout
@@ -2066,16 +2943,24 @@ impl Transport for TmuxBackend {
2066
2943
  size_based.max(2000)
2067
2944
  };
2068
2945
  let poll_start = std::time::Instant::now();
2069
- token_visible_for_report = if payload_token_marker(payload).is_some() {
2946
+ let mut token_ever_visible = false;
2947
+ let mut tracked_paste: Option<PasteLatch> = None;
2948
+ token_visible_for_report = if let Some(m) = payload_token_marker(payload) {
2070
2949
  let mut visible = false;
2071
2950
  while poll_start.elapsed().as_millis() < token_poll_timeout_ms as u128 {
2072
- match token_visible_in_capture(self, target, payload) {
2073
- Ok(Some(true)) => {
2074
- visible = true;
2075
- break;
2951
+ match self.capture(target, CaptureRange::Tail(80)) {
2952
+ Ok(cap) => {
2953
+ tracked_paste = latch_paste(&cap.text, tracked_paste);
2954
+ if cap.text.contains(m) {
2955
+ visible = true;
2956
+ token_ever_visible = true;
2957
+ }
2958
+ // 折叠占位符出现 = TUI 侧注入完成。token 出现在未折叠高块里不算完成。
2959
+ if paste_ready_for_enter(&cap.text, m) {
2960
+ break;
2961
+ }
2076
2962
  }
2077
2963
  Err(_) => break, // tmux unavailable, skip poll
2078
- _ => {}
2079
2964
  }
2080
2965
  std::thread::sleep(Duration::from_millis(50));
2081
2966
  }
@@ -2096,6 +2981,7 @@ impl Transport for TmuxBackend {
2096
2981
  // the entire submit_and_verify loop — single send, no consumption
2097
2982
  // check, KeySentAfterVisibleToken verification.
2098
2983
  if !matches!(submit, Key::Enter) {
2984
+ self.prepare_pane_for_submit(target, false);
2099
2985
  self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
2100
2986
  notify_submit_observer(observer);
2101
2987
  let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
@@ -2117,21 +3003,27 @@ impl Transport for TmuxBackend {
2117
3003
  });
2118
3004
  }
2119
3005
 
3006
+ sleep_remaining_paste_to_submit_floor(pasted_at, current_paste_to_submit_floor());
3007
+
2120
3008
  let marker = payload_token_marker(payload);
2121
3009
  let max_submit_attempts: u32 = 3;
2122
3010
  let mut consumption_attempts: u32 = 0;
3011
+ let mut capture_retries: u32 = 0;
2123
3012
  let mut consumed: Option<bool> = None;
2124
3013
  let mut attempts_detail: Vec<SubmitAttemptObservation> = Vec::new();
2125
3014
  let mut any_attempt_matched = false;
2126
3015
 
2127
3016
  let poll_consumption = !payload.skip_consumption_poll();
2128
3017
  if !poll_consumption {
3018
+ self.prepare_pane_for_submit(target, true);
2129
3019
  if self
2130
3020
  .run_inject_stage(&submit_argv, InjectStage::Submit)
2131
3021
  .is_ok()
2132
3022
  {
2133
3023
  notify_submit_observer(observer);
2134
3024
  consumption_attempts = 1;
3025
+ // 跳过消费门(leader skip)不是「没能判断」;保持既有 EnterSent。
3026
+ consumed = Some(true);
2135
3027
  }
2136
3028
  }
2137
3029
  let submit_attempt_limit = if poll_consumption {
@@ -2150,6 +3042,10 @@ impl Transport for TmuxBackend {
2150
3042
  if attempt > 0 {
2151
3043
  if let Some(m) = marker {
2152
3044
  if let Ok(cap) = self.capture(target, CaptureRange::Tail(40)) {
3045
+ if cap.text.contains(m) {
3046
+ token_ever_visible = true;
3047
+ }
3048
+ tracked_paste = latch_paste(&cap.text, tracked_paste);
2153
3049
  let obs = submit_attempt_observation(
2154
3050
  attempt_index,
2155
3051
  &cap,
@@ -2160,10 +3056,31 @@ impl Transport for TmuxBackend {
2160
3056
  any_attempt_matched = true;
2161
3057
  }
2162
3058
  attempts_detail.push(obs);
2163
- if !token_in_bottom_n(&cap.text, m, 15) {
3059
+ // NeverSeen 不得把 token 缺席写成 consumed;
3060
+ // 见过再消失,或本次占位符身份离开 composer,才停手。
3061
+ if consumption_from_capture(
3062
+ &cap.text,
3063
+ m,
3064
+ token_ever_visible,
3065
+ tracked_paste,
3066
+ ) == Some(true)
3067
+ {
2164
3068
  consumed = Some(true);
2165
3069
  break;
2166
3070
  }
3071
+ if cursor_single_enter_enabled() {
3072
+ if provider_busy_signal_in_tail(&cap.text) {
3073
+ consumed = Some(true);
3074
+ break;
3075
+ }
3076
+ if !should_resubmit_enter_cursor(&cap.text, m) {
3077
+ consumed = Some(false);
3078
+ break;
3079
+ }
3080
+ } else if !should_resubmit_enter(&cap.text, m, tracked_paste) {
3081
+ consumed = Some(false);
3082
+ break;
3083
+ }
2167
3084
  }
2168
3085
  }
2169
3086
  }
@@ -2178,9 +3095,13 @@ impl Transport for TmuxBackend {
2178
3095
  // send Enter, never Escape.
2179
3096
  let _ = escape_argv;
2180
3097
 
3098
+ // 发前归零接收态(tmux mode + 未闭合 bracketed paste)。
3099
+ // 所有 Enter 路径的收敛点:prepare_pane_for_submit。
3100
+ // mode 不是忙闲;cancel 失败不得让注入失败。E55: 不送 Escape/C-c。
3101
+ self.prepare_pane_for_submit(target, true);
3102
+
2181
3103
  // Enter — send-keys failure is degraded (tmux may not have
2182
- // the pane in sim/test env). Break to consumed=None path
2183
- // which returns EnterSentWithoutPlaceholderCheck.
3104
+ // the pane in sim/test env). Break to consumed=None → Unverified.
2184
3105
  if self
2185
3106
  .run_inject_stage(&submit_argv, InjectStage::Submit)
2186
3107
  .is_err()
@@ -2196,17 +3117,27 @@ impl Transport for TmuxBackend {
2196
3117
  if attempt == 0 && matches!(token_visible_for_report, Some(false)) {
2197
3118
  token_visible_for_report =
2198
3119
  post_submit_token_visible(self, target, payload).unwrap_or(Some(false));
3120
+ if token_visible_for_report == Some(true) {
3121
+ token_ever_visible = true;
3122
+ }
2199
3123
  }
2200
3124
 
2201
- // Poll: token disappeared from bottom 15 lines = consumed.
2202
- // Capture failures → consumed=None (non-blocking).
3125
+ // Poll: token 三态 + 本次 #N 占位符。
3126
+ // Capture failures → 只重读 capture,绝不重粘、不加 Enter;读不到则 None。
2203
3127
  if let Some(m) = marker {
2204
3128
  let mut found_consumed = false;
2205
- let mut capture_failed = false;
3129
+ let mut saw_capture = false;
3130
+ let mut consecutive_capture_failures: u32 = 0;
2206
3131
  for _ in 0..12 {
2207
3132
  std::thread::sleep(Duration::from_millis(100));
2208
3133
  match self.capture(target, CaptureRange::Tail(40)) {
2209
3134
  Ok(cap) => {
3135
+ consecutive_capture_failures = 0;
3136
+ saw_capture = true;
3137
+ if cap.text.contains(m) {
3138
+ token_ever_visible = true;
3139
+ }
3140
+ tracked_paste = latch_paste(&cap.text, tracked_paste);
2210
3141
  let obs = submit_attempt_observation(
2211
3142
  attempt_index,
2212
3143
  &cap,
@@ -2217,18 +3148,37 @@ impl Transport for TmuxBackend {
2217
3148
  any_attempt_matched = true;
2218
3149
  }
2219
3150
  attempts_detail.push(obs);
2220
- if !token_in_bottom_n(&cap.text, m, 15) {
3151
+ if consumption_from_capture(
3152
+ &cap.text,
3153
+ m,
3154
+ token_ever_visible,
3155
+ tracked_paste,
3156
+ ) == Some(true)
3157
+ {
3158
+ found_consumed = true;
3159
+ break;
3160
+ }
3161
+ if cursor_single_enter_enabled()
3162
+ && provider_busy_signal_in_tail(&cap.text)
3163
+ {
2221
3164
  found_consumed = true;
2222
3165
  break;
2223
3166
  }
2224
3167
  }
2225
3168
  Err(_) => {
2226
- capture_failed = true;
2227
- break;
3169
+ capture_retries = capture_retries.saturating_add(1);
3170
+ consecutive_capture_failures =
3171
+ consecutive_capture_failures.saturating_add(1);
3172
+ if consecutive_capture_failures >= POST_SUBMIT_CAPTURE_RETRY {
3173
+ break;
3174
+ }
3175
+ std::thread::sleep(Duration::from_millis(
3176
+ POST_SUBMIT_CAPTURE_RETRY_MS,
3177
+ ));
2228
3178
  }
2229
3179
  }
2230
3180
  }
2231
- if capture_failed {
3181
+ if !saw_capture {
2232
3182
  consumed = None;
2233
3183
  break;
2234
3184
  }
@@ -2237,8 +3187,9 @@ impl Transport for TmuxBackend {
2237
3187
  break;
2238
3188
  }
2239
3189
  } else {
2240
- // Non-token payload: single Enter, no consumption check.
2241
- consumed = None;
3190
+ // Non-token payload: single Enter, no consumption check
3191
+ // (0.3.27). Not 「没能判断」— 本门不覆盖无 token 路径。
3192
+ consumed = Some(true);
2242
3193
  break;
2243
3194
  }
2244
3195
  }
@@ -2250,33 +3201,120 @@ impl Transport for TmuxBackend {
2250
3201
  // `submit_verification`; the prints only spammed
2251
3202
  // coordinator.log without additive signal. Behavior
2252
3203
  // is byte-identical.
2253
- let submit_verification = match consumed {
3204
+ // consumed=false: token still in composer. Paste landing
3205
+ // (`any_attempt_matched`) is A, not submit. Only a Working
3206
+ // signal counts as consumption. Else say unverified.
3207
+ let _ = any_attempt_matched;
3208
+ let mut last_turn_text = attempts_detail
3209
+ .last()
3210
+ .map(|obs| obs.pane_tail_excerpt.clone());
3211
+ let mut submit_verification = match consumed {
2254
3212
  Some(true) => SubmitVerification::EnterSentWithoutPlaceholderCheck,
2255
- Some(false) => {
2256
- if any_attempt_matched {
2257
- SubmitVerification::EnterSentWithoutPlaceholderCheck
2258
- } else {
2259
- match self.capture(target, CaptureRange::Tail(15)) {
2260
- Ok(cap) => {
2261
- attempts_detail.push(submit_attempt_observation(
2262
- consumption_attempts.max(1),
2263
- &cap,
2264
- marker,
2265
- inject_start.elapsed().as_millis() as u64,
2266
- ));
2267
- let busy = provider_busy_signal_in_tail(&cap.text);
2268
- if busy {
2269
- SubmitVerification::EnterSentWithoutPlaceholderCheck
2270
- } else {
2271
- SubmitVerification::SubmitConsumptionUnverified
3213
+ Some(false) => match self.capture(target, CaptureRange::Tail(15)) {
3214
+ Ok(cap) => {
3215
+ last_turn_text = Some(cap.text.clone());
3216
+ attempts_detail.push(submit_attempt_observation(
3217
+ consumption_attempts.max(1),
3218
+ &cap,
3219
+ marker,
3220
+ inject_start.elapsed().as_millis() as u64,
3221
+ ));
3222
+ if provider_busy_signal_in_tail(&cap.text) {
3223
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
3224
+ } else {
3225
+ SubmitVerification::SubmitConsumptionUnverified
3226
+ }
3227
+ }
3228
+ Err(_) => SubmitVerification::SubmitConsumptionUnverified,
3229
+ },
3230
+ None => SubmitVerification::SubmitConsumptionUnverified,
3231
+ };
3232
+ // Unverified + 折行缺口:只补一颗 C-m。A 的 should_resubmit_enter
3233
+ // 已为真(latch/底15 token,含打满 cap)时 B 不介入。身份拿不到
3234
+ // (None)不补——重复不可逆,滞留可人工救。cursor 单回车不走这里。
3235
+ // 终止:已消费 / 身份消失 / A 已覆盖 / 达上限 / busy。
3236
+ if matches!(
3237
+ submit_verification,
3238
+ SubmitVerification::SubmitConsumptionUnverified
3239
+ ) && consumed == Some(false)
3240
+ && !cursor_single_enter_enabled()
3241
+ {
3242
+ if let Some(m) = marker {
3243
+ let mut leftover_resends = UNVERIFIED_COMPOSER_RESEND_MAX;
3244
+ while leftover_resends > 0 {
3245
+ leftover_resends -= 1;
3246
+ let Ok(cap) = self.capture(target, CaptureRange::Tail(15)) else {
3247
+ break;
3248
+ };
3249
+ last_turn_text = Some(cap.text.clone());
3250
+ if !should_resend_unverified_wrap_gap(&cap.text, m, tracked_paste) {
3251
+ break;
3252
+ }
3253
+ self.prepare_pane_for_submit(target, true);
3254
+ if self
3255
+ .run_inject_stage(&submit_argv, InjectStage::Submit)
3256
+ .is_err()
3257
+ {
3258
+ break;
3259
+ }
3260
+ notify_submit_observer(observer);
3261
+ consumption_attempts = consumption_attempts.saturating_add(1);
3262
+ let attempt_start = std::time::Instant::now();
3263
+ let mut found_consumed = false;
3264
+ for _ in 0..12 {
3265
+ std::thread::sleep(Duration::from_millis(100));
3266
+ match self.capture(target, CaptureRange::Tail(40)) {
3267
+ Ok(cap) => {
3268
+ if cap.text.contains(m) {
3269
+ token_ever_visible = true;
3270
+ }
3271
+ tracked_paste = latch_paste(&cap.text, tracked_paste);
3272
+ let obs = submit_attempt_observation(
3273
+ consumption_attempts.max(1),
3274
+ &cap,
3275
+ marker,
3276
+ attempt_start.elapsed().as_millis() as u64,
3277
+ );
3278
+ attempts_detail.push(obs);
3279
+ last_turn_text = Some(cap.text.clone());
3280
+ if consumption_from_capture(
3281
+ &cap.text,
3282
+ m,
3283
+ token_ever_visible,
3284
+ tracked_paste,
3285
+ ) == Some(true)
3286
+ || provider_busy_signal_in_tail(&cap.text)
3287
+ {
3288
+ // Gone+无 latch 会把折行 token 写成已消费;身份仍在则不算。
3289
+ if !this_paste_identity_in_composer(
3290
+ &cap.text,
3291
+ m,
3292
+ tracked_paste,
3293
+ ) {
3294
+ found_consumed = true;
3295
+ break;
3296
+ }
3297
+ }
3298
+ if !this_paste_identity_in_composer(
3299
+ &cap.text,
3300
+ m,
3301
+ tracked_paste,
3302
+ ) {
3303
+ found_consumed = true;
3304
+ break;
3305
+ }
2272
3306
  }
3307
+ Err(_) => break,
2273
3308
  }
2274
- Err(_) => SubmitVerification::SubmitConsumptionUnverified,
3309
+ }
3310
+ if found_consumed {
3311
+ submit_verification =
3312
+ SubmitVerification::EnterSentWithoutPlaceholderCheck;
3313
+ break;
2275
3314
  }
2276
3315
  }
2277
3316
  }
2278
- None => submit_verification_for_key(submit),
2279
- };
3317
+ }
2280
3318
  let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
2281
3319
  return Ok(InjectReport {
2282
3320
  stage_reached: InjectStage::Submit,
@@ -2285,13 +3323,11 @@ impl Transport for TmuxBackend {
2285
3323
  token_visible_for_report,
2286
3324
  ),
2287
3325
  submit_verification,
2288
- turn_verification: match payload {
2289
- InjectPayload::Empty => TurnVerification::NotRequired,
2290
- InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => {
2291
- TurnVerification::NotYetObserved
2292
- }
2293
- },
2294
- attempts: consumption_attempts,
3326
+ turn_verification: turn_verification_for_payload(
3327
+ payload,
3328
+ last_turn_text.as_deref(),
3329
+ ),
3330
+ attempts: consumption_attempts.saturating_add(capture_retries),
2295
3331
  submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
2296
3332
  appear_gate_elapsed_ms: 0,
2297
3333
  appear_gate_matched: false,
@@ -2308,12 +3344,7 @@ impl Transport for TmuxBackend {
2308
3344
  token_visible_for_report,
2309
3345
  ),
2310
3346
  submit_verification: submit_verification_for_key(submit),
2311
- turn_verification: match payload {
2312
- InjectPayload::Empty => TurnVerification::NotRequired,
2313
- InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => {
2314
- TurnVerification::NotYetObserved
2315
- }
2316
- },
3347
+ turn_verification: turn_verification_for_payload(payload, None),
2317
3348
  attempts: 1,
2318
3349
  // E50 PR-1: Empty payload / non-Text fallthrough path — no submit
2319
3350
  // diagnostics applicable.
@@ -2323,6 +3354,13 @@ impl Transport for TmuxBackend {
2323
3354
 
2324
3355
  fn send_keys(&self, target: &Target, keys: &[Key]) -> Result<(), TransportError> {
2325
3356
  let pane = pane_from_target(target);
3357
+ let _pane_input = crate::lifecycle::pane_input_lock::acquire_or_proceed(
3358
+ crate::lifecycle::pane_input_lock::PaneInputLockRequest {
3359
+ workspace: self.event_workspace.as_deref(),
3360
+ target_key: pane.as_str(),
3361
+ operation: "send_keys",
3362
+ },
3363
+ );
2326
3364
  if keys.contains(&Key::CancelMode) {
2327
3365
  if let Some(mode) = pane_mode_from_raw(self.query(target, PaneField::PaneMode)?) {
2328
3366
  let argv = crate::transport::tmux_cancel_mode_argv(&pane, mode);
@@ -2330,6 +3368,9 @@ impl Transport for TmuxBackend {
2330
3368
  }
2331
3369
  return Ok(());
2332
3370
  }
3371
+ if keys.iter().any(|k| matches!(k, Key::Enter)) {
3372
+ self.prepare_pane_for_submit(target, true);
3373
+ }
2333
3374
  let argv = tmux_send_keys_argv(&pane, keys);
2334
3375
  self.run_ok(&argv)
2335
3376
  }