@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.
- package/Cargo.lock +8 -1
- package/Cargo.toml +1 -1
- package/crates/team-agent/Cargo.toml +1 -0
- package/crates/team-agent/src/cli/adapters.rs +5 -0
- package/crates/team-agent/src/cli/diagnose.rs +82 -0
- package/crates/team-agent/src/cli/emit.rs +3 -3
- package/crates/team-agent/src/cli/grok_slot.rs +299 -0
- package/crates/team-agent/src/cli/leader.rs +21 -7
- package/crates/team-agent/src/cli/leaders.rs +125 -1
- package/crates/team-agent/src/cli/mod.rs +20 -0
- package/crates/team-agent/src/cli/send/presentation.rs +7 -1
- package/crates/team-agent/src/cli/spec.rs +2 -0
- package/crates/team-agent/src/cli/status_port/compact.rs +28 -0
- package/crates/team-agent/src/cli/status_port/snapshot.rs +25 -1
- package/crates/team-agent/src/cli/tests/base.rs +26 -3
- package/crates/team-agent/src/cli/tests/missing_subcommands.rs +129 -19
- package/crates/team-agent/src/cli/tests/shutdown_kill_plan.rs +387 -6
- package/crates/team-agent/src/cli/tests/status_send.rs +79 -10
- package/crates/team-agent/src/communication_mode/mod.rs +6 -4
- package/crates/team-agent/src/compiler.rs +59 -0
- package/crates/team-agent/src/coordinator/backoff.rs +55 -0
- package/crates/team-agent/src/coordinator/conpty_shim.rs +82 -0
- package/crates/team-agent/src/coordinator/health.rs +173 -0
- package/crates/team-agent/src/coordinator/mod.rs +31 -0
- package/crates/team-agent/src/coordinator/orphan.rs +31 -0
- package/crates/team-agent/src/coordinator/runtime_detectors.rs +30 -0
- package/crates/team-agent/src/coordinator/runtime_observation.rs +25 -0
- package/crates/team-agent/src/coordinator/steps/abnormal.rs +59 -0
- package/crates/team-agent/src/coordinator/steps/delivery.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/health_sync.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/mod.rs +22 -0
- package/crates/team-agent/src/coordinator/steps/persist.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/runtime_prompts.rs +10 -0
- package/crates/team-agent/src/coordinator/steps/session_gate.rs +10 -0
- package/crates/team-agent/src/coordinator/tick.rs +133 -22
- package/crates/team-agent/src/coordinator/types.rs +49 -0
- package/crates/team-agent/src/db/message_store.rs +39 -5
- package/crates/team-agent/src/layout/worker_env.rs +20 -3
- package/crates/team-agent/src/layout/worker_window_helpers.rs +2 -0
- package/crates/team-agent/src/leader/provider_attribution.rs +53 -0
- package/crates/team-agent/src/leader/registry.rs +65 -0
- package/crates/team-agent/src/leader/start.rs +920 -63
- package/crates/team-agent/src/lifecycle/display.rs +56 -0
- package/crates/team-agent/src/lifecycle/helpers.rs +57 -0
- package/crates/team-agent/src/lifecycle/launch/add_agent.rs +332 -12
- package/crates/team-agent/src/lifecycle/launch/add_agent_state.rs +62 -0
- package/crates/team-agent/src/lifecycle/launch/agent_state.rs +59 -0
- package/crates/team-agent/src/lifecycle/launch/clone_agent.rs +101 -11
- package/crates/team-agent/src/lifecycle/launch/cursor_create_chat.rs +229 -0
- package/crates/team-agent/src/lifecycle/launch/cursor_mcp.rs +332 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent.rs +204 -456
- package/crates/team-agent/src/lifecycle/launch/fork_entry.rs +24 -0
- package/crates/team-agent/src/lifecycle/launch/grok_per_seat.rs +260 -0
- package/crates/team-agent/src/lifecycle/launch/identity.rs +146 -0
- package/crates/team-agent/src/lifecycle/launch/layout.rs +60 -0
- package/crates/team-agent/src/lifecycle/launch/leader_context.rs +112 -0
- package/crates/team-agent/src/lifecycle/launch/mcp_config.rs +530 -0
- package/crates/team-agent/src/lifecycle/launch/ownership.rs +40 -0
- package/crates/team-agent/src/lifecycle/launch/plan.rs +31 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start.rs +66 -0
- package/crates/team-agent/src/lifecycle/launch/quick_start_transport.rs +78 -0
- package/crates/team-agent/src/lifecycle/launch/readiness.rs +85 -38
- package/crates/team-agent/src/lifecycle/launch/role_source.rs +44 -44
- package/crates/team-agent/src/lifecycle/launch/spawn.rs +62 -2
- package/crates/team-agent/src/lifecycle/launch/spec_state.rs +139 -0
- package/crates/team-agent/src/lifecycle/launch/state_projection.rs +109 -0
- package/crates/team-agent/src/lifecycle/launch/worker_env.rs +214 -1
- package/crates/team-agent/src/lifecycle/launch.rs +99 -29
- package/crates/team-agent/src/lifecycle/lock.rs +42 -1
- package/crates/team-agent/src/lifecycle/mod.rs +11 -0
- package/crates/team-agent/src/lifecycle/pane_input_lock.rs +161 -0
- package/crates/team-agent/src/lifecycle/profile_launch.rs +98 -0
- package/crates/team-agent/src/lifecycle/profile_smoke.rs +51 -1
- package/crates/team-agent/src/lifecycle/restart/agent.rs +124 -0
- package/crates/team-agent/src/lifecycle/restart/common.rs +369 -11
- package/crates/team-agent/src/lifecycle/restart/orchestrator.rs +27 -0
- package/crates/team-agent/src/lifecycle/restart/preflight.rs +25 -0
- package/crates/team-agent/src/lifecycle/restart/rebuild.rs +106 -0
- package/crates/team-agent/src/lifecycle/restart/remove.rs +196 -5
- package/crates/team-agent/src/lifecycle/restart/selection.rs +72 -0
- package/crates/team-agent/src/lifecycle/restart/team_state.rs +22 -0
- package/crates/team-agent/src/lifecycle/restart.rs +29 -0
- package/crates/team-agent/src/lifecycle/tests/agent_ops.rs +2 -3
- package/crates/team-agent/src/lifecycle/tests/clone_agent_preserves_source_tools.rs +309 -0
- package/crates/team-agent/src/lifecycle/tests/clone_fork_copilot_perms_red.rs +150 -157
- package/crates/team-agent/src/lifecycle/tests/copilot_provider_red.rs +2 -2
- package/crates/team-agent/src/lifecycle/tests/core.rs +4 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_mcp_overlay.rs +287 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_require_explicit_model_red.rs +229 -0
- package/crates/team-agent/src/lifecycle/tests/cursor_restart_resume_red.rs +353 -0
- package/crates/team-agent/src/lifecycle/tests/g1_silent_faces.rs +784 -0
- package/crates/team-agent/src/lifecycle/tests/gate_fixtures.rs +562 -0
- package/crates/team-agent/src/lifecycle/tests/grok_effort_argv_red.rs +239 -0
- package/crates/team-agent/src/lifecycle/tests/grok_mcp_overlay_red.rs +498 -0
- package/crates/team-agent/src/lifecycle/tests/grok_require_explicit_model_red.rs +250 -0
- package/crates/team-agent/src/lifecycle/tests/grok_restart_resume_red.rs +293 -0
- package/crates/team-agent/src/lifecycle/tests/lane_ops.rs +63 -38
- package/crates/team-agent/src/lifecycle/tests/launch_spawn.rs +3 -2
- package/crates/team-agent/src/lifecycle/tests/lifecycle_rollback_red.rs +16 -8
- package/crates/team-agent/src/lifecycle/tests/mcp_tool_name_format_red.rs +69 -0
- package/crates/team-agent/src/lifecycle/tests/phase_b_contracts.rs +20 -19
- package/crates/team-agent/src/lifecycle/tests/phase_golden.rs +97 -61
- package/crates/team-agent/src/lifecycle/tests/restart_rebind_hotfix_252_red.rs +2 -0
- package/crates/team-agent/src/lifecycle/tests/startup_latency_contract.rs +40 -7
- package/crates/team-agent/src/lifecycle/tests/test_isolation_escape_contract.rs +59 -1
- package/crates/team-agent/src/lifecycle/tests/worker_spawn_env_red.rs +69 -26
- package/crates/team-agent/src/lifecycle/tests.rs +23 -13
- package/crates/team-agent/src/lifecycle/types.rs +61 -0
- package/crates/team-agent/src/lifecycle/worker_command_context.rs +85 -11
- package/crates/team-agent/src/mcp_server/tests/scoped.rs +100 -1
- package/crates/team-agent/src/mcp_server/tests.rs +196 -2
- package/crates/team-agent/src/messaging/delivery.rs +405 -40
- package/crates/team-agent/src/messaging/helpers.rs +2 -0
- package/crates/team-agent/src/messaging/leader_receiver.rs +53 -1
- package/crates/team-agent/src/messaging/results.rs +4 -0
- package/crates/team-agent/src/messaging/send.rs +34 -0
- package/crates/team-agent/src/messaging/tests/dup_inject.rs +406 -0
- package/crates/team-agent/src/messaging/tests/e23.rs +9 -0
- package/crates/team-agent/src/messaging/tests/leader_inject_acceptance.rs +66 -0
- package/crates/team-agent/src/messaging/tests/mod.rs +1 -0
- package/crates/team-agent/src/messaging/types.rs +4 -1
- package/crates/team-agent/src/model/enums.rs +14 -5
- package/crates/team-agent/src/model/permissions.rs +3 -0
- package/crates/team-agent/src/os_probe.rs +73 -2
- package/crates/team-agent/src/provider/adapter.rs +231 -4
- package/crates/team-agent/src/provider/adapters/cursor_agent.rs +86 -0
- package/crates/team-agent/src/provider/adapters/grok.rs +157 -0
- package/crates/team-agent/src/provider/adapters/mod.rs +2 -0
- package/crates/team-agent/src/provider/bypass_flags.rs +46 -2
- package/crates/team-agent/src/provider/classify.rs +4 -2
- package/crates/team-agent/src/provider/faults.rs +2 -1
- package/crates/team-agent/src/provider/mod.rs +11 -0
- package/crates/team-agent/src/provider/session/capture.rs +158 -20
- package/crates/team-agent/src/provider/session/context_fork/claude.rs +38 -1
- package/crates/team-agent/src/provider/session/context_fork/codex.rs +72 -1
- package/crates/team-agent/src/provider/session/context_fork/outcome.rs +57 -1
- package/crates/team-agent/src/provider/session/context_fork.rs +77 -3
- package/crates/team-agent/src/provider/session/mod.rs +18 -0
- package/crates/team-agent/src/provider/session/resume.rs +57 -0
- package/crates/team-agent/src/provider/session_scan/claude.rs +125 -1
- package/crates/team-agent/src/provider/session_scan/codex.rs +94 -1
- package/crates/team-agent/src/provider/session_scan/common.rs +204 -2
- package/crates/team-agent/src/provider/session_scan/copilot.rs +33 -1
- package/crates/team-agent/src/provider/session_scan/cursor.rs +538 -0
- package/crates/team-agent/src/provider/session_scan/grok.rs +288 -0
- package/crates/team-agent/src/provider/session_scan.rs +8 -0
- package/crates/team-agent/src/provider/submit_now.rs +94 -0
- package/crates/team-agent/src/provider/tests/adapter.rs +304 -0
- package/crates/team-agent/src/provider/types.rs +4 -2
- package/crates/team-agent/src/provider/wire.rs +23 -1
- package/crates/team-agent/src/state/persist.rs +301 -8
- package/crates/team-agent/src/state/repository.rs +5 -0
- package/crates/team-agent/src/tmux_backend/tests.rs +1737 -42
- package/crates/team-agent/src/tmux_backend.rs +1064 -75
- package/crates/team-agent/src/transport/tests/wire.rs +28 -2
- package/crates/team-agent/src/transport.rs +248 -1
- package/npm/install.mjs +129 -73
- package/package.json +4 -4
- package/skills/team-agent/SKILL.md +33 -238
- package/skills/team-agent/command-coverage.json +31 -0
- package/crates/team-agent/src/lifecycle/launch/fork_agent/completion.rs +0 -59
- package/crates/team-agent/src/lifecycle/launch/fork_finalize.rs +0 -488
- package/crates/team-agent/src/lifecycle/launch/fork_pending.rs +0 -109
- 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,
|
|
38
|
-
CaptureRange, CapturedText, InjectPayload, InjectReport, InjectStage,
|
|
39
|
-
PaneField, PaneId, PaneInfo, PaneMode, SessionName, SetEnvOutcome,
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
2073
|
-
Ok(
|
|
2074
|
-
|
|
2075
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
2202
|
-
// Capture failures →
|
|
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
|
|
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
|
|
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
|
-
|
|
2227
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
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
|
-
|
|
3257
|
+
}
|
|
3258
|
+
if found_consumed {
|
|
3259
|
+
submit_verification =
|
|
3260
|
+
SubmitVerification::EnterSentWithoutPlaceholderCheck;
|
|
3261
|
+
break;
|
|
2275
3262
|
}
|
|
2276
3263
|
}
|
|
2277
3264
|
}
|
|
2278
|
-
|
|
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:
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
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:
|
|
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
|
}
|