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