@team-agent/installer 0.5.66 → 0.5.68

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 (164) 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 +82 -0
  6. package/crates/team-agent/src/cli/emit.rs +3 -3
  7. package/crates/team-agent/src/cli/grok_slot.rs +299 -0
  8. package/crates/team-agent/src/cli/leader.rs +21 -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 +2 -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 +59 -0
  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 +30 -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 +2 -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 +920 -63
  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 +332 -12
  46. package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +62 -0
  47. package/crates/team-agent/src/lifecycle/launch/agent_state.rs +59 -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 +332 -0
  51. package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +204 -456
  52. package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
  53. package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
  54. package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
  55. package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
  56. package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
  57. package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +530 -0
  58. package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
  59. package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
  60. package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
  61. package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +78 -0
  62. package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
  63. package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
  64. package/crates/team-agent/src/lifecycle/launch/spawn.rs +62 -2
  65. package/crates/team-agent/src/lifecycle/launch/spec_state.rs +139 -0
  66. package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
  67. package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
  68. package/crates/team-agent/src/lifecycle/launch.rs +99 -29
  69. package/crates/team-agent/src/lifecycle/lock.rs +42 -1
  70. package/crates/team-agent/src/lifecycle/mod.rs +11 -0
  71. package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
  72. package/crates/team-agent/src/lifecycle/profile_launch.rs +98 -0
  73. package/crates/team-agent/src/lifecycle/profile_smoke.rs +51 -1
  74. package/crates/team-agent/src/lifecycle/restart/agent.rs +124 -0
  75. package/crates/team-agent/src/lifecycle/restart/common.rs +369 -11
  76. package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
  77. package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
  78. package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
  79. package/crates/team-agent/src/lifecycle/restart/remove.rs +196 -5
  80. package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
  81. package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
  82. package/crates/team-agent/src/lifecycle/restart.rs +29 -0
  83. package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
  84. package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
  85. package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
  86. package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
  87. package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
  88. package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +287 -0
  89. package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +229 -0
  90. package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
  91. package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +784 -0
  92. package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
  93. package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
  94. package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +498 -0
  95. package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
  96. package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
  97. package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
  98. package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
  99. package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
  100. package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
  101. package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
  102. package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
  103. package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +2 -0
  104. package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
  105. package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
  106. package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
  107. package/crates/team-agent/src/lifecycle/tests.rs +23 -13
  108. package/crates/team-agent/src/lifecycle/types.rs +61 -0
  109. package/crates/team-agent/src/lifecycle/worker_command_context.rs +85 -11
  110. package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
  111. package/crates/team-agent/src/mcp_server/tests.rs +196 -2
  112. package/crates/team-agent/src/messaging/delivery.rs +405 -40
  113. package/crates/team-agent/src/messaging/helpers.rs +2 -0
  114. package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
  115. package/crates/team-agent/src/messaging/results.rs +4 -0
  116. package/crates/team-agent/src/messaging/send.rs +34 -0
  117. package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
  118. package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
  119. package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
  120. package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
  121. package/crates/team-agent/src/messaging/types.rs +4 -1
  122. package/crates/team-agent/src/model/enums.rs +14 -5
  123. package/crates/team-agent/src/model/permissions.rs +3 -0
  124. package/crates/team-agent/src/os_probe.rs +73 -2
  125. package/crates/team-agent/src/provider/adapter.rs +231 -4
  126. package/crates/team-agent/src/provider/adapters/cursor_agent.rs +86 -0
  127. package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
  128. package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
  129. package/crates/team-agent/src/provider/bypass_flags.rs +46 -2
  130. package/crates/team-agent/src/provider/classify.rs +4 -2
  131. package/crates/team-agent/src/provider/faults.rs +2 -1
  132. package/crates/team-agent/src/provider/mod.rs +11 -0
  133. package/crates/team-agent/src/provider/session/capture.rs +158 -20
  134. package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
  135. package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
  136. package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
  137. package/crates/team-agent/src/provider/session/context_fork.rs +77 -3
  138. package/crates/team-agent/src/provider/session/mod.rs +18 -0
  139. package/crates/team-agent/src/provider/session/resume.rs +57 -0
  140. package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
  141. package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
  142. package/crates/team-agent/src/provider/session_scan/common.rs +204 -2
  143. package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
  144. package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
  145. package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
  146. package/crates/team-agent/src/provider/session_scan.rs +8 -0
  147. package/crates/team-agent/src/provider/submit_now.rs +94 -0
  148. package/crates/team-agent/src/provider/tests/adapter.rs +304 -0
  149. package/crates/team-agent/src/provider/types.rs +4 -2
  150. package/crates/team-agent/src/provider/wire.rs +23 -1
  151. package/crates/team-agent/src/state/persist.rs +301 -8
  152. package/crates/team-agent/src/state/repository.rs +5 -0
  153. package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
  154. package/crates/team-agent/src/tmux_backend.rs +1064 -75
  155. package/crates/team-agent/src/transport/tests/wire.rs +28 -2
  156. package/crates/team-agent/src/transport.rs +248 -1
  157. package/npm/install.mjs +129 -73
  158. package/package.json +4 -4
  159. package/skills/team-agent/SKILL.md +33 -238
  160. package/skills/team-agent/command-coverage.json +31 -0
  161. package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
  162. package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
  163. package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
  164. 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
 
@@ -215,6 +244,11 @@ pub(crate) enum RuntimeTmuxEndpointSource {
215
244
  }
216
245
 
217
246
  impl RuntimeTmuxEndpointSource {
247
+ /// ---
248
+ /// purpose: endpoint 来源的稳定诊断拼写,让日志能分清 endpoint 是从 state 读的还是 workspace 兜底算的
249
+ /// returns: state.tmux_endpoint / state.tmux_socket / workspace_fallback 三者之一
250
+ /// boundary: 只标来源,不表示该 endpoint 上真的有 tmux server
251
+ /// ---
218
252
  pub(crate) fn as_str(self) -> &'static str {
219
253
  match self {
220
254
  Self::StateTmuxEndpoint => "state.tmux_endpoint",
@@ -233,6 +267,11 @@ pub(crate) struct RuntimeTmuxBackendSelection {
233
267
  impl TmuxBackend {
234
268
  /// Backend bound to the real `tmux` subprocess on the SHARED default socket (no `-L`).
235
269
  /// Non-team callers + existing argv/unit tests stay unaffected.
270
+ /// ---
271
+ /// purpose: 构造绑在共享默认 socket 上的真实 tmux 后端(argv 不带 -L / -S)
272
+ /// returns: 用 RealCommandRunner 执行、无 socket、无事件 workspace 的后端
273
+ /// boundary: 不属于任何 team,故 kill_server 对它是 no-op;不写 events.jsonl(没有 workspace)
274
+ /// ---
236
275
  pub fn new() -> Self {
237
276
  Self {
238
277
  runner: Box::new(RealCommandRunner),
@@ -244,6 +283,13 @@ impl TmuxBackend {
244
283
  /// CP-1 team backend: bound to the real `tmux` subprocess on a PER-WORKSPACE socket, derived
245
284
  /// deterministically from the canonicalized workspace path so the leader CLI, the daemon, and
246
285
  /// every later op (spawn / inject / has_session / kill) hit the SAME `tmux -L <socket>` server.
286
+ /// ---
287
+ /// purpose: 构造绑在「按 workspace 派生的专属 socket」上的真实 tmux 后端
288
+ /// params:
289
+ /// workspace: 工作区路径;socket 名由它经 socket_name_for_workspace 确定性派生
290
+ /// returns: socket 为 Name(ta-xxxxxxxxxxxx)、事件写入该 workspace 的后端
291
+ /// boundary: 只决定「连哪台 server」,不创建 server、不验证 socket 是否存在(探测见 socket_probe_missing_for_workspace)
292
+ /// ---
247
293
  pub fn for_workspace(workspace: &Path) -> Self {
248
294
  Self {
249
295
  runner: Box::new(RealCommandRunner),
@@ -254,6 +300,13 @@ impl TmuxBackend {
254
300
  }
255
301
  }
256
302
 
303
+ /// ---
304
+ /// purpose: 按给定短 socket 名构造真实 tmux 后端
305
+ /// params:
306
+ /// socket: 短 socket 名;空串或 "default" 视为「用共享默认 socket」
307
+ /// returns: socket 名非空且非 default 时绑 Name(socket),否则等价于 new()
308
+ /// boundary: 不识别绝对路径(会被原样当短名走 -L);要按路径寻址请用 for_tmux_endpoint;不设事件 workspace
309
+ /// ---
257
310
  pub(crate) fn for_socket_name(socket: &str) -> Self {
258
311
  if socket.is_empty() || socket == "default" {
259
312
  Self::new()
@@ -266,6 +319,13 @@ impl TmuxBackend {
266
319
  }
267
320
  }
268
321
 
322
+ /// ---
323
+ /// purpose: 按 endpoint 字符串构造真实 tmux 后端,自动分辨绝对路径与短名
324
+ /// params:
325
+ /// endpoint: 绝对路径 ⇒ 直接当 socket 路径;短名 ⇒ 先解析成已存在或默认根下的路径;空串 / "default" ⇒ 共享默认 socket
326
+ /// returns: 解析成功时绑 Path(...);短名解析不出路径时退回 new()
327
+ /// boundary: 只解析寻址,不探活、不创建 server;不设事件 workspace
328
+ /// ---
269
329
  pub(crate) fn for_tmux_endpoint(endpoint: &str) -> Self {
270
330
  if endpoint.is_empty() || endpoint == "default" {
271
331
  Self::new()
@@ -288,6 +348,15 @@ impl TmuxBackend {
288
348
  }
289
349
  }
290
350
 
351
+ /// ---
352
+ /// purpose: 把 leader 认领的绑定 nonce 写进 pane 级 tmux 用户选项,给「这个 pane 实例是不是我绑的那个」留可核凭据
353
+ /// params:
354
+ /// pane: 目标 pane 标识
355
+ /// nonce: 本次绑定的凭据串,原样写入,不做格式校验
356
+ /// returns: 写入命令成功即 Ok
357
+ /// errors: tmux set-option 非 0 或子进程失败时返回 TransportError
358
+ /// boundary: 只写不读、不比对;写的是 pane 级(-p)选项,随 pane 消亡,不跨 socket;不判断该 nonce 是否已被别人占用
359
+ /// ---
291
360
  pub(crate) fn set_pane_binding_nonce(
292
361
  &self,
293
362
  pane: &PaneId,
@@ -306,6 +375,13 @@ impl TmuxBackend {
306
375
  }
307
376
 
308
377
  /// Backend with an injected runner (tests: canned/recording tmux output). Shared default socket.
378
+ /// ---
379
+ /// purpose: 用注入的 runner 构造后端(测试用录制/罐装 tmux 输出),绑共享默认 socket
380
+ /// params:
381
+ /// runner: 替代 RealCommandRunner 的执行 seam
382
+ /// returns: 无 socket、无事件 workspace 的后端
383
+ /// boundary: runner 注入是本文件唯一的 OS 边界替换 seam(with_runner_for_workspace / with_runner_for_tmux_endpoint 同用);不改变任何 argv 构造逻辑
384
+ /// ---
309
385
  pub fn with_runner(runner: Box<dyn CommandRunner>) -> Self {
310
386
  Self {
311
387
  runner,
@@ -316,6 +392,14 @@ impl TmuxBackend {
316
392
 
317
393
  /// Backend with an injected runner bound to a per-workspace socket (tests: assert the `-L` is in
318
394
  /// the recorded argv for a workspace-bound backend).
395
+ /// ---
396
+ /// purpose: 用注入的 runner 构造绑 workspace 专属 socket 的后端(测试断言 -L 确实进了 argv)
397
+ /// params:
398
+ /// runner: 执行 seam
399
+ /// workspace: socket 名与事件落盘位置的来源
400
+ /// returns: socket 为 Name(派生名)、事件写入该 workspace 的后端
401
+ /// boundary: socket 名派生与 for_workspace 完全同源,不允许出现第二套派生规则
402
+ /// ---
319
403
  pub fn with_runner_for_workspace(runner: Box<dyn CommandRunner>, workspace: &Path) -> Self {
320
404
  Self {
321
405
  runner,
@@ -326,10 +410,15 @@ impl TmuxBackend {
326
410
  }
327
411
  }
328
412
 
329
- pub(crate) fn with_runner_for_tmux_endpoint(
330
- runner: Box<dyn CommandRunner>,
331
- endpoint: &str,
332
- ) -> Self {
413
+ /// ---
414
+ /// purpose: 用注入的 runner 构造按 endpoint 寻址的后端
415
+ /// params:
416
+ /// runner: 执行 seam
417
+ /// endpoint: 绝对路径 ⇒ Path(...);空串 / "default" ⇒ 无 socket;短名 ⇒ 解析成路径,解析不出则无 socket
418
+ /// returns: 按上述分支绑定 socket 的后端;不设事件 workspace
419
+ /// boundary: 分支与 for_tmux_endpoint 语义一致但顺序不同(此处先判绝对路径),两者都不探活
420
+ /// ---
421
+ pub fn with_runner_for_tmux_endpoint(runner: Box<dyn CommandRunner>, endpoint: &str) -> Self {
333
422
  if Path::new(endpoint).is_absolute() {
334
423
  Self {
335
424
  runner,
@@ -360,6 +449,14 @@ impl TmuxBackend {
360
449
  }
361
450
 
362
451
  /// Build the exact argv that a workspace-bound tmux backend will execute.
452
+ /// ---
453
+ /// purpose: 算出「workspace 绑定后端实际会执行的那条 argv」,供诊断与提示文案复用
454
+ /// params:
455
+ /// workspace: 决定注入哪个 -L socket
456
+ /// argv: 原始 argv;首元素为 "tmux" 时才会被插入 socket 参数
457
+ /// returns: 插好 socket 参数的 argv;非 tmux argv 原样返回
458
+ /// boundary: 只构造不执行;结果必须与真实执行走的 tmux_argv 完全一致,否则提示会骗人
459
+ /// ---
363
460
  pub fn argv_for_workspace(workspace: &Path, argv: &[String]) -> Vec<String> {
364
461
  Self::for_workspace(workspace).tmux_argv(argv)
365
462
  }
@@ -392,6 +489,13 @@ impl TmuxBackend {
392
489
  /// `tmux -L <socket> kill-server` (CP-1 cleanup): best-effort teardown of the per-team server on
393
490
  /// shutdown so per-team sockets do not orphan. No-op (and never errors) for a default-socket
394
491
  /// backend, and a "no server" failure is ignored.
492
+ /// ---
493
+ /// purpose: 收尾时尽力拆掉本 team 专属的 tmux server,避免 socket 变孤儿
494
+ /// boundary:
495
+ /// - 默认 socket 后端直接返回,绝不拆共享 server
496
+ /// - 只在 list_targets 证明 server 为空时才拆;列举失败按「未证明所有权」处理,倒向不拆;有事件 workspace 时才写 tmux.kill_server_skipped_nonempty_or_unknown 事件(按 endpoint / 短名构造的后端没有,静默跳过)
497
+ /// - 尽力而为:执行结果被丢弃,不返回错误、不重试
498
+ /// ---
395
499
  pub fn kill_server(&self) {
396
500
  if self.socket.is_none() {
397
501
  return;
@@ -422,6 +526,14 @@ impl TmuxBackend {
422
526
  }
423
527
  }
424
528
 
529
+ /// ---
530
+ /// purpose: 选出运行期该用的 tmux 后端——优先跟随 state 里持久化的 endpoint,没有才按 workspace 派生
531
+ /// params:
532
+ /// workspace: 兜底派生 socket 的来源
533
+ /// state: 运行期状态 JSON;从中读 tmux_endpoint,其次 tmux_socket
534
+ /// returns: 后端 + 实际使用的 endpoint + 来源标记(三者一并返回,便于日志说清为什么连这台)
535
+ /// boundary: 只做选择,不探活、不创建 server;state 里的 endpoint 即便已失效也照选,失败留给后续命令暴露
536
+ /// ---
425
537
  pub(crate) fn tmux_backend_for_runtime_state_or_workspace(
426
538
  workspace: &Path,
427
539
  state: Option<&serde_json::Value>,
@@ -442,6 +554,16 @@ pub(crate) fn tmux_backend_for_runtime_state_or_workspace(
442
554
  }
443
555
  }
444
556
 
557
+ /// ---
558
+ /// purpose: 同 tmux_backend_for_runtime_state_or_workspace,但用注入的 runner,给测试断言选择逻辑
559
+ /// params:
560
+ /// runner: 执行 seam
561
+ /// workspace: 兜底派生 socket 的来源
562
+ /// state: 运行期状态 JSON
563
+ /// returns: 后端 + 实际使用的 endpoint + 来源标记
564
+ /// cfg: test
565
+ /// boundary: 仅测试编译;选择分支必须与生产版本同构,否则测的不是同一件事
566
+ /// ---
445
567
  #[cfg(test)]
446
568
  pub(crate) fn tmux_backend_with_runner_for_runtime_state_or_workspace(
447
569
  runner: Box<dyn CommandRunner>,
@@ -496,6 +618,13 @@ fn runtime_tmux_endpoint_from_state(
496
618
  /// identical workspace identity. Adding a wrapper (not calling
497
619
  /// `socket_name_for_workspace` directly outside this file) keeps the
498
620
  /// existing internal API stable.
621
+ /// ---
622
+ /// purpose: 对外暴露「workspace 身份短哈希」,让 tmux 与 ConPTY 两个后端看到同一个 workspace 身份
623
+ /// params:
624
+ /// workspace: 工作区路径;先 canonicalize 再哈希
625
+ /// returns: 12 位十六进制串,即 socket 名去掉 ta- 前缀的部分
626
+ /// boundary: 与 socket_name_for_workspace 同源,禁止另起一套哈希;canonicalize 失败时退回原始路径参与哈希,故未落盘的路径可能与落盘后不同值
627
+ /// ---
499
628
  pub fn workspace_short_hash_pub(workspace: &Path) -> String {
500
629
  // The tmux short-socket name is `ta-<12 hex>`; the workspace-hash
501
630
  // used by the ConPTY workspace identity is the raw 12-hex portion
@@ -508,6 +637,13 @@ pub fn workspace_short_hash_pub(workspace: &Path) -> String {
508
637
  /// Public re-export of the crate-private `runtime_tmux_endpoint_from_state`
509
638
  /// probe. `transport_factory` uses this to spot the legacy tmux endpoint
510
639
  /// in `state` without duplicating the field-precedence logic.
640
+ /// ---
641
+ /// purpose: 对外暴露 state 里 tmux endpoint 的读取与字段优先级,避免调用方各写一份
642
+ /// params:
643
+ /// state: 运行期状态 JSON
644
+ /// returns: Some((endpoint, 来源标记)) 当 tmux_endpoint 或 tmux_socket 非空;两者都缺或为空则 None
645
+ /// boundary: 只读字段不探活;返回值里不会出现 workspace_fallback(那是选后端时才产生的来源)
646
+ /// ---
511
647
  pub fn runtime_tmux_endpoint_from_state_pub(
512
648
  state: Option<&serde_json::Value>,
513
649
  ) -> Option<(String, &'static str)> {
@@ -521,6 +657,16 @@ pub fn runtime_tmux_endpoint_from_state_pub(
521
657
  })
522
658
  }
523
659
 
660
+ /// ---
661
+ /// purpose: 由 workspace 路径确定性派生短 socket 名,保证 CLI、daemon 与后续每条命令都落到同一台 tmux server
662
+ /// params:
663
+ /// workspace: 工作区路径;先 canonicalize,失败则退回原始路径
664
+ /// returns: ta- 加 12 位十六进制(FNV-1a 低 48 位)
665
+ /// boundary:
666
+ /// - 必须短:AF_UNIX 路径长度有限,不能拿 session 名当 socket 名
667
+ /// - 用固定 FNV-1a 而非 std 默认哈希(后者跨版本不稳定)
668
+ /// - 只算名字,不判断该 socket 是否存在
669
+ /// ---
524
670
  pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
525
671
  let canonical = workspace
526
672
  .canonicalize()
@@ -530,10 +676,24 @@ pub(crate) fn socket_name_for_workspace(workspace: &Path) -> String {
530
676
  format!("ta-{:012x}", hasher.finish() & 0xffff_ffff_ffff)
531
677
  }
532
678
 
679
+ /// ---
680
+ /// purpose: 给出 workspace 专属 socket 的文件路径
681
+ /// params:
682
+ /// workspace: 工作区路径
683
+ /// returns: 已存在的 socket 路径优先;否则给默认根下的预期路径;Windows 上恒为 None
684
+ /// boundary: 返回 Some 不代表 socket 存在(可能只是「按约定应该在这」);要判存在用 socket_probe_missing_for_workspace
685
+ /// ---
533
686
  pub(crate) fn socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
534
687
  socket_path_for_name(&socket_name_for_workspace(workspace))
535
688
  }
536
689
 
690
+ /// ---
691
+ /// purpose: 探测 workspace 专属 socket 当前是否不存在
692
+ /// params:
693
+ /// workspace: 工作区路径
694
+ /// returns: 在已知 socket 根下都找不到该名字时为 true
695
+ /// boundary: 只看文件是否存在,不连不握手——socket 文件在但 server 已死也会报 false
696
+ /// ---
537
697
  pub(crate) fn socket_probe_missing_for_workspace(workspace: &Path) -> bool {
538
698
  existing_socket_path_for_workspace(workspace).is_none()
539
699
  }
@@ -542,6 +702,14 @@ fn existing_socket_path_for_workspace(workspace: &Path) -> Option<PathBuf> {
542
702
  existing_socket_path_for_name(&socket_name_for_workspace(workspace))
543
703
  }
544
704
 
705
+ /// ---
706
+ /// purpose: 把短 socket 名解析成文件路径
707
+ /// params:
708
+ /// socket_name: 短名;空串、"default"、绝对路径三种输入都视为「无短名可解析」
709
+ /// returns: 已存在则返回实际路径;否则 Unix 上给 /tmp/tmux-<uid>/<name> 预期路径,Windows 上 None
710
+ /// cfg: unix 分支读 geteuid;not(unix) 分支恒 None
711
+ /// boundary: 不创建目录、不创建 socket;返回 Some 不代表文件存在
712
+ /// ---
545
713
  pub(crate) fn socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
546
714
  if socket_name.is_empty() || socket_name == "default" || Path::new(socket_name).is_absolute() {
547
715
  return None;
@@ -581,6 +749,13 @@ fn existing_socket_path_for_name(socket_name: &str) -> Option<PathBuf> {
581
749
  None
582
750
  }
583
751
 
752
+ /// ---
753
+ /// purpose: socket 找不到时给操作者的可执行提示,把期望的 socket 名与找过的目录都写出来
754
+ /// params:
755
+ /// workspace: 工作区路径,用于派生 socket 名
756
+ /// returns: 含 socket 名、已搜索根目录列表与下一步命令的单行提示
757
+ /// boundary: 只生成文案,不重试、不修复、不创建 socket
758
+ /// ---
584
759
  pub(crate) fn socket_missing_hint_for_workspace(workspace: &Path) -> String {
585
760
  let socket_name = socket_name_for_workspace(workspace);
586
761
  let roots = tmux_socket_roots()
@@ -593,6 +768,15 @@ pub(crate) fn socket_missing_hint_for_workspace(workspace: &Path) -> String {
593
768
  )
594
769
  }
595
770
 
771
+ /// ---
772
+ /// purpose: 生成让操作者手工 attach 到指定窗口的 tmux 命令行(按 workspace 派生 socket)
773
+ /// params:
774
+ /// workspace: 决定 -S 后面的 socket 路径
775
+ /// session_name: attach 目标 session
776
+ /// window_name: attach 目标 window
777
+ /// returns: 完整命令字符串;socket 路径解析不出时 None
778
+ /// boundary: 只生成文案、不执行、不验证 session/window 是否存在;endpoint 已持久化在 state 里时应改用 attach_command_for_runtime_state_or_workspace,否则会指到错误的 socket
779
+ /// ---
596
780
  pub(crate) fn attach_command_for_workspace(
597
781
  workspace: &Path,
598
782
  session_name: &SessionName,
@@ -607,6 +791,14 @@ pub(crate) fn attach_command_for_workspace(
607
791
  ))
608
792
  }
609
793
 
794
+ /// ---
795
+ /// purpose: 同 attach_command_for_workspace,但只到 session 一级(不指定 window)
796
+ /// params:
797
+ /// workspace: 决定 -S 后面的 socket 路径
798
+ /// session_name: attach 目标 session
799
+ /// returns: 完整命令字符串;socket 路径解析不出时 None
800
+ /// boundary: 只生成文案、不执行、不验证 session 是否存在
801
+ /// ---
610
802
  pub(crate) fn attach_command_for_session(
611
803
  workspace: &Path,
612
804
  session_name: &SessionName,
@@ -619,6 +811,14 @@ pub(crate) fn attach_command_for_session(
619
811
  ))
620
812
  }
621
813
 
814
+ /// ---
815
+ /// purpose: 按 transport 自报的 endpoint 生成 attach 命令,保证提示指向它真正在用的那台 server
816
+ /// params:
817
+ /// transport: 提供 tmux_endpoint 的传输后端
818
+ /// session_name: attach 目标 session
819
+ /// returns: 完整命令字符串;后端报不出 endpoint(非 tmux / 测试替身)时 None
820
+ /// boundary: 只生成文案、不执行;endpoint 是绝对路径用 -S、短名用 -L,由 attach_command_for_endpoint_session 分辨
821
+ /// ---
622
822
  pub(crate) fn attach_command_for_transport_session(
623
823
  transport: &dyn crate::transport::Transport,
624
824
  session_name: &SessionName,
@@ -633,6 +833,16 @@ pub(crate) fn attach_command_for_transport_session(
633
833
  /// socket — otherwise operators are told to attach to a socket where the
634
834
  /// session does not exist. Falls back to workspace-hash when state has no
635
835
  /// persisted endpoint.
836
+ /// ---
837
+ /// purpose: 生成 attach 到指定窗口的命令,优先跟随 state 里持久化的 endpoint,没有才回落 workspace 派生 socket
838
+ /// params:
839
+ /// workspace: 回落路径的来源
840
+ /// state: 运行期状态 JSON;从中读 tmux_endpoint / tmux_socket
841
+ /// session_name: attach 目标 session
842
+ /// window_name: attach 目标 window
843
+ /// returns: 完整命令字符串;两条路都算不出 socket 时 None
844
+ /// boundary: 只生成文案、不执行、不验证目标存在;endpoint 是绝对路径用 -S、短名用 -L
845
+ /// ---
636
846
  pub(crate) fn attach_command_for_runtime_state_or_workspace(
637
847
  workspace: &Path,
638
848
  state: Option<&serde_json::Value>,
@@ -656,6 +866,15 @@ pub(crate) fn attach_command_for_runtime_state_or_workspace(
656
866
  attach_command_for_workspace(workspace, session_name, window_name)
657
867
  }
658
868
 
869
+ /// ---
870
+ /// purpose: 同 attach_command_for_runtime_state_or_workspace,但只到 session 一级
871
+ /// params:
872
+ /// workspace: 回落路径的来源
873
+ /// state: 运行期状态 JSON
874
+ /// session_name: attach 目标 session
875
+ /// returns: 完整命令字符串;两条路都算不出 socket 时 None
876
+ /// boundary: 只生成文案、不执行、不验证 session 存在
877
+ /// ---
659
878
  pub(crate) fn attach_command_for_runtime_state_session_or_workspace(
660
879
  workspace: &Path,
661
880
  state: Option<&serde_json::Value>,
@@ -676,6 +895,15 @@ fn attach_command_for_endpoint_session(endpoint: &str, session_name: &SessionNam
676
895
  format!("tmux {flag} {endpoint} attach -t {}", session_name.as_str())
677
896
  }
678
897
 
898
+ /// ---
899
+ /// purpose: 为一组窗口批量生成 attach 命令
900
+ /// params:
901
+ /// workspace: 决定 socket 路径
902
+ /// session_name: attach 目标 session
903
+ /// window_names: 待生成的窗口名序列
904
+ /// returns: 生成成功的命令列表;算不出 socket 的条目被静默丢弃,故长度可能短于入参
905
+ /// boundary: 只生成文案、不执行;丢弃发生在整体层面(socket 算不出就一条都没有),调用方不能假定下标与入参对齐
906
+ /// ---
679
907
  pub(crate) fn attach_commands_for_windows<'a>(
680
908
  workspace: &Path,
681
909
  session_name: &SessionName,
@@ -689,6 +917,12 @@ pub(crate) fn attach_commands_for_windows<'a>(
689
917
  .collect()
690
918
  }
691
919
 
920
+ /// ---
921
+ /// purpose: 列出本机可能放 tmux socket 的根目录
922
+ /// returns: Unix 上为 /tmp/tmux-<uid> 加 TMPDIR/tmux-<uid>(排序去重);Windows 上为空
923
+ /// cfg: unix 读 geteuid 与 TMPDIR;not(unix) 返回空表示「本平台无此约定」
924
+ /// boundary: 只列约定目录,不验证目录存在、不列举里面的 socket(那是 tmux_socket_endpoints)
925
+ /// ---
692
926
  pub(crate) fn tmux_socket_roots() -> Vec<PathBuf> {
693
927
  // Batch 1: `/tmp/tmux-<uid>` root enumeration is Unix-only tmux
694
928
  // convention. On Windows return empty so the caller loops zero
@@ -711,6 +945,12 @@ pub(crate) fn tmux_socket_roots() -> Vec<PathBuf> {
711
945
  }
712
946
  }
713
947
 
948
+ /// ---
949
+ /// purpose: 枚举本机所有已存在的 tmux socket 路径
950
+ /// returns: 各 socket 根下真正是 socket 文件的绝对路径,排序去重;Windows 上为空
951
+ /// cfg: unix 才用 is_socket 过滤;not(unix) 直接跳过(该平台 socket 根为空)
952
+ /// boundary: 只看文件类型,不连不握手——列出的 socket 可能属于已死的 server;不做任何删除
953
+ /// ---
714
954
  pub(crate) fn tmux_socket_endpoints() -> Vec<String> {
715
955
  let mut endpoints = Vec::new();
716
956
  for root in tmux_socket_roots() {
@@ -747,6 +987,11 @@ pub(crate) fn tmux_socket_endpoints() -> Vec<String> {
747
987
  endpoints
748
988
  }
749
989
 
990
+ /// ---
991
+ /// purpose: 从当前进程所在的 tmux 环境($TMUX)反推它连的是哪个 socket
992
+ /// returns: $TMUX 首段是绝对路径时返回该路径;$TMUX 未设 / 为空 / 首段非绝对路径时 None
993
+ /// boundary: 读进程环境,只在自己就跑在 tmux 里时有意义;不校验该 socket 仍可用
994
+ /// ---
750
995
  pub(crate) fn socket_name_from_tmux_env() -> Option<String> {
751
996
  let tmux = std::env::var("TMUX")
752
997
  .ok()
@@ -1041,6 +1286,38 @@ impl TmuxBackend {
1041
1286
  Err(subprocess_error(argv, output))
1042
1287
  }
1043
1288
  }
1289
+
1290
+ /// 任意非 0 tmux mode。best-effort,查询失败 → None。
1291
+ ///
1292
+ /// Cherry-pick 866939b1 冲突裁定:本线没有 `inject_journal` 模块(modify/delete),
1293
+ /// 不复活整份 journal。`pane_mode_from_raw` 识别 copy/tree/view/client;
1294
+ /// 一切 Some(mode) 都算在 mode(头注释「非 0 先 cancel」)。
1295
+ fn pane_in_mode(&self, target: &Target) -> Option<bool> {
1296
+ let raw = self.query(target, PaneField::PaneMode).ok().flatten()?;
1297
+ Some(pane_mode_from_raw(Some(raw)).is_some())
1298
+ }
1299
+
1300
+ fn pane_mode(&self, target: &Target) -> Option<PaneMode> {
1301
+ let raw = self.query(target, PaneField::PaneMode).ok().flatten()?;
1302
+ pane_mode_from_raw(Some(raw))
1303
+ }
1304
+
1305
+ /// 发 Enter 前归零接收态。A1/A3/A4/A6 唯一入口。
1306
+ ///
1307
+ /// 1. 非 0 pane_mode → `tmux_cancel_mode_argv`(按真实 mode 分派,不是硬编码 Copy)
1308
+ /// cancel 失败不 fail 提交。E55:不送 Escape/C-c。
1309
+ ///
1310
+ /// 用户裁定 2026-08-23:**不发 ESC,也不发 ESC 的替代品**(含字面 CSI 201~)。
1311
+ /// 发 ESC/CSI 的唯一好处是让消息当场直接上屏;本项目只要求「下一次工具调用时能上屏」
1312
+ /// ⇒ 不存在需要它才能满足的场景,发它没有收益,只有污染 composer 输入的风险。
1313
+ fn prepare_pane_for_submit(&self, target: &Target, close_bracketed_paste: bool) {
1314
+ let _ = close_bracketed_paste;
1315
+ let pane = pane_from_target(target);
1316
+ if let Some(mode) = self.pane_mode(target) {
1317
+ let argv = crate::transport::tmux_cancel_mode_argv(&pane, mode);
1318
+ let _ = self.run_inject_stage(&argv, InjectStage::Submit);
1319
+ }
1320
+ }
1044
1321
  }
1045
1322
 
1046
1323
  fn subprocess_error(argv: Vec<String>, output: CommandOutput) -> TransportError {
@@ -1128,6 +1405,84 @@ fn inject_verification_for_payload(payload: &InjectPayload) -> InjectVerificatio
1128
1405
  /// U1 #7: the exact delivery-token marker a token payload carries
1129
1406
  /// (`[team-agent-token:<id>]`). Use the full marker, not only the prefix, so an old
1130
1407
  /// scrollback token cannot verify a new message.
1408
+ /// Measured cursor-agent 2026.08.11 floor: text and first Enter ≥1s apart
1409
+ /// (phase0 M2-5/M2-6; `.team/scripts/cursor_send.sh`).
1410
+ /// Grok uses the same 1s floor (悬案B:零地板时 Enter 落在折叠窗口内被吞).
1411
+ pub const CURSOR_PASTE_TO_SUBMIT_FLOOR: Duration = Duration::from_secs(1);
1412
+
1413
+ thread_local! {
1414
+ static PASTE_TO_SUBMIT_FLOOR: Cell<Duration> = const { Cell::new(Duration::ZERO) };
1415
+ static CURSOR_SINGLE_ENTER: Cell<bool> = const { Cell::new(false) };
1416
+ }
1417
+
1418
+ /// Run `f` with a paste→Enter floor. Delivery sets 1s for CursorAgent and Grok.
1419
+ /// Tests pass a small non-zero Duration to cover the sleep branch.
1420
+ /// Default is ZERO — claude/codex and unset callers pay nothing.
1421
+ /// This is not TEAM_AGENT_TEST_TMP (that var is a path, always set in seats).
1422
+ /// ---
1423
+ /// purpose: 在 f 执行期间设定「粘贴到回车之间至少等多久」的地板,退出时恢复原值
1424
+ /// params:
1425
+ /// floor: 本次生效的最小间隔;ZERO 表示不等
1426
+ /// f: 在该地板下执行的闭包
1427
+ /// returns: 原样返回 f 的返回值
1428
+ /// boundary: thread_local 作用域,不跨线程;只设阈值,真正的等待发生在 sleep_remaining_paste_to_submit_floor;默认 ZERO,只有 delivery 对 cursor 才设 1s
1429
+ /// ---
1430
+ pub fn with_paste_to_submit_floor<R>(floor: Duration, f: impl FnOnce() -> R) -> R {
1431
+ PASTE_TO_SUBMIT_FLOOR.with(|cell| {
1432
+ let previous = cell.replace(floor);
1433
+ let result = f();
1434
+ cell.set(previous);
1435
+ result
1436
+ })
1437
+ }
1438
+
1439
+ /// ---
1440
+ /// purpose: 读当前线程生效的粘贴到回车地板
1441
+ /// returns: 当前地板;未设过则为 ZERO
1442
+ /// boundary: 只读不改;ZERO 表示「不等」,不是「未配置」——两者在此不做区分
1443
+ /// ---
1444
+ pub(crate) fn current_paste_to_submit_floor() -> Duration {
1445
+ PASTE_TO_SUBMIT_FLOOR.with(Cell::get)
1446
+ }
1447
+
1448
+ /// ---
1449
+ /// purpose: cursor 注入单回车闸(delivery 仅对 CursorAgent 打开)
1450
+ /// contract: 默认关;打开后重试 Enter 仅当 token 仍在输入区且无 busy
1451
+ /// boundary: 不改 claude/grok/codex 默认重试;测试隔离下默认关
1452
+ /// ---
1453
+ pub fn with_cursor_single_enter<R>(enabled: bool, f: impl FnOnce() -> R) -> R {
1454
+ CURSOR_SINGLE_ENTER.with(|cell| {
1455
+ let previous = cell.replace(enabled);
1456
+ let result = f();
1457
+ cell.set(previous);
1458
+ result
1459
+ })
1460
+ }
1461
+
1462
+ /// ---
1463
+ /// purpose: 读当前线程的 cursor 单回车闸开关
1464
+ /// returns: 打开为 true;默认关
1465
+ /// boundary: 只读不改;开关只影响重按 Enter 的判据来源,不改变首次 Enter
1466
+ /// ---
1467
+ pub(crate) fn cursor_single_enter_enabled() -> bool {
1468
+ CURSOR_SINGLE_ENTER.with(Cell::get)
1469
+ }
1470
+
1471
+ /// ---
1472
+ /// purpose: 补齐粘贴到回车之间还差的等待时间
1473
+ /// params:
1474
+ /// pasted_at: 粘贴完成的时刻
1475
+ /// floor: 要求的最小间隔
1476
+ /// returns: 无返回值;已过地板则立即返回,否则阻塞剩余时长
1477
+ /// boundary: 阻塞当前线程;只保证「不早于」,不保证 TUI 真的处理完粘贴
1478
+ /// ---
1479
+ pub(crate) fn sleep_remaining_paste_to_submit_floor(pasted_at: Instant, floor: Duration) {
1480
+ let remain = floor.saturating_sub(pasted_at.elapsed());
1481
+ if !remain.is_zero() {
1482
+ std::thread::sleep(remain);
1483
+ }
1484
+ }
1485
+
1131
1486
  fn payload_token_marker(payload: &InjectPayload) -> Option<&str> {
1132
1487
  let text = payload.text()?;
1133
1488
  let start = text.find("[team-agent-token:")?;
@@ -1239,6 +1594,326 @@ fn token_in_bottom_n(text: &str, marker: &str, n: usize) -> bool {
1239
1594
  .any(|line| line.contains(marker))
1240
1595
  }
1241
1596
 
1597
+ /// purpose: token 在本轮注入中的三态观测
1598
+ /// contract:
1599
+ /// - Visible: 当前 capture 可见
1600
+ /// - Gone: 本轮见过,当前不可见(「见过又消失」才是 token 正信号)
1601
+ /// - NeverSeen: 本轮从未出现 — 不得把缺席写成 consumed
1602
+ /// boundary: 观测枚举,不是 SubmitVerification 裁定
1603
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1604
+ pub(crate) enum TokenSighting {
1605
+ Visible,
1606
+ Gone,
1607
+ NeverSeen,
1608
+ }
1609
+
1610
+ /// purpose: composer 区折叠占位符(含可选 #N 或 grok 行数)
1611
+ /// contract: 只看底部 n 非空行;id 来自 `pasted text #N` / `pasted content #N`;
1612
+ /// grok `[Pasted: N lines]` 无 #N,用 line_count 当身份,不得把「有占位符」当成同一次
1613
+ /// boundary: scrollback 里的旧占位符不算
1614
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1615
+ pub(crate) struct ComposerPastedPrompt {
1616
+ pub literal: &'static str,
1617
+ pub id: Option<u32>,
1618
+ pub line_count: Option<u32>,
1619
+ pub from_bottom: u32,
1620
+ }
1621
+
1622
+ /// purpose: 本次粘贴在 composer 上的可锁定身份
1623
+ /// contract: HashId = claude/codex `#N`;GrokLineCount = grok `[Pasted: N lines|N KB]` 的 N
1624
+ /// boundary: 无 #N 时禁止用「任意占位符」冒充 HashId
1625
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1626
+ pub(crate) enum PasteLatch {
1627
+ HashId(u32),
1628
+ GrokLineCount(u32),
1629
+ }
1630
+
1631
+ fn parse_pasted_hash_id(lower_line: &str) -> Option<u32> {
1632
+ for prefix in ["pasted text #", "pasted content #"] {
1633
+ if let Some(idx) = lower_line.find(prefix) {
1634
+ let rest = &lower_line[idx + prefix.len()..];
1635
+ let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1636
+ if digits.is_empty() {
1637
+ continue;
1638
+ }
1639
+ if let Ok(id) = digits.parse::<u32>() {
1640
+ return Some(id);
1641
+ }
1642
+ }
1643
+ }
1644
+ None
1645
+ }
1646
+
1647
+ fn parse_grok_pasted_line_count(lower_line: &str) -> Option<u32> {
1648
+ // grok: `[pasted: 42 lines]` or `[pasted: 13 kb]` — 冒号紧跟 pasted,没有 `#N`
1649
+ let idx = lower_line.find("pasted:")?;
1650
+ let rest = lower_line.get(idx.saturating_add("pasted:".len())..)?;
1651
+ let rest = rest.trim_start();
1652
+ let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1653
+ if digits.is_empty() {
1654
+ return None;
1655
+ }
1656
+ let after = rest.get(digits.len()..)?.trim_start();
1657
+ let unit_ok = after.starts_with("lines")
1658
+ || after.starts_with("line")
1659
+ || after.starts_with("kb")
1660
+ || after.starts_with("mb")
1661
+ || after.starts_with('b');
1662
+ if unit_ok {
1663
+ digits.parse().ok()
1664
+ } else {
1665
+ None
1666
+ }
1667
+ }
1668
+
1669
+ fn pasted_literal_in_line(lower_line: &str) -> Option<&'static str> {
1670
+ if lower_line.contains("pasted content") {
1671
+ Some("pasted content")
1672
+ } else if lower_line.contains("pasted text") {
1673
+ Some("pasted text")
1674
+ } else if parse_grok_pasted_line_count(lower_line).is_some() {
1675
+ Some("pasted:")
1676
+ } else {
1677
+ None
1678
+ }
1679
+ }
1680
+
1681
+ /// ---
1682
+ /// purpose: 只在 composer(底部 n 非空行)认折叠占位符
1683
+ /// params:
1684
+ /// text: 已规范化的 pane 尾文本
1685
+ /// n: 只看倒数 n 个非空行;超出这一窗口的占位符视为 transcript 残留,不算
1686
+ /// returns: 命中则给出字面量、可选编号、可选 grok 行数、距底行数;否则 None
1687
+ /// contract: 返回字面量 + 可选编号;没有占位符则 None
1688
+ /// boundary: 不替代 token 路径;编号缺失时调用方必须保守,不得报已消费
1689
+ /// ---
1690
+ pub(crate) fn pasted_prompt_in_composer(text: &str, n: usize) -> Option<ComposerPastedPrompt> {
1691
+ let mut from_bottom = 0u32;
1692
+ for line in text
1693
+ .lines()
1694
+ .rev()
1695
+ .filter(|line| !line.trim().is_empty())
1696
+ .take(n)
1697
+ {
1698
+ let lower = line.to_ascii_lowercase();
1699
+ if let Some(literal) = pasted_literal_in_line(&lower) {
1700
+ return Some(ComposerPastedPrompt {
1701
+ literal,
1702
+ id: parse_pasted_hash_id(&lower),
1703
+ line_count: parse_grok_pasted_line_count(&lower),
1704
+ from_bottom,
1705
+ });
1706
+ }
1707
+ from_bottom = from_bottom.saturating_add(1);
1708
+ }
1709
+ None
1710
+ }
1711
+
1712
+ fn latch_paste(text: &str, current: Option<PasteLatch>) -> Option<PasteLatch> {
1713
+ if current.is_some() {
1714
+ return current;
1715
+ }
1716
+ let prompt = pasted_prompt_in_composer(text, 15)?;
1717
+ if let Some(id) = prompt.id {
1718
+ return Some(PasteLatch::HashId(id));
1719
+ }
1720
+ prompt.line_count.map(PasteLatch::GrokLineCount)
1721
+ }
1722
+
1723
+ fn token_sighting(token_now: bool, token_ever_visible: bool) -> TokenSighting {
1724
+ if token_now {
1725
+ TokenSighting::Visible
1726
+ } else if token_ever_visible {
1727
+ TokenSighting::Gone
1728
+ } else {
1729
+ TokenSighting::NeverSeen
1730
+ }
1731
+ }
1732
+
1733
+ fn consumption_from_placeholder(text: &str, tracked: Option<PasteLatch>) -> Option<bool> {
1734
+ let prompt = pasted_prompt_in_composer(text, 15);
1735
+ match (tracked, prompt) {
1736
+ (Some(PasteLatch::HashId(id)), Some(p)) if p.id == Some(id) => Some(false),
1737
+ (Some(PasteLatch::HashId(_)), Some(p)) if p.id.is_some() => Some(true),
1738
+ (Some(PasteLatch::HashId(_)), Some(_)) => Some(false),
1739
+ (Some(PasteLatch::HashId(_)), None) => Some(true),
1740
+ (Some(PasteLatch::GrokLineCount(n)), Some(p)) if p.line_count == Some(n) => Some(false),
1741
+ // 不同 N:不能把「上一贴走了」写成已消费(无 #N,换行数 ≠ 同一次)
1742
+ (Some(PasteLatch::GrokLineCount(_)), Some(_)) => Some(false),
1743
+ (Some(PasteLatch::GrokLineCount(_)), None) => Some(true),
1744
+ (None, _) => Some(false),
1745
+ }
1746
+ }
1747
+
1748
+ /// ---
1749
+ /// purpose: 无身份时禁止把「未证实」升级成连按回车
1750
+ /// params:
1751
+ /// text: 最近一次 pane 尾文本
1752
+ /// marker: 本条消息的投递 token 完整标记
1753
+ /// tracked: 本次粘贴已锁定的占位符身份(编号或 grok 行数);None 表示没锁到身份
1754
+ /// returns: true 才允许再按一次 Enter
1755
+ /// contract: 只有 token 仍在 composer 底 15 非空行,或锁定的占位符身份仍在,才重按
1756
+ /// boundary: 不重粘文本;不把不同 grok 行数当成可重按的同一次;tracked 为 None 时占位符路径一律不重按(token 仍在底 15 非空行的重按与 tracked 无关)
1757
+ /// ---
1758
+ pub(crate) fn should_resubmit_enter(text: &str, marker: &str, tracked: Option<PasteLatch>) -> bool {
1759
+ if token_in_bottom_n(text, marker, 15) {
1760
+ return true;
1761
+ }
1762
+ let Some(prompt) = pasted_prompt_in_composer(text, 15) else {
1763
+ return false;
1764
+ };
1765
+ match tracked {
1766
+ Some(PasteLatch::HashId(id)) => prompt.id == Some(id),
1767
+ Some(PasteLatch::GrokLineCount(n)) => prompt.line_count == Some(n),
1768
+ None => false,
1769
+ }
1770
+ }
1771
+
1772
+ fn composer_joined(text: &str, n: usize) -> String {
1773
+ let mut lines: Vec<&str> = text
1774
+ .lines()
1775
+ .rev()
1776
+ .filter(|line| !line.trim().is_empty())
1777
+ .take(n)
1778
+ .collect();
1779
+ lines.reverse();
1780
+ lines.concat()
1781
+ }
1782
+
1783
+ /// ---
1784
+ /// purpose: 本次注入身份是否还在 composer(复用 token / #N / grok 行数,不认提示符皮肤)
1785
+ /// contract: 单行 token、折行拼接 token、或锁定的 PasteLatch 仍在底部 ⇒ true
1786
+ /// boundary: payload 里的 ❯/`composer>`/`>` 不算身份;无身份不得据此连按回车
1787
+ /// ---
1788
+ pub(crate) fn this_paste_identity_in_composer(
1789
+ text: &str,
1790
+ marker: &str,
1791
+ tracked: Option<PasteLatch>,
1792
+ ) -> bool {
1793
+ should_resubmit_enter(text, marker, tracked) || composer_joined(text, 15).contains(marker)
1794
+ }
1795
+
1796
+ /// ---
1797
+ /// purpose: Unverified 之后要不要补一颗 C-m(本次身份仍在,不是提示符长什么样)
1798
+ /// contract: 无 busy 且本次身份仍在 composer ⇒ true;达上限由调用方停
1799
+ /// boundary: consumed=None 不在这里决定(调用方不补);不重粘;不把 payload 提示符字符当守卫
1800
+ /// ---
1801
+ pub(crate) fn should_resend_enter_after_unverified(
1802
+ text: &str,
1803
+ marker: &str,
1804
+ tracked: Option<PasteLatch>,
1805
+ ) -> bool {
1806
+ if provider_busy_signal_in_tail(text) {
1807
+ return false;
1808
+ }
1809
+ this_paste_identity_in_composer(text, marker, tracked)
1810
+ }
1811
+
1812
+ /// ---
1813
+ /// purpose: Unverified 补发 B 是否该介入——只补 A 的 should_resubmit_enter 看不到的折行缺口
1814
+ /// params:
1815
+ /// text: 最近一次 pane 尾文本
1816
+ /// marker: 本条消息的投递 token 完整标记
1817
+ /// tracked: 本次粘贴已锁定的占位符身份(编号或 grok 行数);None 表示没锁到身份
1818
+ /// returns: true 才允许 B 再按一次 Enter
1819
+ /// contract: !busy 且 this_paste_identity_in_composer 且 !should_resubmit_enter ⇒ true
1820
+ /// boundary: A 已因 latch / 底15 单行 token 为真时不介入(含 A 已打满 cap);不重粘;consumed=None 不在这里决定
1821
+ /// ---
1822
+ pub(crate) fn should_resend_unverified_wrap_gap(
1823
+ text: &str,
1824
+ marker: &str,
1825
+ tracked: Option<PasteLatch>,
1826
+ ) -> bool {
1827
+ if should_resubmit_enter(text, marker, tracked) {
1828
+ return false;
1829
+ }
1830
+ should_resend_enter_after_unverified(text, marker, tracked)
1831
+ }
1832
+
1833
+ /// ---
1834
+ /// purpose: cursor 重按 Enter 的核实:token 仍在输入框,且回合未在进行
1835
+ /// contract: busy ⇒ 不重按;token 不在底部 5 非空行 ⇒ 不重按
1836
+ /// boundary: 不把 transcript 里的 pasted #N 当输入框;不替代 claude/grok 的 should_resubmit_enter
1837
+ /// ---
1838
+ pub(crate) fn should_resubmit_enter_cursor(text: &str, marker: &str) -> bool {
1839
+ if provider_busy_signal_in_tail(text) {
1840
+ return false;
1841
+ }
1842
+ token_in_bottom_n(text, marker, 5)
1843
+ }
1844
+
1845
+ /// ---
1846
+ /// purpose: Phase 1 判定 TUI 侧注入已完成、可以按回车
1847
+ /// params:
1848
+ /// text: 最近一次 pane 尾文本
1849
+ /// marker: 本条消息的投递 token 完整标记
1850
+ /// returns: true 表示可以进入提交阶段
1851
+ /// contract: composer 已有折叠占位符 ⇒ 完成;短贴 token 可见且不是待折叠高块 ⇒ 完成
1852
+ /// boundary: token 出现在未折叠的高块里不算完成(按早了);只判「能不能按」,不判「按了有没有成」
1853
+ /// ---
1854
+ pub(crate) fn paste_ready_for_enter(text: &str, marker: &str) -> bool {
1855
+ if pasted_prompt_in_composer(text, 15).is_some() {
1856
+ return true;
1857
+ }
1858
+ text.contains(marker) && !raw_multiline_still_unfolded(text, marker)
1859
+ }
1860
+
1861
+ fn raw_multiline_still_unfolded(text: &str, marker: &str) -> bool {
1862
+ if pasted_prompt_in_composer(text, 15).is_some() {
1863
+ return false;
1864
+ }
1865
+ if !text.contains(marker) {
1866
+ return false;
1867
+ }
1868
+ text.lines().filter(|line| !line.trim().is_empty()).count() > 15
1869
+ }
1870
+
1871
+ /// ---
1872
+ /// purpose: 把 token 三态 + 本次 #N / grok 占位符合成消费判定
1873
+ /// params:
1874
+ /// text: 最近一次 pane 尾文本
1875
+ /// marker: 本条消息的投递 token 完整标记
1876
+ /// token_ever_visible: 本次注入期间是否曾在 pane 上见过该 token
1877
+ /// tracked: 本次粘贴锁定的占位符身份;None 表示没锁到
1878
+ /// returns: Some(true) 已消费;Some(false) 未消费;本函数不产生 None
1879
+ /// contract:
1880
+ /// Some(true)=已消费;Some(false)=未消费(含 NeverSeen 且无正信号 → 未证实走 false)
1881
+ /// Gone 必须先复核 PasteLatch 身份:占位符仍在 composer ⇒ 未消费
1882
+ /// None 不由本函数产生;调用方在 capture 失败时保持 None,必须落到 Unverified
1883
+ /// boundary: 不修 BUSY 入队;Working 信号由调用方在 Some(false) 之后看;None 不得当成功
1884
+ /// ---
1885
+ pub(crate) fn consumption_from_capture(
1886
+ text: &str,
1887
+ marker: &str,
1888
+ token_ever_visible: bool,
1889
+ tracked: Option<PasteLatch>,
1890
+ ) -> Option<bool> {
1891
+ let token_now = token_in_bottom_n(text, marker, 15);
1892
+ match token_sighting(token_now, token_ever_visible) {
1893
+ TokenSighting::Visible => Some(false),
1894
+ TokenSighting::Gone => gone_consumption(text, tracked),
1895
+ TokenSighting::NeverSeen => consumption_from_placeholder(text, tracked),
1896
+ }
1897
+ }
1898
+
1899
+ /// ---
1900
+ /// purpose: Gone 分支在判已消费前复核 composer 占位符(PasteLatch 身份)
1901
+ /// contract: 锁定身份仍在,或未锁定但 grok `[Pasted: …]` 仍在 ⇒ Some(false)
1902
+ /// boundary: 空 composer + 无 grok 占位符保持 Some(true)(claude 短贴路径不变)
1903
+ /// ---
1904
+ fn gone_consumption(text: &str, tracked: Option<PasteLatch>) -> Option<bool> {
1905
+ if tracked.is_some() {
1906
+ return match consumption_from_placeholder(text, tracked) {
1907
+ Some(false) => Some(false),
1908
+ _ => Some(true),
1909
+ };
1910
+ }
1911
+ match pasted_prompt_in_composer(text, 15) {
1912
+ Some(p) if p.literal == "pasted:" => Some(false),
1913
+ _ => Some(true),
1914
+ }
1915
+ }
1916
+
1242
1917
  fn marker_position_from_bottom(text: &str, marker: &str) -> Option<u32> {
1243
1918
  let mut from_bottom = 0u32;
1244
1919
  for line in text.lines().rev().filter(|line| !line.trim().is_empty()) {
@@ -1259,6 +1934,7 @@ fn provider_busy_signal_in_tail(text: &str) -> bool {
1259
1934
  let lower = line.to_ascii_lowercase();
1260
1935
  lower.contains("working")
1261
1936
  || lower.contains("thinking")
1937
+ || lower.contains("processing")
1262
1938
  || lower.contains("esc to interrupt")
1263
1939
  || line.contains('●')
1264
1940
  || line.contains('⏳')
@@ -1274,9 +1950,52 @@ fn provider_busy_signal_in_tail(text: &str) -> bool {
1274
1950
  || line.contains('⠏')
1275
1951
  || line.contains('✶')
1276
1952
  || line.contains('✢')
1953
+ || line.contains('✻')
1954
+ || line.contains('✽')
1955
+ || line.contains('✳')
1277
1956
  })
1278
1957
  }
1279
1958
 
1959
+ /// ---
1960
+ /// purpose: 入箱 vs 开跑(G4)。给定最近一次 pane 尾,回答席位是否因此开跑。
1961
+ /// params:
1962
+ /// text: 最近一次 pane 尾文本
1963
+ /// marker: 本条消息的投递 token 完整标记;None 表示本次载荷没有 token 可查
1964
+ /// returns: 三态观测值,绝不返回 NotRequired(那是空载荷路径由调用方直接给的)
1965
+ /// contract:
1966
+ /// - LeaderNewTurnBoundaryVerified = 底部 busy 信号
1967
+ /// - LeaderNewTurnBoundaryMissing = composer 仍有本次粘贴(占位符或 token)且无 busy
1968
+ /// - NotYetObserved = 其余(含 composer 已空但无 busy)→ 不知道,不得报开跑
1969
+ /// boundary:
1970
+ /// - 不是 SubmitVerification / 不是投递闸门;不许用过了 N 秒来判
1971
+ /// - busy 判据是底 15 非空行的宽子串匹配,正文或 transcript 残留可让它假阳,判定强度到此为止
1972
+ /// ---
1973
+ pub(crate) fn observe_turn_from_capture(text: &str, marker: Option<&str>) -> TurnVerification {
1974
+ if provider_busy_signal_in_tail(text) {
1975
+ return TurnVerification::LeaderNewTurnBoundaryVerified;
1976
+ }
1977
+ let paste_still = pasted_prompt_in_composer(text, 15).is_some();
1978
+ let token_still = marker
1979
+ .map(|m| token_in_bottom_n(text, m, 15))
1980
+ .unwrap_or(false);
1981
+ if paste_still || token_still {
1982
+ return TurnVerification::LeaderNewTurnBoundaryMissing;
1983
+ }
1984
+ TurnVerification::NotYetObserved
1985
+ }
1986
+
1987
+ fn turn_verification_for_payload(
1988
+ payload: &InjectPayload,
1989
+ last_text: Option<&str>,
1990
+ ) -> TurnVerification {
1991
+ match payload {
1992
+ InjectPayload::Empty => TurnVerification::NotRequired,
1993
+ InjectPayload::Text(_) | InjectPayload::TextSkipConsumptionPoll(_) => {
1994
+ observe_turn_from_capture(last_text.unwrap_or(""), payload_token_marker(payload))
1995
+ }
1996
+ }
1997
+ }
1998
+
1280
1999
  fn submit_attempt_observation(
1281
2000
  attempt_index: u32,
1282
2001
  captured: &CapturedText,
@@ -1284,12 +2003,24 @@ fn submit_attempt_observation(
1284
2003
  elapsed_ms: u64,
1285
2004
  ) -> SubmitAttemptObservation {
1286
2005
  let marker_position = marker.and_then(|m| marker_position_from_bottom(&captured.text, m));
2006
+ // marker 在时也走 pasted_prompt_match:折叠占位符是编排长粘贴的正信号,
2007
+ // 不能只在无 token 载荷上才认。matched 仍表示「composer 里还有待提交信号」。
1287
2008
  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
- )
2009
+ if token_in_bottom_n(&captured.text, marker, 15) {
2010
+ (
2011
+ true,
2012
+ marker_position.map(|_| marker.to_string()),
2013
+ marker_position,
2014
+ )
2015
+ } else if let Some((literal, where_in_tail)) = pasted_prompt_match(&captured.text) {
2016
+ (
2017
+ pasted_prompt_in_composer(&captured.text, 15).is_some(),
2018
+ Some(literal.to_string()),
2019
+ Some(where_in_tail),
2020
+ )
2021
+ } else {
2022
+ (false, None, None)
2023
+ }
1293
2024
  } else if let Some((literal, where_in_tail)) = pasted_prompt_match(&captured.text) {
1294
2025
  (true, Some(literal.to_string()), Some(where_in_tail))
1295
2026
  } else {
@@ -1311,14 +2042,7 @@ fn submit_attempt_observation(
1311
2042
  /// appears in the bottom N non-empty lines. Narrower than the full-Tail(80) check
1312
2043
  /// that caused scrollback ghost matches (E50 defect B).
1313
2044
  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
- })
2045
+ pasted_prompt_in_composer(text, n).is_some()
1322
2046
  }
1323
2047
 
1324
2048
  /// E50 PR-1 (0.3.24 P0, pasted-prompt 假阴诊断): factor `capture_has_pasted_content_prompt`
@@ -1335,12 +2059,26 @@ fn pasted_prompt_in_bottom(text: &str, n: usize) -> bool {
1335
2059
  /// where it remains in the last 80 lines. The current matcher cannot
1336
2060
  /// distinguish that from a live placeholder; this fn surfaces the data
1337
2061
  /// the operator needs to see (PR-2 will USE it to fix the criterion).
2062
+ /// ---
2063
+ /// purpose: 在整段 pane 尾里找折叠占位符字面量,并给出它距底部多少非空行
2064
+ /// params:
2065
+ /// text: pane 尾文本;不限窗口,整段都找
2066
+ /// returns: Some((命中的字面量, 距底非空行数));无命中则 None;字面量只出现在被裁掉的空白行时保守记为 0
2067
+ /// boundary:
2068
+ /// - 只做取证不做判定:距底行数大说明多半在 transcript 而非 composer,但本函数不替调用方下结论
2069
+ /// - 与 pasted_prompt_in_composer 不同——后者限定底部窗口,是判定用;本函数不限窗口,是诊断用
2070
+ /// ---
1338
2071
  pub(crate) fn pasted_prompt_match(text: &str) -> Option<(&'static str, u32)> {
1339
2072
  let lower = text.to_ascii_lowercase();
1340
2073
  let lit = if lower.contains("pasted content") {
1341
2074
  "pasted content"
1342
2075
  } else if lower.contains("pasted text") {
1343
2076
  "pasted text"
2077
+ } else if text
2078
+ .lines()
2079
+ .any(|line| parse_grok_pasted_line_count(&line.to_ascii_lowercase()).is_some())
2080
+ {
2081
+ "pasted:"
1344
2082
  } else {
1345
2083
  return None;
1346
2084
  };
@@ -1351,7 +2089,13 @@ pub(crate) fn pasted_prompt_match(text: &str) -> Option<(&'static str, u32)> {
1351
2089
  .collect();
1352
2090
  let mut from_bottom: u32 = 0;
1353
2091
  for line in non_empty.iter().rev() {
1354
- if line.to_ascii_lowercase().contains(lit) {
2092
+ let line_lower = line.to_ascii_lowercase();
2093
+ let hit = if lit == "pasted:" {
2094
+ parse_grok_pasted_line_count(&line_lower).is_some()
2095
+ } else {
2096
+ line_lower.contains(lit)
2097
+ };
2098
+ if hit {
1355
2099
  return Some((lit, from_bottom));
1356
2100
  }
1357
2101
  from_bottom = from_bottom.saturating_add(1);
@@ -1369,6 +2113,17 @@ pub(crate) fn pasted_prompt_match(text: &str) -> Option<(&'static str, u32)> {
1369
2113
  /// 4. Cap at ~1200 bytes (UTF-8 safe truncation).
1370
2114
  /// Returns `(excerpt, line_count)`. Designed to be CHEAP — no regex crate
1371
2115
  /// dependency, simple byte scanning.
2116
+ /// ---
2117
+ /// purpose: 把 pane 抓屏加工成可安全写进 events.jsonl 的摘录
2118
+ /// params:
2119
+ /// raw: 原始抓屏文本
2120
+ /// tail_lines: 只保留倒数这么多非空行
2121
+ /// returns: (脱敏并截断后的摘录, 实际保留的行数)
2122
+ /// boundary:
2123
+ /// - 只做四件事:剥 ANSI、取尾部、脱敏常见密钥形状、按 1200 字节在字符边界截断
2124
+ /// - 脱敏是形状匹配,不是完备保证;非常见形状的凭据不会被识别
2125
+ /// - 输出仍是消息正文的一部分,只落本地事件文件,不外传
2126
+ /// ---
1372
2127
  pub(crate) fn scrub_pane_excerpt(raw: &str, tail_lines: usize) -> (String, u32) {
1373
2128
  let stripped = strip_ansi_escapes_inplace(raw);
1374
2129
  let lines: Vec<&str> = stripped
@@ -1530,6 +2285,11 @@ const PASTED_CONTENT_SUBMIT_ATTEMPTS: u32 = 3;
1530
2285
  /// submission when the first Enter was consumed but our readback was slow.
1531
2286
  const POST_SUBMIT_CONSUMPTION_ATTEMPTS: u32 = 3;
1532
2287
  const POST_SUBMIT_CONSUMPTION_POLL_MS: u64 = 60;
2288
+ /// post-Enter `capture()` 失败时只重读、不重按。计入 `InjectReport.attempts`。
2289
+ const POST_SUBMIT_CAPTURE_RETRY: u32 = 4;
2290
+ const POST_SUBMIT_CAPTURE_RETRY_MS: u64 = 20;
2291
+ /// Unverified 之后、本次身份仍在 composer 时,只补这一颗回车。
2292
+ const UNVERIFIED_COMPOSER_RESEND_MAX: u32 = 1;
1533
2293
 
1534
2294
  /// E46 (0.3.24 bug#5, C5 provider-agnostic detector): the pane's input region
1535
2295
  /// is "consumed" when the token text that was just visible BEFORE the Enter
@@ -1615,6 +2375,15 @@ pub const LEADER_PROVIDER_EXIT_MARKER_SUFFIX: &str = "exited with";
1615
2375
  /// 0.4.x (CR R6): build the leader exit marker text for `provider_label`.
1616
2376
  /// Used by both the shell wrapper (printf source) and the health check
1617
2377
  /// (capture substring) so they cannot drift.
2378
+ /// ---
2379
+ /// purpose: 单一来源地拼出 leader pane 的 provider 退出标记文本
2380
+ /// params:
2381
+ /// provider_label: 人类可读的 provider 名,原样嵌入标记
2382
+ /// returns: 前缀 + provider 名 + 后缀 拼成的标记(不含退出码,退出码由 shell 的 printf 补)
2383
+ /// boundary:
2384
+ /// - 写标记的 shell wrapper 与读标记的健康检查必须都用这一个函数,禁止各写一份字面量
2385
+ /// - 这是内容信号:pane 里出现同样文本(如 cat 日志)就会误触发,判定强度到此为止
2386
+ /// ---
1618
2387
  pub fn leader_provider_exit_marker(provider_label: &str) -> String {
1619
2388
  format!(
1620
2389
  "{LEADER_PROVIDER_EXIT_MARKER_PREFIX} {provider_label} {LEADER_PROVIDER_EXIT_MARKER_SUFFIX}"
@@ -1633,6 +2402,14 @@ pub fn leader_provider_exit_marker(provider_label: &str) -> String {
1633
2402
  ///
1634
2403
  /// Returns `(session_name, Some(pane_id))` when the ambient tmux
1635
2404
  /// responds, or `None` if `$TMUX` is unset / `display-message` fails.
2405
+ /// ---
2406
+ /// purpose: 探出当前 leader 进程正待在哪个环境 tmux 的哪个 session / pane 里
2407
+ /// returns: Some((session 名, 可选 pane 标识));TMUX 与 TMUX_PANE 都未设,或所有探测命令都失败或解析不出时 None(仅 TMUX_PANE 在设时仍会带 -t 探测)
2408
+ /// boundary:
2409
+ /// - 本文件唯一允许绕过 socket 绑定后端、直接 Command::new("tmux") 的例外,因为它的任务就是发现「我在哪」
2410
+ /// - 依次试 TMUX_PANE 定位与无 -t 的当前 pane,取第一个能解析出的结果;失败静默跳到下一条,不报错
2411
+ /// - 只读不写,不 attach、不改任何 tmux 状态
2412
+ /// ---
1636
2413
  pub fn probe_ambient_leader_pane_info() -> Option<(String, Option<String>)> {
1637
2414
  let pane = std::env::var("TMUX_PANE")
1638
2415
  .ok()
@@ -1712,6 +2489,15 @@ pub const WORKER_PROVIDER_EXIT_MARKER_SUFFIX: &str = "exited with";
1712
2489
  /// 0.5.39 Slice 2: build the worker exit marker text for `provider_label`.
1713
2490
  /// Used by both the worker shell wrapper (printf source) and future
1714
2491
  /// status/classifier code (capture substring) so they cannot drift.
2492
+ /// ---
2493
+ /// purpose: 单一来源地拼出 worker pane 的 provider 退出标记文本
2494
+ /// params:
2495
+ /// provider_label: 人类可读的 provider 名,原样嵌入标记
2496
+ /// returns: worker 专用前缀 + provider 名 + 后缀;与 leader 标记刻意不同,便于分清是哪种 pane 掉回 shell
2497
+ /// boundary:
2498
+ /// - 写标记的 worker shell wrapper 与读标记的分类代码必须都用这一个函数
2499
+ /// - 同为内容信号,pane 里出现同样文本即误触发
2500
+ /// ---
1715
2501
  pub fn worker_provider_exit_marker(provider_label: &str) -> String {
1716
2502
  format!(
1717
2503
  "{WORKER_PROVIDER_EXIT_MARKER_PREFIX} {provider_label} {WORKER_PROVIDER_EXIT_MARKER_SUFFIX}"
@@ -1725,6 +2511,20 @@ pub fn worker_provider_exit_marker(provider_label: &str) -> String {
1725
2511
  /// cascade into whole-server death). When the provider exits, the worker
1726
2512
  /// pane remains alive with an explicit worker exit marker, then runs an
1727
2513
  /// inert `sh` tail that does not read terminal input.
2514
+ /// ---
2515
+ /// purpose: 拼出 worker pane 的 shell 启动行,让 provider 作为子进程运行,退出后 pane 不塌成 [exited]
2516
+ /// params:
2517
+ /// argv: provider 启动命令,逐项做 shell 引用
2518
+ /// cwd: 进程工作目录
2519
+ /// env: 要导出的环境变量;键若出现在 env_unset 中则跳过,保证 unset 赢
2520
+ /// env_unset: 必须真正 unset 的键;先 unset 再导出
2521
+ /// provider_label: 嵌入退出标记的 provider 名
2522
+ /// returns: 单条 shell 命令行,依次为 cd、unset、env 导出与 provider(不带 exec)、printf 退出标记、exec 惰性 sh 尾巴
2523
+ /// boundary:
2524
+ /// - 只拼字符串,不执行、不 spawn
2525
+ /// - 与 leader 版的唯一实质差异是退出标记前缀;结构必须保持一致
2526
+ /// - 惰性尾巴不读 pane 标准输入,故该 pane 之后收到的注入不会被任何进程消费
2527
+ /// ---
1728
2528
  pub fn worker_shell_wrapper_command(
1729
2529
  argv: &[String],
1730
2530
  cwd: &Path,
@@ -1776,6 +2576,20 @@ pub fn worker_shell_wrapper_command(
1776
2576
  ///
1777
2577
  /// `provider_label` is a human-readable provider name (e.g. "claude",
1778
2578
  /// "codex") embedded in the exit marker for diagnostics.
2579
+ /// ---
2580
+ /// purpose: 拼出 leader pane 的 shell 启动行,让 provider 作为子进程运行,退出后 pane 保留并显示退出标记
2581
+ /// params:
2582
+ /// argv: provider 启动命令,逐项做 shell 引用
2583
+ /// cwd: 进程工作目录
2584
+ /// env: 要导出的环境变量;键若出现在 env_unset 中则跳过,保证 unset 赢
2585
+ /// env_unset: 必须真正 unset 的键;先 unset 再导出
2586
+ /// provider_label: 嵌入退出标记的 provider 名
2587
+ /// returns: 单条 shell 命令行,四段依次为 cd、unset、env 导出与 provider(刻意不带 exec)、printf 退出标记加 exec 惰性 sh 尾巴
2588
+ /// boundary:
2589
+ /// - 只拼字符串,不执行、不 spawn
2590
+ /// - 刻意不用 exec:用了 provider 就成了 pane 主进程,退出即塌成 [exited]
2591
+ /// - 退出标记文本必须来自 leader_provider_exit_marker,禁止在此写字面量
2592
+ /// ---
1779
2593
  pub fn leader_shell_wrapper_command(
1780
2594
  argv: &[String],
1781
2595
  cwd: &Path,
@@ -2020,10 +2834,18 @@ impl Transport for TmuxBackend {
2020
2834
  observer: Option<&dyn SubmitObserver>,
2021
2835
  ) -> Result<InjectReport, TransportError> {
2022
2836
  let pane = pane_from_target(target);
2837
+ let _pane_input = crate::lifecycle::pane_input_lock::acquire_or_proceed(
2838
+ crate::lifecycle::pane_input_lock::PaneInputLockRequest {
2839
+ workspace: self.event_workspace.as_deref(),
2840
+ target_key: pane.as_str(),
2841
+ operation: "inject",
2842
+ },
2843
+ );
2023
2844
  // U1 #7: pane readback signal for the non-pasted-prompt text path.
2024
2845
  let mut token_visible_for_report: Option<bool> = None;
2025
2846
  match payload {
2026
2847
  InjectPayload::Empty => {
2848
+ self.prepare_pane_for_submit(target, matches!(submit, Key::Enter));
2027
2849
  let argv = tmux_empty_inject_argv(&pane, submit);
2028
2850
  self.run_ok(&argv)?;
2029
2851
  notify_submit_observer(observer);
@@ -2053,9 +2875,12 @@ impl Transport for TmuxBackend {
2053
2875
  //
2054
2876
  // Design truth source: .team/artifacts/E55-delivery-architecture-design.html
2055
2877
  // Python parity: dynamic timeout max(2s, bytes/25000), poll 50ms.
2878
+ // Cursor Ink:文本与 Enter 必须分开发且间隔 ≥1s,否则第一次
2879
+ // Enter 被吞。token 可见性轮询的耗时算进这 1s;测试隔离下地板为 0。
2056
2880
  // ═══════════════════════════════════════════════════════════
2057
2881
  let inject_start = std::time::Instant::now();
2058
- let submit_argv = tmux_send_keys_argv(&pane, &[submit]);
2882
+ let pasted_at = inject_start;
2883
+ let submit_argv = tmux_send_submit_argv(&pane, submit);
2059
2884
 
2060
2885
  // Phase 1: token visibility poll — wait for the pasted text to
2061
2886
  // become visible in the pane before submitting. Dynamic timeout
@@ -2066,16 +2891,24 @@ impl Transport for TmuxBackend {
2066
2891
  size_based.max(2000)
2067
2892
  };
2068
2893
  let poll_start = std::time::Instant::now();
2069
- token_visible_for_report = if payload_token_marker(payload).is_some() {
2894
+ let mut token_ever_visible = false;
2895
+ let mut tracked_paste: Option<PasteLatch> = None;
2896
+ token_visible_for_report = if let Some(m) = payload_token_marker(payload) {
2070
2897
  let mut visible = false;
2071
2898
  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;
2899
+ match self.capture(target, CaptureRange::Tail(80)) {
2900
+ Ok(cap) => {
2901
+ tracked_paste = latch_paste(&cap.text, tracked_paste);
2902
+ if cap.text.contains(m) {
2903
+ visible = true;
2904
+ token_ever_visible = true;
2905
+ }
2906
+ // 折叠占位符出现 = TUI 侧注入完成。token 出现在未折叠高块里不算完成。
2907
+ if paste_ready_for_enter(&cap.text, m) {
2908
+ break;
2909
+ }
2076
2910
  }
2077
2911
  Err(_) => break, // tmux unavailable, skip poll
2078
- _ => {}
2079
2912
  }
2080
2913
  std::thread::sleep(Duration::from_millis(50));
2081
2914
  }
@@ -2096,6 +2929,7 @@ impl Transport for TmuxBackend {
2096
2929
  // the entire submit_and_verify loop — single send, no consumption
2097
2930
  // check, KeySentAfterVisibleToken verification.
2098
2931
  if !matches!(submit, Key::Enter) {
2932
+ self.prepare_pane_for_submit(target, false);
2099
2933
  self.run_inject_stage(&submit_argv, InjectStage::Submit)?;
2100
2934
  notify_submit_observer(observer);
2101
2935
  let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
@@ -2117,21 +2951,27 @@ impl Transport for TmuxBackend {
2117
2951
  });
2118
2952
  }
2119
2953
 
2954
+ sleep_remaining_paste_to_submit_floor(pasted_at, current_paste_to_submit_floor());
2955
+
2120
2956
  let marker = payload_token_marker(payload);
2121
2957
  let max_submit_attempts: u32 = 3;
2122
2958
  let mut consumption_attempts: u32 = 0;
2959
+ let mut capture_retries: u32 = 0;
2123
2960
  let mut consumed: Option<bool> = None;
2124
2961
  let mut attempts_detail: Vec<SubmitAttemptObservation> = Vec::new();
2125
2962
  let mut any_attempt_matched = false;
2126
2963
 
2127
2964
  let poll_consumption = !payload.skip_consumption_poll();
2128
2965
  if !poll_consumption {
2966
+ self.prepare_pane_for_submit(target, true);
2129
2967
  if self
2130
2968
  .run_inject_stage(&submit_argv, InjectStage::Submit)
2131
2969
  .is_ok()
2132
2970
  {
2133
2971
  notify_submit_observer(observer);
2134
2972
  consumption_attempts = 1;
2973
+ // 跳过消费门(leader skip)不是「没能判断」;保持既有 EnterSent。
2974
+ consumed = Some(true);
2135
2975
  }
2136
2976
  }
2137
2977
  let submit_attempt_limit = if poll_consumption {
@@ -2150,6 +2990,10 @@ impl Transport for TmuxBackend {
2150
2990
  if attempt > 0 {
2151
2991
  if let Some(m) = marker {
2152
2992
  if let Ok(cap) = self.capture(target, CaptureRange::Tail(40)) {
2993
+ if cap.text.contains(m) {
2994
+ token_ever_visible = true;
2995
+ }
2996
+ tracked_paste = latch_paste(&cap.text, tracked_paste);
2153
2997
  let obs = submit_attempt_observation(
2154
2998
  attempt_index,
2155
2999
  &cap,
@@ -2160,10 +3004,31 @@ impl Transport for TmuxBackend {
2160
3004
  any_attempt_matched = true;
2161
3005
  }
2162
3006
  attempts_detail.push(obs);
2163
- if !token_in_bottom_n(&cap.text, m, 15) {
3007
+ // NeverSeen 不得把 token 缺席写成 consumed;
3008
+ // 见过再消失,或本次占位符身份离开 composer,才停手。
3009
+ if consumption_from_capture(
3010
+ &cap.text,
3011
+ m,
3012
+ token_ever_visible,
3013
+ tracked_paste,
3014
+ ) == Some(true)
3015
+ {
2164
3016
  consumed = Some(true);
2165
3017
  break;
2166
3018
  }
3019
+ if cursor_single_enter_enabled() {
3020
+ if provider_busy_signal_in_tail(&cap.text) {
3021
+ consumed = Some(true);
3022
+ break;
3023
+ }
3024
+ if !should_resubmit_enter_cursor(&cap.text, m) {
3025
+ consumed = Some(false);
3026
+ break;
3027
+ }
3028
+ } else if !should_resubmit_enter(&cap.text, m, tracked_paste) {
3029
+ consumed = Some(false);
3030
+ break;
3031
+ }
2167
3032
  }
2168
3033
  }
2169
3034
  }
@@ -2178,9 +3043,13 @@ impl Transport for TmuxBackend {
2178
3043
  // send Enter, never Escape.
2179
3044
  let _ = escape_argv;
2180
3045
 
3046
+ // 发前归零接收态(tmux mode + 未闭合 bracketed paste)。
3047
+ // 所有 Enter 路径的收敛点:prepare_pane_for_submit。
3048
+ // mode 不是忙闲;cancel 失败不得让注入失败。E55: 不送 Escape/C-c。
3049
+ self.prepare_pane_for_submit(target, true);
3050
+
2181
3051
  // 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.
3052
+ // the pane in sim/test env). Break to consumed=None → Unverified.
2184
3053
  if self
2185
3054
  .run_inject_stage(&submit_argv, InjectStage::Submit)
2186
3055
  .is_err()
@@ -2196,17 +3065,27 @@ impl Transport for TmuxBackend {
2196
3065
  if attempt == 0 && matches!(token_visible_for_report, Some(false)) {
2197
3066
  token_visible_for_report =
2198
3067
  post_submit_token_visible(self, target, payload).unwrap_or(Some(false));
3068
+ if token_visible_for_report == Some(true) {
3069
+ token_ever_visible = true;
3070
+ }
2199
3071
  }
2200
3072
 
2201
- // Poll: token disappeared from bottom 15 lines = consumed.
2202
- // Capture failures → consumed=None (non-blocking).
3073
+ // Poll: token 三态 + 本次 #N 占位符。
3074
+ // Capture failures → 只重读 capture,绝不重粘、不加 Enter;读不到则 None
2203
3075
  if let Some(m) = marker {
2204
3076
  let mut found_consumed = false;
2205
- let mut capture_failed = false;
3077
+ let mut saw_capture = false;
3078
+ let mut consecutive_capture_failures: u32 = 0;
2206
3079
  for _ in 0..12 {
2207
3080
  std::thread::sleep(Duration::from_millis(100));
2208
3081
  match self.capture(target, CaptureRange::Tail(40)) {
2209
3082
  Ok(cap) => {
3083
+ consecutive_capture_failures = 0;
3084
+ saw_capture = true;
3085
+ if cap.text.contains(m) {
3086
+ token_ever_visible = true;
3087
+ }
3088
+ tracked_paste = latch_paste(&cap.text, tracked_paste);
2210
3089
  let obs = submit_attempt_observation(
2211
3090
  attempt_index,
2212
3091
  &cap,
@@ -2217,18 +3096,37 @@ impl Transport for TmuxBackend {
2217
3096
  any_attempt_matched = true;
2218
3097
  }
2219
3098
  attempts_detail.push(obs);
2220
- if !token_in_bottom_n(&cap.text, m, 15) {
3099
+ if consumption_from_capture(
3100
+ &cap.text,
3101
+ m,
3102
+ token_ever_visible,
3103
+ tracked_paste,
3104
+ ) == Some(true)
3105
+ {
3106
+ found_consumed = true;
3107
+ break;
3108
+ }
3109
+ if cursor_single_enter_enabled()
3110
+ && provider_busy_signal_in_tail(&cap.text)
3111
+ {
2221
3112
  found_consumed = true;
2222
3113
  break;
2223
3114
  }
2224
3115
  }
2225
3116
  Err(_) => {
2226
- capture_failed = true;
2227
- break;
3117
+ capture_retries = capture_retries.saturating_add(1);
3118
+ consecutive_capture_failures =
3119
+ consecutive_capture_failures.saturating_add(1);
3120
+ if consecutive_capture_failures >= POST_SUBMIT_CAPTURE_RETRY {
3121
+ break;
3122
+ }
3123
+ std::thread::sleep(Duration::from_millis(
3124
+ POST_SUBMIT_CAPTURE_RETRY_MS,
3125
+ ));
2228
3126
  }
2229
3127
  }
2230
3128
  }
2231
- if capture_failed {
3129
+ if !saw_capture {
2232
3130
  consumed = None;
2233
3131
  break;
2234
3132
  }
@@ -2237,8 +3135,9 @@ impl Transport for TmuxBackend {
2237
3135
  break;
2238
3136
  }
2239
3137
  } else {
2240
- // Non-token payload: single Enter, no consumption check.
2241
- consumed = None;
3138
+ // Non-token payload: single Enter, no consumption check
3139
+ // (0.3.27). Not 「没能判断」— 本门不覆盖无 token 路径。
3140
+ consumed = Some(true);
2242
3141
  break;
2243
3142
  }
2244
3143
  }
@@ -2250,33 +3149,120 @@ impl Transport for TmuxBackend {
2250
3149
  // `submit_verification`; the prints only spammed
2251
3150
  // coordinator.log without additive signal. Behavior
2252
3151
  // is byte-identical.
2253
- let submit_verification = match consumed {
3152
+ // consumed=false: token still in composer. Paste landing
3153
+ // (`any_attempt_matched`) is A, not submit. Only a Working
3154
+ // signal counts as consumption. Else say unverified.
3155
+ let _ = any_attempt_matched;
3156
+ let mut last_turn_text = attempts_detail
3157
+ .last()
3158
+ .map(|obs| obs.pane_tail_excerpt.clone());
3159
+ let mut submit_verification = match consumed {
2254
3160
  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
3161
+ Some(false) => match self.capture(target, CaptureRange::Tail(15)) {
3162
+ Ok(cap) => {
3163
+ last_turn_text = Some(cap.text.clone());
3164
+ attempts_detail.push(submit_attempt_observation(
3165
+ consumption_attempts.max(1),
3166
+ &cap,
3167
+ marker,
3168
+ inject_start.elapsed().as_millis() as u64,
3169
+ ));
3170
+ if provider_busy_signal_in_tail(&cap.text) {
3171
+ SubmitVerification::EnterSentWithoutPlaceholderCheck
3172
+ } else {
3173
+ SubmitVerification::SubmitConsumptionUnverified
3174
+ }
3175
+ }
3176
+ Err(_) => SubmitVerification::SubmitConsumptionUnverified,
3177
+ },
3178
+ None => SubmitVerification::SubmitConsumptionUnverified,
3179
+ };
3180
+ // Unverified + 折行缺口:只补一颗 C-m。A 的 should_resubmit_enter
3181
+ // 已为真(latch/底15 token,含打满 cap)时 B 不介入。身份拿不到
3182
+ // (None)不补——重复不可逆,滞留可人工救。cursor 单回车不走这里。
3183
+ // 终止:已消费 / 身份消失 / A 已覆盖 / 达上限 / busy。
3184
+ if matches!(
3185
+ submit_verification,
3186
+ SubmitVerification::SubmitConsumptionUnverified
3187
+ ) && consumed == Some(false)
3188
+ && !cursor_single_enter_enabled()
3189
+ {
3190
+ if let Some(m) = marker {
3191
+ let mut leftover_resends = UNVERIFIED_COMPOSER_RESEND_MAX;
3192
+ while leftover_resends > 0 {
3193
+ leftover_resends -= 1;
3194
+ let Ok(cap) = self.capture(target, CaptureRange::Tail(15)) else {
3195
+ break;
3196
+ };
3197
+ last_turn_text = Some(cap.text.clone());
3198
+ if !should_resend_unverified_wrap_gap(&cap.text, m, tracked_paste) {
3199
+ break;
3200
+ }
3201
+ self.prepare_pane_for_submit(target, true);
3202
+ if self
3203
+ .run_inject_stage(&submit_argv, InjectStage::Submit)
3204
+ .is_err()
3205
+ {
3206
+ break;
3207
+ }
3208
+ notify_submit_observer(observer);
3209
+ consumption_attempts = consumption_attempts.saturating_add(1);
3210
+ let attempt_start = std::time::Instant::now();
3211
+ let mut found_consumed = false;
3212
+ for _ in 0..12 {
3213
+ std::thread::sleep(Duration::from_millis(100));
3214
+ match self.capture(target, CaptureRange::Tail(40)) {
3215
+ Ok(cap) => {
3216
+ if cap.text.contains(m) {
3217
+ token_ever_visible = true;
3218
+ }
3219
+ tracked_paste = latch_paste(&cap.text, tracked_paste);
3220
+ let obs = submit_attempt_observation(
3221
+ consumption_attempts.max(1),
3222
+ &cap,
3223
+ marker,
3224
+ attempt_start.elapsed().as_millis() as u64,
3225
+ );
3226
+ attempts_detail.push(obs);
3227
+ last_turn_text = Some(cap.text.clone());
3228
+ if consumption_from_capture(
3229
+ &cap.text,
3230
+ m,
3231
+ token_ever_visible,
3232
+ tracked_paste,
3233
+ ) == Some(true)
3234
+ || provider_busy_signal_in_tail(&cap.text)
3235
+ {
3236
+ // Gone+无 latch 会把折行 token 写成已消费;身份仍在则不算。
3237
+ if !this_paste_identity_in_composer(
3238
+ &cap.text,
3239
+ m,
3240
+ tracked_paste,
3241
+ ) {
3242
+ found_consumed = true;
3243
+ break;
3244
+ }
3245
+ }
3246
+ if !this_paste_identity_in_composer(
3247
+ &cap.text,
3248
+ m,
3249
+ tracked_paste,
3250
+ ) {
3251
+ found_consumed = true;
3252
+ break;
3253
+ }
2272
3254
  }
3255
+ Err(_) => break,
2273
3256
  }
2274
- Err(_) => SubmitVerification::SubmitConsumptionUnverified,
3257
+ }
3258
+ if found_consumed {
3259
+ submit_verification =
3260
+ SubmitVerification::EnterSentWithoutPlaceholderCheck;
3261
+ break;
2275
3262
  }
2276
3263
  }
2277
3264
  }
2278
- None => submit_verification_for_key(submit),
2279
- };
3265
+ }
2280
3266
  let total_elapsed_ms = inject_start.elapsed().as_millis() as u64;
2281
3267
  return Ok(InjectReport {
2282
3268
  stage_reached: InjectStage::Submit,
@@ -2285,13 +3271,11 @@ impl Transport for TmuxBackend {
2285
3271
  token_visible_for_report,
2286
3272
  ),
2287
3273
  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,
3274
+ turn_verification: turn_verification_for_payload(
3275
+ payload,
3276
+ last_turn_text.as_deref(),
3277
+ ),
3278
+ attempts: consumption_attempts.saturating_add(capture_retries),
2295
3279
  submit_diagnostics: Some(crate::transport::SubmitDiagnostics {
2296
3280
  appear_gate_elapsed_ms: 0,
2297
3281
  appear_gate_matched: false,
@@ -2308,12 +3292,7 @@ impl Transport for TmuxBackend {
2308
3292
  token_visible_for_report,
2309
3293
  ),
2310
3294
  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
- },
3295
+ turn_verification: turn_verification_for_payload(payload, None),
2317
3296
  attempts: 1,
2318
3297
  // E50 PR-1: Empty payload / non-Text fallthrough path — no submit
2319
3298
  // diagnostics applicable.
@@ -2323,6 +3302,13 @@ impl Transport for TmuxBackend {
2323
3302
 
2324
3303
  fn send_keys(&self, target: &Target, keys: &[Key]) -> Result<(), TransportError> {
2325
3304
  let pane = pane_from_target(target);
3305
+ let _pane_input = crate::lifecycle::pane_input_lock::acquire_or_proceed(
3306
+ crate::lifecycle::pane_input_lock::PaneInputLockRequest {
3307
+ workspace: self.event_workspace.as_deref(),
3308
+ target_key: pane.as_str(),
3309
+ operation: "send_keys",
3310
+ },
3311
+ );
2326
3312
  if keys.contains(&Key::CancelMode) {
2327
3313
  if let Some(mode) = pane_mode_from_raw(self.query(target, PaneField::PaneMode)?) {
2328
3314
  let argv = crate::transport::tmux_cancel_mode_argv(&pane, mode);
@@ -2330,6 +3316,9 @@ impl Transport for TmuxBackend {
2330
3316
  }
2331
3317
  return Ok(());
2332
3318
  }
3319
+ if keys.iter().any(|k| matches!(k, Key::Enter)) {
3320
+ self.prepare_pane_for_submit(target, true);
3321
+ }
2333
3322
  let argv = tmux_send_keys_argv(&pane, keys);
2334
3323
  self.run_ok(&argv)
2335
3324
  }