ot-cli 0.2.1__py3-none-any.whl
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.
- openterminal/__init__.py +3 -0
- openterminal/agent/__init__.py +39 -0
- openterminal/agent/backend.py +180 -0
- openterminal/agent/build.py +115 -0
- openterminal/agent/middleware.py +111 -0
- openterminal/agent/model.py +41 -0
- openterminal/agent/prompt.py +63 -0
- openterminal/agent/runner.py +340 -0
- openterminal/agent/sysprobe.py +164 -0
- openterminal/agent/token_est.py +49 -0
- openterminal/app.py +52 -0
- openterminal/approval.py +7 -0
- openterminal/backend.py +3 -0
- openterminal/banner.py +7 -0
- openterminal/cli.py +426 -0
- openterminal/cmdset.py +7 -0
- openterminal/config.py +322 -0
- openterminal/connections/__init__.py +20 -0
- openterminal/connections/db.py +240 -0
- openterminal/connections/models.py +289 -0
- openterminal/connections/secrets.py +68 -0
- openterminal/connections_db.py +6 -0
- openterminal/core/__init__.py +41 -0
- openterminal/core/ai_flow.py +781 -0
- openterminal/core/auth.py +499 -0
- openterminal/core/cmdset_runner.py +352 -0
- openterminal/core/exec_events.py +650 -0
- openterminal/core/history.py +79 -0
- openterminal/core/hook_gate.py +415 -0
- openterminal/core/messages.py +193 -0
- openterminal/core/nested_ssh.py +178 -0
- openterminal/core/pipeline.py +1187 -0
- openterminal/core/pump.py +190 -0
- openterminal/core/transcript.py +26 -0
- openterminal/frontend/__init__.py +21 -0
- openterminal/frontend/approval.py +40 -0
- openterminal/frontend/banner.py +40 -0
- openterminal/frontend/headless.py +290 -0
- openterminal/frontend/render.py +41 -0
- openterminal/frontend/taskview.py +184 -0
- openterminal/frontend/term.py +1417 -0
- openterminal/headless.py +7 -0
- openterminal/history_db.py +7 -0
- openterminal/local_pty.py +3 -0
- openterminal/local_win.py +3 -0
- openterminal/policy.py +362 -0
- openterminal/render.py +7 -0
- openterminal/secrets_store.py +5 -0
- openterminal/session/__init__.py +17 -0
- openterminal/session/base.py +328 -0
- openterminal/session/local_posix.py +184 -0
- openterminal/session/local_win.py +173 -0
- openterminal/session/ssh.py +321 -0
- openterminal/shell/__init__.py +17 -0
- openterminal/shell/cmdset.py +49 -0
- openterminal/shell/integration.py +1298 -0
- openterminal/shell_integration.py +7 -0
- openterminal/shell_session.py +8 -0
- openterminal/skills/__init__.py +45 -0
- openterminal/skills/markdown-tables/SKILL.md +247 -0
- openterminal/ssh_pty.py +6 -0
- openterminal/sysprobe.py +7 -0
- openterminal/taskview.py +7 -0
- openterminal/term_frontend.py +7 -0
- openterminal/token_est.py +3 -0
- openterminal/transcript.py +7 -0
- openterminal/web/__init__.py +1 -0
- openterminal/web/files_api.py +789 -0
- openterminal/web/frontend/index.html +37 -0
- openterminal/web/frontend/static/app.js +3632 -0
- openterminal/web/frontend/static/block_stream.js +274 -0
- openterminal/web/frontend/static/block_view.js +383 -0
- openterminal/web/frontend/static/files_panel.js +782 -0
- openterminal/web/frontend/static/md_lite.js +127 -0
- openterminal/web/frontend/static/modal.js +122 -0
- openterminal/web/frontend/static/pad_ledger.js +46 -0
- openterminal/web/frontend/static/style.css +915 -0
- openterminal/web/frontend/static/ui/ui.css +1 -0
- openterminal/web/frontend/static/ui/ui.js +6292 -0
- openterminal/web/frontend/vendor/addon-fit.js +2 -0
- openterminal/web/frontend/vendor/xterm.css +8 -0
- openterminal/web/frontend/vendor/xterm.js +8 -0
- openterminal/web/protocol.py +41 -0
- openterminal/web/server.py +322 -0
- openterminal/web/worker.py +62 -0
- ot_cli-0.2.1.dist-info/METADATA +439 -0
- ot_cli-0.2.1.dist-info/RECORD +91 -0
- ot_cli-0.2.1.dist-info/WHEEL +5 -0
- ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
- ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
- ot_cli-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
"""单管线核心:PTY 直通 + hook 记账 + AI 任务(自 web/worker.py 搬移,设计见 docs/superpowers/specs/2026-09-27-cli-single-pipeline-design.md)。
|
|
2
|
+
|
|
3
|
+
显示模型:PTY 字节是唯一显示管线——核心除需吞除/记账的字节外,把
|
|
4
|
+
live/exec 相位文本原样转发给前端出口;不做任何显示路由(无
|
|
5
|
+
term_open/commit/command_done、无直通活块、无嵌套 shell 状态机、无写死
|
|
6
|
+
提示符)。核心只做三件事:
|
|
7
|
+
|
|
8
|
+
1. 记账:OSC 133(A/B/C/D 相位)+ OSC 6337(CMD/AI/EXEC 行报告)的
|
|
9
|
+
StreamRouter 监听——history、退出码、AI 上下文;
|
|
10
|
+
2. AI:agent(自然语言任务)+ ``__ot_exec__`` 工具命令注入通道,输出经
|
|
11
|
+
ai_token/ai_think/ai_collapse/ai_card/final 事件驱动前端卡片层;
|
|
12
|
+
3. 非显示事件:approval/ask_password/ask_host_key/usage 等。
|
|
13
|
+
|
|
14
|
+
执行上下文从 10 种瘦身到 3 种(§5.2):``agent``(AI 工具执行中)、
|
|
15
|
+
``probe``(隐藏探测)、``silent``(模式开关注入);用户命令不进栈,仅记账。
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import getpass
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
import uuid
|
|
26
|
+
from collections import deque
|
|
27
|
+
from contextlib import suppress
|
|
28
|
+
from dataclasses import dataclass, replace
|
|
29
|
+
from typing import Callable
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_OFFLINE_CAP = 500 # 断线期缓存的 JSON 控制帧上限(防长期断线涨内存)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _dbg(*a) -> None:
|
|
36
|
+
"""OT_WEB_DEBUG=1 时打印交互式状态机轨迹(排障用,默认静默)。"""
|
|
37
|
+
if _OT_DBG:
|
|
38
|
+
print(f"[otdbg +{time.monotonic() - _OT_T0:7.3f}s]", *a, flush=True)
|
|
39
|
+
|
|
40
|
+
from ..shell import cmdset
|
|
41
|
+
from . import history
|
|
42
|
+
from ..config import Config, TargetConfig, app_dir
|
|
43
|
+
from ..connections import (
|
|
44
|
+
PW_STORE_FAIL_HINT, display_name, open_session, parse_user_at_host)
|
|
45
|
+
from .history import HISTORY_RECALL_LIMIT
|
|
46
|
+
from ..policy import Policy
|
|
47
|
+
from .messages import (
|
|
48
|
+
CHANNEL_DEAD_EC, CHANNEL_DEAD_MSG, ClientMsg, InteractiveRunner,
|
|
49
|
+
PasswordCancelled, ServerMsg, _ANSI_RE, _ansi_free, _dbg,
|
|
50
|
+
_PROBE_TIMEOUT,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# 健康路径兜底:补发 \r 后等 hook AI 上报的最长时限(超时改走外部启动)
|
|
54
|
+
SUBMIT_FALLBACK_DELAY = 1.2
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# 嵌套密码验证窗口:密码打进 PTY 后 hold 前端输入的时长上限(auth 结果输出
|
|
58
|
+
# 到达即提前结束);超时视为已resolved,防输入被永久 hold
|
|
59
|
+
_PW_VERIFY_TIMEOUT = 3.0
|
|
60
|
+
# hold 释放防抖:auth 结果输出后稍等再放——隧道合包可能把 deny 与重讨提示符
|
|
61
|
+
# 拆成两块,即放会让抢跑命令打进新密码位
|
|
62
|
+
_PW_HOLD_RELEASE = 0.25
|
|
63
|
+
# 手输密码验证成功后的「记住」询问防抖(P1-3/P1-4):等一拍让迟到的 deny/
|
|
64
|
+
# 重讨提示落地再问——「只落本次验证通过的密码」,错密拆块到达也不误问
|
|
65
|
+
_PW_REMEMBER_DELAY = 0.45
|
|
66
|
+
# 验证失败指纹:auth 结果输出出现即判失败——错密不进库、不弹记忆询问。
|
|
67
|
+
# 英文覆盖 OpenSSH(Permission denied)/sudo(Sorry, try again / incorrect
|
|
68
|
+
# password)原文,中文兜部分本地化;成功输出不含这些字样(bytes 字面量
|
|
69
|
+
# 不能含非 ASCII,中文段 str 编码拼接——同 _PASSWORD_PROMPT_RE)
|
|
70
|
+
_PW_AUTH_FAIL_RE = re.compile(
|
|
71
|
+
rb"permission denied|sorry, try again|authentication fail"
|
|
72
|
+
rb"|incorrect password|" + "密码错误|口令错误".encode(),
|
|
73
|
+
re.IGNORECASE)
|
|
74
|
+
# AI 僵尸任务静默上限:_ai_task 存活但长时间零产出(无 ai_token/think/tool、
|
|
75
|
+
# 无非遇抑 echo)→ 按僵尸回收。触发场景:任务卡在某个等不住的 await 上一直
|
|
76
|
+
# 不归 None,使提示符幕帘在每次 prompt_start 重新关帘(见 _curtain_close),
|
|
77
|
+
# shell 重绘的新提示符永被扣住 → 底行目录冻结「像写死」。回收后 finally 自动
|
|
78
|
+
# 清 _ai_task 并开帘,提示符即刻恢复。取长值防空转思考被误杀。
|
|
79
|
+
_AI_ZOMBIE_T = 180.0
|
|
80
|
+
_PW_MODAL_DISMISS = 0.5 # 密码提示符被消费后陈旧模态的代关防抖
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# 注入期续行提示符指纹(zsh PS2):回显流里出现即判定输入字节被行规程丢弃、
|
|
84
|
+
# 引号失配,shell 卡在续行等补引号——setup 注入竞态的自愈触发点
|
|
85
|
+
_PS2_ECHO_RE = re.compile(rb"(?:quote|dquote|cmdsubst)> ")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _looks_ai(text: str) -> bool:
|
|
89
|
+
"""自然语言粗判(外部分类触发链)。与 shell hook 的分类语义对齐:只有
|
|
90
|
+
「首词明显不是命令」才拦去模型,含 CJK 参数的命令(如 grep 错误 app.log)
|
|
91
|
+
仍走终端执行。英文自然语言不在前端拦截范围,仍由 hook 的 type -t 分类。"""
|
|
92
|
+
t = text.strip()
|
|
93
|
+
if t.startswith("?"):
|
|
94
|
+
return True
|
|
95
|
+
parts = t.split()
|
|
96
|
+
if not parts:
|
|
97
|
+
return False
|
|
98
|
+
first = parts[0]
|
|
99
|
+
for ch in ";|&()":
|
|
100
|
+
first = first.split(ch, 1)[0]
|
|
101
|
+
if any("一" <= c <= "鿿" or "" <= c <= "ヿ"
|
|
102
|
+
or "가" <= c <= "" for c in first):
|
|
103
|
+
return True
|
|
104
|
+
# 首词不像命令名(引号、波浪号、空格切分出的奇怪 token)→ 交给模型
|
|
105
|
+
return not all(
|
|
106
|
+
c.isalnum() or c in "./@%+:=_,-" for c in first)
|
|
107
|
+
from ..connections.secrets import load_password, store_password
|
|
108
|
+
from ..shell.integration import (
|
|
109
|
+
StreamRouter, agent_exec_line, history_inject_line, history_quiet_line,
|
|
110
|
+
injection_lines, is_internal_line,
|
|
111
|
+
parse_probe, probe_command, toggle_line,
|
|
112
|
+
)
|
|
113
|
+
from ..session.base import (
|
|
114
|
+
_PASSWORD_PROMPT_RE, CommandResult, _needs_password_prompt)
|
|
115
|
+
from ..agent.sysprobe import (
|
|
116
|
+
LOCAL_PROFILE, MANUAL_PRESETS, load_host_cache, probe_profile,
|
|
117
|
+
save_host_cache,
|
|
118
|
+
)
|
|
119
|
+
from .transcript import open_transcript
|
|
120
|
+
from ..connections.secrets import load_password_alias as _load_password_alias_impl
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_password_alias(host: str, user: str | None, port: int | None) -> str | None:
|
|
124
|
+
"""首跳(_connect)与嵌套(_load_nested_password)共用的凭据读取:
|
|
125
|
+
None/22 端口别名互查,规范实现在 connections.secrets.load_password_alias。
|
|
126
|
+
|
|
127
|
+
这里只做透传并把本模块的 load_password 全局当读函数传入——读取永远
|
|
128
|
+
走 core.load_password 这个名字,wmod 桥转发的测试 monkeypatch
|
|
129
|
+
(test_web_worker/test_term_frontend)语义不变。
|
|
130
|
+
"""
|
|
131
|
+
return _load_password_alias_impl(host, user, port, load=load_password)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# AI 栈(deepagents/langchain/anthropic,导入约 4s——启动耗时实测大头)与
|
|
135
|
+
# CLI 启动/建连/纯 shell 路径解耦:下面两个名字延迟到首个 AI 任务
|
|
136
|
+
# (_ensure_agent→_build_agent_now)时由 _load_agent_stack 惰性填充。保留为
|
|
137
|
+
# 模块级占位(初值 None)有两个硬约束:
|
|
138
|
+
# ① web/worker.py 的 `from ..core import build_agent` 再导出 + _PatchBridge
|
|
139
|
+
# 转发要求 hasattr(core, "build_agent") 为真;
|
|
140
|
+
# ② test_term_frontend / test_web_worker 靠
|
|
141
|
+
# monkeypatch.setattr(cmod/wmod, "build_agent", fake) 打桩——一旦被覆盖
|
|
142
|
+
# 就非 None,_load_agent_stack 跳过真导入直接用桩。
|
|
143
|
+
build_agent = None
|
|
144
|
+
build_chat_model = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _load_agent_stack() -> None:
|
|
148
|
+
"""把 AI 栈入口惰性载入模块级 build_agent/build_chat_model。已被测试
|
|
149
|
+
monkeypatch 覆盖(非 None)的名字原样保留,不触发 deepagents 真导入。"""
|
|
150
|
+
global build_agent, build_chat_model
|
|
151
|
+
if build_agent is None:
|
|
152
|
+
from ..agent.build import build_agent as _build_agent
|
|
153
|
+
build_agent = _build_agent
|
|
154
|
+
if build_chat_model is None:
|
|
155
|
+
from ..agent.model import build_chat_model as _build_chat_model
|
|
156
|
+
build_chat_model = _build_chat_model
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class PipelineCore:
|
|
160
|
+
# pad 指纹扫描窗开关:类级默认 False——测试可直接构造实例不跑 __init__,
|
|
161
|
+
# live 分支读它时属性必须存在
|
|
162
|
+
_pad_inflight = False
|
|
163
|
+
# 事件节奏分流:False=逐 token 流式(CLI 直播);True=段攒流(Web:
|
|
164
|
+
# 每段一条完整 ai_result + 心跳,前端不攒 token 流)。由子类覆写。
|
|
165
|
+
paced_display: bool = False
|
|
166
|
+
|
|
167
|
+
def __init__(self, cfg: Config, target_name: str, *,
|
|
168
|
+
policy: Policy | None = None) -> None:
|
|
169
|
+
self.cfg = cfg
|
|
170
|
+
self.target_name = target_name
|
|
171
|
+
self.tab_id = uuid.uuid4().hex[:8]
|
|
172
|
+
self.policy = policy or Policy(
|
|
173
|
+
mode=cfg.policy.mode,
|
|
174
|
+
auto_extra=cfg.policy.auto_extra,
|
|
175
|
+
approve_extra=cfg.policy.approve_extra,
|
|
176
|
+
deny_extra=cfg.policy.deny_extra,
|
|
177
|
+
)
|
|
178
|
+
# 模型与用量(Web 状态栏展示 / 切换)
|
|
179
|
+
self._model = cfg.model # 当前 ModelConfig(可运行时切换)
|
|
180
|
+
models = list(getattr(cfg.model, "models", [])) or [cfg.model.model]
|
|
181
|
+
if cfg.model.model not in models:
|
|
182
|
+
models.insert(0, cfg.model.model)
|
|
183
|
+
self._models = models
|
|
184
|
+
self._tokens_in = 0
|
|
185
|
+
self._tokens_out = 0
|
|
186
|
+
self.transcript = open_transcript(self.tab_id)
|
|
187
|
+
self.session = None
|
|
188
|
+
self.profile = None
|
|
189
|
+
self.agent = None
|
|
190
|
+
self.allowed: set[str] = set()
|
|
191
|
+
self.backend = None
|
|
192
|
+
self.connected = asyncio.Event()
|
|
193
|
+
self._sink = None
|
|
194
|
+
self._hb = None # 应用层心跳任务(attach 起、close 自然退)
|
|
195
|
+
self._outbox: asyncio.Queue = asyncio.Queue()
|
|
196
|
+
self._inbox: asyncio.Queue = asyncio.Queue()
|
|
197
|
+
self._pws: asyncio.Queue = asyncio.Queue() # 密码应答
|
|
198
|
+
self.auth # noqa: B018 — 密码状态机组件:触达 lazy 属性即建 _pw_* 状态(core/auth.py)
|
|
199
|
+
self.exec # noqa: B018 — 标记流/注入/幕帘组件:触达 lazy 属性即建 _exec_*/_curtain 等状态(core/exec_events.py)
|
|
200
|
+
self.hook # noqa: B018 — hook 集成/验活组件:触达 lazy 属性即建 _hook_*/_interactive 等状态(core/hook_gate.py)
|
|
201
|
+
self.cs # noqa: B018 — 连接后命令集组件:触达 lazy 属性即建 _cs_* 状态(core/cmdset_runner.py)
|
|
202
|
+
self.ai # noqa: B018 — AI 行处理/任务组件:触达 lazy 属性即建 _ai_*/_display 等 AI 状态(core/ai_flow.py)
|
|
203
|
+
self.pump # noqa: B018 — 输出泵组件:触达 lazy 属性即建 _pump/_sender/_pump_phase 状态(core/pump.py)
|
|
204
|
+
self._hks: asyncio.Queue = asyncio.Queue() # 主机密钥应答
|
|
205
|
+
self._decisions: asyncio.Queue = asyncio.Queue() # 审批决策
|
|
206
|
+
# 未决审批记账:审批帧只发一次,WS 断隙/刷新即丢,core 会永久等决策
|
|
207
|
+
# (= 任务假死,真机 2026-10-06);attach 重挂时据此补发
|
|
208
|
+
self._pending_approval: ServerMsg | None = None
|
|
209
|
+
# 断线期攒下的 JSON 控制帧(终局/用量/状态…):PTY 字节帧照旧丢,
|
|
210
|
+
# 控制帧丢了前端就永久收不到任务收尾(真机「AI 正在思考」不收)
|
|
211
|
+
self._offline: list = []
|
|
212
|
+
self._boundary_ack = asyncio.Event() # 前端阶段结账完成(ai_boundary 门闩)
|
|
213
|
+
self._task: asyncio.Task | None = None
|
|
214
|
+
self._target: TargetConfig | None = None # _connect 时持有(命令集等读取)
|
|
215
|
+
self._target_host = "" # _connect 时填充
|
|
216
|
+
self._target_user = ""
|
|
217
|
+
self._closed = False
|
|
218
|
+
self._closed_sent = False # closed 消息已入 outbox(sender 退出门闩)
|
|
219
|
+
# 泵 EOF(_pump_eof)置位:断线后首个键经 _on_keys 触发 _revive_session
|
|
220
|
+
self._session_dead = False
|
|
221
|
+
# 模型上下文轮次:/clear 自增 → TaskRunner 换新 thread_id,langgraph
|
|
222
|
+
# MemorySaver 按 thread 存历史,旧上下文不再进新任务的提示词(省 token,
|
|
223
|
+
# 语义同 CLI /clear 换 session_id)
|
|
224
|
+
self._ctx_epoch = 0
|
|
225
|
+
# --- 交互式 shell 集成(单管线真终端)---
|
|
226
|
+
self._router: StreamRouter | None = None
|
|
227
|
+
self._exec_future = None # InteractiveRunner 等待 D 标记的 future
|
|
228
|
+
# --- 失败救援(分类判 CMD 就直接执行;失败即信号,一键交 AI)---
|
|
229
|
+
self._ev_prompt = asyncio.Event() # 模式切换:等提示符重画
|
|
230
|
+
# 任意流字节(live/exec 回显)到达信号:重注入分片逐片等回显做应答
|
|
231
|
+
# (固定短间隔会在 shell 执行间隙把分片堆进 canonical 缓冲溢出丢块)
|
|
232
|
+
self._ev_stream = asyncio.Event()
|
|
233
|
+
self._ev_exec_end = asyncio.Event() # 模式切换:等 toggle 的 D 标记
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def nested(self) -> "NestedSshTracker":
|
|
237
|
+
"""嵌套 SSH 帧解析组件(无状态,惰性建以兼容裸构造测试实例)。"""
|
|
238
|
+
from .nested_ssh import NestedSshTracker # 局部导入:core/__init__ 反向转发本模块,模块级导入成环
|
|
239
|
+
n = self.__dict__.get("_nested")
|
|
240
|
+
if n is None:
|
|
241
|
+
n = NestedSshTracker(self)
|
|
242
|
+
self.__dict__["_nested"] = n
|
|
243
|
+
return n
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def auth(self) -> "AuthFlow":
|
|
247
|
+
"""密码状态机组件(Task 3):惰性建以兼容裸构造测试实例。"""
|
|
248
|
+
a = self.__dict__.get("_auth")
|
|
249
|
+
if a is None:
|
|
250
|
+
from .auth import AuthFlow
|
|
251
|
+
a = AuthFlow(self)
|
|
252
|
+
self.__dict__["_auth"] = a
|
|
253
|
+
return a
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def exec(self) -> "ExecEvents":
|
|
257
|
+
"""标记流/注入/幕帘组件(Task 4):惰性建以兼容裸构造测试实例。"""
|
|
258
|
+
e = self.__dict__.get("_exec_events")
|
|
259
|
+
if e is None:
|
|
260
|
+
from .exec_events import ExecEvents
|
|
261
|
+
e = ExecEvents(self)
|
|
262
|
+
self.__dict__["_exec_events"] = e
|
|
263
|
+
return e
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def hook(self) -> "HookGate":
|
|
267
|
+
"""hook 集成/验活组件(Task 5):惰性建以兼容裸构造测试实例。"""
|
|
268
|
+
h = self.__dict__.get("_hook_gate")
|
|
269
|
+
if h is None:
|
|
270
|
+
from .hook_gate import HookGate
|
|
271
|
+
h = HookGate(self)
|
|
272
|
+
self.__dict__["_hook_gate"] = h
|
|
273
|
+
return h
|
|
274
|
+
|
|
275
|
+
@property
|
|
276
|
+
def cs(self) -> "CmdsetRunner":
|
|
277
|
+
"""连接后命令集组件(Task 6):惰性建以兼容裸构造测试实例。"""
|
|
278
|
+
c = self.__dict__.get("_cmdset_runner")
|
|
279
|
+
if c is None:
|
|
280
|
+
from .cmdset_runner import CmdsetRunner
|
|
281
|
+
c = CmdsetRunner(self)
|
|
282
|
+
self.__dict__["_cmdset_runner"] = c
|
|
283
|
+
return c
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def ai(self) -> "AiFlow":
|
|
287
|
+
"""AI 行处理/任务组件(Task 7):惰性建以兼容裸构造测试实例。"""
|
|
288
|
+
a = self.__dict__.get("_ai_flow")
|
|
289
|
+
if a is None:
|
|
290
|
+
from .ai_flow import AiFlow
|
|
291
|
+
a = AiFlow(self)
|
|
292
|
+
self.__dict__["_ai_flow"] = a
|
|
293
|
+
return a
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def pump(self) -> "OutputPump":
|
|
297
|
+
"""输出泵组件(Task 8):惰性建以兼容裸构造测试实例。"""
|
|
298
|
+
p = self.__dict__.get("_output_pump")
|
|
299
|
+
if p is None:
|
|
300
|
+
from .pump import OutputPump
|
|
301
|
+
p = OutputPump(self)
|
|
302
|
+
self.__dict__["_output_pump"] = p
|
|
303
|
+
return p
|
|
304
|
+
|
|
305
|
+
# --- 委托(tests/ 引用旧名,长期保留——勿删)---
|
|
306
|
+
@staticmethod
|
|
307
|
+
def _parse_ssh_cmd_target(line: str
|
|
308
|
+
) -> tuple[str | None, str | None, int | None] | None:
|
|
309
|
+
from .nested_ssh import NestedSshTracker
|
|
310
|
+
return NestedSshTracker.parse_ssh_cmd_target(line)
|
|
311
|
+
|
|
312
|
+
def _innermost_ssh_frame(self) -> int | None:
|
|
313
|
+
return self.nested.innermost_ssh_frame()
|
|
314
|
+
|
|
315
|
+
def _inner_ssh_prompt_live(self) -> bool:
|
|
316
|
+
return self.nested.inner_ssh_prompt_live()
|
|
317
|
+
|
|
318
|
+
def _nested_ssh_target(self) -> tuple[str | None, str | None, int | None]:
|
|
319
|
+
return self.nested.nested_ssh_target()
|
|
320
|
+
|
|
321
|
+
def _ssh_frame_host(self) -> str | None:
|
|
322
|
+
return self.nested.ssh_frame_host()
|
|
323
|
+
|
|
324
|
+
def _sudo_pw_key(self) -> tuple[str, str, int | None] | None:
|
|
325
|
+
return self.nested.sudo_pw_key()
|
|
326
|
+
|
|
327
|
+
# --- Agent 输入框斜杠命令 ---
|
|
328
|
+
def _is_pwsh(self) -> bool:
|
|
329
|
+
return self.profile is not None and \
|
|
330
|
+
"powershell" in (self.profile.shell or "").lower()
|
|
331
|
+
|
|
332
|
+
# --- 提示符幕帘(任务期扣住提示符重绘,注入/收尾放行)---
|
|
333
|
+
|
|
334
|
+
# --- 模型兜底分类(cli 共用形态;web 侧行分类由 hook 完成)---
|
|
335
|
+
# --- sink / 消息入口 ---
|
|
336
|
+
def attach(self, sink) -> None:
|
|
337
|
+
self._sink = sink
|
|
338
|
+
if self._sender is None:
|
|
339
|
+
self._sender = asyncio.create_task(self.pump._sender_loop())
|
|
340
|
+
if self._task is None:
|
|
341
|
+
self._task = asyncio.create_task(self._run())
|
|
342
|
+
if self._hb is None:
|
|
343
|
+
self._hb = asyncio.create_task(self.pump._heartbeat())
|
|
344
|
+
self._flush_offline()
|
|
345
|
+
if self._pending_approval is not None:
|
|
346
|
+
# 重连/刷新重挂:补发未决审批(首挂时必为 None,不会重复投)
|
|
347
|
+
self._emit_nowait(self._pending_approval)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
async def _reap_zombie_ai(self) -> None:
|
|
351
|
+
"""僵尸任务自愈:``_ai_task`` 存活却长时间零产出且 shell 空闲 → 回收。
|
|
352
|
+
|
|
353
|
+
兜底缓解「底行提示符冻结」一类:任务卡在某个等不住的 await 上一直不
|
|
354
|
+
归 None,``_curtain_close`` 便会在每次 prompt_start 重新关帘,shell 重绘的新
|
|
355
|
+
提示符(cd 后就该变目录)永被幕帘扣住。回收走
|
|
356
|
+
``_maybe_cancel_ai`` → ``_ai_flow`` 的 finally 自动清 ``_ai_task``
|
|
357
|
+
并开帘,提示符即刻恢复;同时放 status 知会前端/用户。"""
|
|
358
|
+
t = self._ai_task
|
|
359
|
+
if t is None or t.done():
|
|
360
|
+
return
|
|
361
|
+
if t.get_coro() is asyncio.current_task():
|
|
362
|
+
return # 自己不能收自己
|
|
363
|
+
if time.monotonic() - self._ai_ping_at < _AI_ZOMBIE_T:
|
|
364
|
+
return
|
|
365
|
+
_dbg("ai zombie reap: ai_task 静默 >", _AI_ZOMBIE_T, "s")
|
|
366
|
+
await self.ai._maybe_cancel_ai()
|
|
367
|
+
await self.emit_msg(ServerMsg(
|
|
368
|
+
type="status",
|
|
369
|
+
text="AI 任务长时间无响应,已自动停止;提示符已恢复。如需继续请重发。"))
|
|
370
|
+
|
|
371
|
+
def _flush_offline(self) -> None:
|
|
372
|
+
"""补发断线期攒下的控制帧:插到 outbox 队头,保证先于重挂后新帧。
|
|
373
|
+
|
|
374
|
+
全程无 await:drain + 回填在同一事件循环步内完成,不会与并发生产者
|
|
375
|
+
交错乱序。"""
|
|
376
|
+
if not self._offline:
|
|
377
|
+
return
|
|
378
|
+
buf, self._offline = self._offline, []
|
|
379
|
+
pending = []
|
|
380
|
+
while True:
|
|
381
|
+
try:
|
|
382
|
+
pending.append(self._outbox.get_nowait())
|
|
383
|
+
except asyncio.QueueEmpty:
|
|
384
|
+
break
|
|
385
|
+
for item in buf:
|
|
386
|
+
self._outbox.put_nowait(item)
|
|
387
|
+
for item in pending:
|
|
388
|
+
self._outbox.put_nowait(item)
|
|
389
|
+
|
|
390
|
+
def detach(self, sink) -> None:
|
|
391
|
+
if self._sink is sink:
|
|
392
|
+
self._sink = None
|
|
393
|
+
|
|
394
|
+
async def feed_input(self, data: bytes) -> None:
|
|
395
|
+
"""前端 → 核心键盘字节(CLI 输入泵 / web 二进制帧同一入口)。
|
|
396
|
+
|
|
397
|
+
等价 ClientMsg(type="raw");必须走 _inbox → _dispatch → _on_keys
|
|
398
|
+
保持 web 既有顺序语义(直调 _on_keys 会插队到已排队的
|
|
399
|
+
resize/mode/close 之前)。"""
|
|
400
|
+
await self.feed_msg(ClientMsg(type="raw", data=data))
|
|
401
|
+
|
|
402
|
+
async def feed_msg(self, msg: ClientMsg) -> None:
|
|
403
|
+
"""前端 → 核心结构化上行(原 web handle_client)。"""
|
|
404
|
+
if msg.type == "decision" and msg.decision is not None:
|
|
405
|
+
await self._decisions.put(msg.decision)
|
|
406
|
+
elif msg.type == "auth":
|
|
407
|
+
if msg.auth_kind == "password":
|
|
408
|
+
# 内联手输的密码留档:ready 后前端确认「记住」时回读写库
|
|
409
|
+
# (password_remember 不携明文,避免密码再回流一遍)
|
|
410
|
+
self._pw_last_entered = msg.text
|
|
411
|
+
await self._pws.put(msg.text)
|
|
412
|
+
if msg.remember and not self.auth.store_auth_password(msg.text):
|
|
413
|
+
await self.emit_msg(ServerMsg(
|
|
414
|
+
type="status", text=PW_STORE_FAIL_HINT))
|
|
415
|
+
elif msg.auth_kind == "password_remember":
|
|
416
|
+
# 手输密码登录成功后的「记住」确认:与弹窗勾选记住同键写 keyring
|
|
417
|
+
if getattr(self, "_pw_last_entered", "") and \
|
|
418
|
+
not self.auth.store_auth_password(self._pw_last_entered):
|
|
419
|
+
await self.emit_msg(ServerMsg(
|
|
420
|
+
type="status", text=PW_STORE_FAIL_HINT))
|
|
421
|
+
elif msg.auth_kind == "password_cancel":
|
|
422
|
+
# 终端内联密码输入的 Ctrl+C:中止认证等待(等同连接关闭),
|
|
423
|
+
# ask_password 抛 PasswordCancelled → 前端显示「连接已取消」
|
|
424
|
+
self._pw_cancelled = True
|
|
425
|
+
await self._pws.put(None)
|
|
426
|
+
elif msg.auth_kind == "nested_password":
|
|
427
|
+
# 嵌套 ssh/sudo 密码提示符的模态代答:打进 PTY 而非认证队列
|
|
428
|
+
await self.auth._on_nested_password(msg.text, msg.remember)
|
|
429
|
+
elif msg.auth_kind == "remember_yes":
|
|
430
|
+
# 手输密码验证成功后的「记住」询问应答:按留档键写 keyring
|
|
431
|
+
await self.auth._finish_remember(True)
|
|
432
|
+
elif msg.auth_kind == "remember_no":
|
|
433
|
+
await self.auth._finish_remember(False)
|
|
434
|
+
elif msg.auth_kind == "nested_password_cancel":
|
|
435
|
+
# 模态取消/点遮罩:用户改终端手输密码,立即退出验证窗口
|
|
436
|
+
# (否则手输的密码被 hold 到 _PW_VERIFY_TIMEOUT 才放行);
|
|
437
|
+
# 已 hold 的抢跑输入不丢,仍等 auth 结果输出后依序释放
|
|
438
|
+
self.auth._end_pw_verify()
|
|
439
|
+
self._pw_modal_open = False
|
|
440
|
+
elif msg.auth_kind == "host_key":
|
|
441
|
+
await self._hks.put(msg.text == "true")
|
|
442
|
+
elif msg.auth_kind == "cmdset":
|
|
443
|
+
# 命令集密码弹窗回填:只回填等待队列;存储由 runner 按
|
|
444
|
+
# cmdset:<引用名> 键负责,不能走 _store_auth_password
|
|
445
|
+
# (那会把内网机密码错存到目标主机键下)
|
|
446
|
+
await self._pws.put(msg.text)
|
|
447
|
+
elif msg.type == "cmdset_resume":
|
|
448
|
+
# 前端「继续」按钮:命令集暂停等输入时强制放行
|
|
449
|
+
self._cs_resume.set()
|
|
450
|
+
elif msg.type == "interrupt":
|
|
451
|
+
# ⏹/Ctrl+C:直接给 PTY 发 Ctrl+C(不经过 _inbox——命令执行期间
|
|
452
|
+
# 消息会排队到命令结束才处理);shell 空闲时顺带取消 AI 任务。
|
|
453
|
+
if self._interactive:
|
|
454
|
+
await self.ai._maybe_cancel_ai()
|
|
455
|
+
if self.session is not None:
|
|
456
|
+
await self.session.send_raw(b"\x03")
|
|
457
|
+
elif msg.type == "pad":
|
|
458
|
+
# 卡片占位行:让 shell 打印 N 个空行(占位行必须在 shell 输出流
|
|
459
|
+
# 里,ConPTY 的缓冲区模型才认账;前端自行 write 的行会被重绘覆盖)
|
|
460
|
+
# 挂链式任务而**不 inline await**:泵还要收 boundary_settled(门闩
|
|
461
|
+
# ack),堵在 pad 上会让门闩永远等不到 ack 走超时兜底;且超时后
|
|
462
|
+
# 注入不排在 pad 之后 = 回显抢进未打完的空白区(真机 cover 根因)
|
|
463
|
+
if self.session is None:
|
|
464
|
+
return
|
|
465
|
+
if not self._interactive:
|
|
466
|
+
# 直通模式(hook 注入失败/不可集成)没有 __ot_pad 通道:立即
|
|
467
|
+
# ack(宁藏不盖,卡由显隐/夹紧逻辑兜底)。旧实现静默丢弃 →
|
|
468
|
+
# 前端 _padWait 烧满 8s 超时才放行挂卡(直通下分析卡迟到
|
|
469
|
+
# 8s 的根因)
|
|
470
|
+
await self.emit_msg(ServerMsg(
|
|
471
|
+
type="event", event={"kind": "padded",
|
|
472
|
+
"rows": max(1, msg.pad)}))
|
|
473
|
+
return
|
|
474
|
+
prev = self._pad_task
|
|
475
|
+
|
|
476
|
+
async def _run_pad(p=prev, n=max(1, msg.pad), mr=msg.mirror):
|
|
477
|
+
if p is not None:
|
|
478
|
+
try:
|
|
479
|
+
await p
|
|
480
|
+
except Exception:
|
|
481
|
+
pass
|
|
482
|
+
await self.exec.shell_pad(n, mr)
|
|
483
|
+
|
|
484
|
+
self._pad_task = asyncio.create_task(_run_pad())
|
|
485
|
+
elif msg.type == "boundary_settled":
|
|
486
|
+
# 前端流底卡结账垫满:放行等待中的命令注入(见 _boundary_gate)
|
|
487
|
+
self._boundary_ack.set()
|
|
488
|
+
elif msg.type == "submit":
|
|
489
|
+
# 外部触发链:前端拦截的自然语言整行(不再经 shell hook 分类,
|
|
490
|
+
# su - 等 login shell 重置 hook 后依然可用)。
|
|
491
|
+
# 必须走 _inbox 与按键保序:此前内联处理会在 _dispatch 泵的两个
|
|
492
|
+
# 按键之间插进 \r,shell 只接到半行(真机「批准」被截成「批」)
|
|
493
|
+
await self._inbox.put(msg)
|
|
494
|
+
elif msg.type == "rescue":
|
|
495
|
+
# 失败救援卡决策(accept = 交给 AI)
|
|
496
|
+
await self.ai._on_rescue_decision(msg.accept)
|
|
497
|
+
else:
|
|
498
|
+
await self._inbox.put(msg)
|
|
499
|
+
|
|
500
|
+
async def _boundary_gate(self) -> None:
|
|
501
|
+
"""命令注入前等前端把流底分析卡结账垫满(backend.on_boundary 门闩)。
|
|
502
|
+
|
|
503
|
+
回显字节一旦先于 pad 空行进入 PTY 流,卡的连续空白预留区就被打断、
|
|
504
|
+
永久盖帽截断(真机分析卡末行被切)。旧序 ai_collapse 在 exec_start
|
|
505
|
+
才发、回显早已进区;这里把结账提前到注入前:发 ai_boundary → 前端
|
|
506
|
+
flush token 批 + settle 垫满 → 回 boundary_settled → 才放命令进去。
|
|
507
|
+
超时/前端无活卡立即放行:命令响应速度优先于占位美观(同审批 600ms 兜底)。
|
|
508
|
+
"""
|
|
509
|
+
self._boundary_ack.clear()
|
|
510
|
+
await self.emit_msg(ServerMsg(
|
|
511
|
+
type="event", event={"kind": "ai_boundary"}))
|
|
512
|
+
try:
|
|
513
|
+
await asyncio.wait_for(self._boundary_ack.wait(), 0.7)
|
|
514
|
+
except asyncio.TimeoutError:
|
|
515
|
+
pass
|
|
516
|
+
# 服务端排序兜底:前端 ack 走超时兜底时(高延迟真机 pad 往返 >0.7s)
|
|
517
|
+
# 在途 pad 可能还没打完——命令注入必须排在它后面,否则回显写进未打完
|
|
518
|
+
# 的空白区、把连续预留区打断 = 永久盖帽(真机审批卡压回显根因)。
|
|
519
|
+
# shield:门闩超时只放弃等,不能 cancel 掉 pad 任务本身。
|
|
520
|
+
task = self._pad_task
|
|
521
|
+
if task is not None and not task.done():
|
|
522
|
+
try:
|
|
523
|
+
await asyncio.wait_for(asyncio.shield(task), 2.0)
|
|
524
|
+
except asyncio.TimeoutError:
|
|
525
|
+
pass
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# --- 嵌套密码提示符(ssh/sudo 前台讨密码)---
|
|
529
|
+
|
|
530
|
+
# --- 密码状态机委托(实现在 core/auth.py;tests/ 与组件仍经此引用,
|
|
531
|
+
# 长期保留——勿删)---
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
async def ask_password(self, label: str, auth_kind: str = "") -> str:
|
|
535
|
+
return await self.auth.ask_password(label, auth_kind)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
async def close(self) -> None:
|
|
539
|
+
if self._closed:
|
|
540
|
+
return
|
|
541
|
+
self._closed = True
|
|
542
|
+
# 可释放资源钩子(如文件面板的 SSH 连接):放在最前,后面任何一步
|
|
543
|
+
# 抛错都不会漏释放。旧实现靠调用点记得调 close_cascade,断线回收
|
|
544
|
+
# 与 app 关闭两条路径都漏了。getattr 默认空表 → fake worker 零改动。
|
|
545
|
+
for fn in list(getattr(self, "_disposables", [])):
|
|
546
|
+
with suppress(Exception):
|
|
547
|
+
await fn()
|
|
548
|
+
self._curtain = False # 关闭即弃:扣留的提示符字节随会话消失
|
|
549
|
+
self._curtain_parts = []
|
|
550
|
+
if self._ai_task is not None:
|
|
551
|
+
self._ai_queue.clear()
|
|
552
|
+
self._ai_task.cancel()
|
|
553
|
+
if self._cs_task is not None:
|
|
554
|
+
self._cs_task.cancel()
|
|
555
|
+
# 密码 hold/验证窗口的定时器一并撤掉:不然会话关了它们还空转到点
|
|
556
|
+
self.auth._cancel_pw_hold_release()
|
|
557
|
+
self.hook._cancel_hook_watch()
|
|
558
|
+
t = getattr(self, "_pw_verify_timer", None)
|
|
559
|
+
if t is not None:
|
|
560
|
+
t.cancel()
|
|
561
|
+
self._pw_verify_timer = None
|
|
562
|
+
# 解阻塞挂起的认证/审批等待(ask_* 收到 None 视为已关闭)
|
|
563
|
+
for q in (self._pws, self._hks, self._decisions):
|
|
564
|
+
q.put_nowait(None)
|
|
565
|
+
# 先等泵真正退出再关 session:只 cancel 不等的话,泵可能正压在
|
|
566
|
+
# _read_some 上,fd 被关掉后抛 ValueError 成为未取回异常
|
|
567
|
+
await self.pump._stop_pump()
|
|
568
|
+
if self.session is not None:
|
|
569
|
+
await self.session.close()
|
|
570
|
+
await self.emit_msg(ServerMsg(type="closed", text="已关闭"))
|
|
571
|
+
self._closed_sent = True # 排空 backlog 后 sender 才允许退出
|
|
572
|
+
# 唤醒 _run 的 inbox 等待(若 close 来自外部调用);dispatch 到 close 幂等返回
|
|
573
|
+
self._inbox.put_nowait(ClientMsg(type="close"))
|
|
574
|
+
# _run/_sender 各自在 _closed 后自然退出(见 _run / _sender_loop),
|
|
575
|
+
# 不 cancel 当前任务——close 常在 _run 内部被调用,cancel 会打断自己
|
|
576
|
+
|
|
577
|
+
# --- 认证回调(供 SshPtySession 注入)---
|
|
578
|
+
|
|
579
|
+
async def ask_host_key(self, message: str) -> bool:
|
|
580
|
+
await self.emit_msg(ServerMsg(type="ask_host_key", message=message))
|
|
581
|
+
val = await self._hks.get()
|
|
582
|
+
if val is None:
|
|
583
|
+
raise ConnectionError("连接已关闭")
|
|
584
|
+
return val
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _approval_risk(self, command: str) -> str:
|
|
588
|
+
"""审批风险级(单一来源在 Policy.risk:删除/提权/写改/无法判定等 → high)。"""
|
|
589
|
+
return self.policy.risk(command)
|
|
590
|
+
|
|
591
|
+
async def ask_approval(self, command: str, reasons: str, host: str,
|
|
592
|
+
risk: str = "high", id: int = 0) -> dict:
|
|
593
|
+
msg = ServerMsg(
|
|
594
|
+
type="approval", command=command, reasons=reasons, host=host,
|
|
595
|
+
risk=risk, id=id)
|
|
596
|
+
self._pending_approval = msg
|
|
597
|
+
try:
|
|
598
|
+
await self.emit_msg(msg)
|
|
599
|
+
val = await self._decisions.get()
|
|
600
|
+
finally:
|
|
601
|
+
self._pending_approval = None
|
|
602
|
+
if val is None:
|
|
603
|
+
raise ConnectionError("连接已关闭")
|
|
604
|
+
return val
|
|
605
|
+
|
|
606
|
+
# --- 前端抽象出口:子类覆写(web=TabWorker 进 outbox;CLI=CliCore 进渲染队列)---
|
|
607
|
+
async def emit_msg(self, msg: "ServerMsg") -> None:
|
|
608
|
+
raise NotImplementedError
|
|
609
|
+
|
|
610
|
+
async def emit_bytes(self, data: bytes) -> None:
|
|
611
|
+
raise NotImplementedError
|
|
612
|
+
|
|
613
|
+
def _emit_nowait(self, msg: "ServerMsg") -> None:
|
|
614
|
+
raise NotImplementedError
|
|
615
|
+
|
|
616
|
+
# --- 输出:全部经 outbox 串行(保持顺序)---
|
|
617
|
+
def _buffer_offline(self, kind, payload) -> None:
|
|
618
|
+
"""断线隙的 JSON 控制帧攒住等重挂补发。
|
|
619
|
+
|
|
620
|
+
字节帧丢——重放会与前端已有缓冲重复画屏。控制帧(final/审批收尾/
|
|
621
|
+
closed…)丢了前端就永远收不到收束,真机表现为「AI 正在思考」不收。"""
|
|
622
|
+
if kind == "bytes":
|
|
623
|
+
return
|
|
624
|
+
self._offline.append((kind, payload))
|
|
625
|
+
if len(self._offline) > _OFFLINE_CAP:
|
|
626
|
+
del self._offline[:-_OFFLINE_CAP]
|
|
627
|
+
|
|
628
|
+
async def _run(self) -> None:
|
|
629
|
+
try:
|
|
630
|
+
await self._connect()
|
|
631
|
+
except PasswordCancelled:
|
|
632
|
+
await self.emit_msg(ServerMsg(type="closed", text="连接已取消"))
|
|
633
|
+
self._closed = True # sender 据此排空后退出,避免协程泄漏
|
|
634
|
+
self._closed_sent = True
|
|
635
|
+
return
|
|
636
|
+
except Exception as e: # noqa: BLE001 - 连接失败通知前端
|
|
637
|
+
await self.emit_msg(ServerMsg(
|
|
638
|
+
type="closed", text=f"连接失败:{type(e).__name__}: {e}"))
|
|
639
|
+
self._closed = True # sender 据此排空后退出,避免协程泄漏
|
|
640
|
+
self._closed_sent = True
|
|
641
|
+
return
|
|
642
|
+
self.connected.set()
|
|
643
|
+
await self.emit_msg(ServerMsg(
|
|
644
|
+
type="ready", tab_id=self.tab_id,
|
|
645
|
+
host=self._target_host, user=self._target_user,
|
|
646
|
+
distro=self.profile.distro or "",
|
|
647
|
+
prompt=self._cur_prompt,
|
|
648
|
+
model=self._model.model, models=self._models,
|
|
649
|
+
interactive=1 if self._interactive else 0))
|
|
650
|
+
if self._interactive:
|
|
651
|
+
hint = ("shell 集成已就绪:按键直达终端(Tab 补全 / ↑ 历史 / Ctrl+R 搜索"
|
|
652
|
+
"原生可用);命令直接执行,自然语言交给 AI;? 前缀强制 AI,"
|
|
653
|
+
"! 前缀强制执行命令。")
|
|
654
|
+
else:
|
|
655
|
+
hint = "纯终端直通(未检测到可集成的 shell):按键直达终端。"
|
|
656
|
+
await self.emit_msg(ServerMsg(
|
|
657
|
+
type="status",
|
|
658
|
+
text=f"已连接 {self._target_host}"
|
|
659
|
+
f"({self.profile.distro or self.profile.os_family})。{hint}"))
|
|
660
|
+
# 启动期配置错误(协议二选一冲突/都缺)→ 红色警告卡(真机约定:
|
|
661
|
+
# 报错信息都进红卡);顺序在 ready 后,前端 island 已可挂卡
|
|
662
|
+
for err in (getattr(self.cfg, "errors", None) or []):
|
|
663
|
+
self._emit_nowait(ServerMsg(
|
|
664
|
+
type="event", event={"kind": "alert", "text": err}))
|
|
665
|
+
self.pump.start()
|
|
666
|
+
if self._interactive:
|
|
667
|
+
# 注入期的提示符字节被 setup 消化掉了:Ctrl+L 让 shell 重画提示符
|
|
668
|
+
#(ready 已先入 outbox,顺序有保证)
|
|
669
|
+
await self.session.send_raw(b"\x0c")
|
|
670
|
+
if self._target is not None and self._target.commands:
|
|
671
|
+
self.cs._spawn_cmdset()
|
|
672
|
+
while not self._closed:
|
|
673
|
+
msg = await self._inbox.get()
|
|
674
|
+
try:
|
|
675
|
+
await self._dispatch(msg)
|
|
676
|
+
except Exception as e: # noqa: BLE001 - 单条消息出错不能让 tab 变僵尸
|
|
677
|
+
await self.emit_msg(ServerMsg(
|
|
678
|
+
type="status", text=f"处理出错:{type(e).__name__}: {e}"))
|
|
679
|
+
|
|
680
|
+
async def _connect(self) -> None:
|
|
681
|
+
target = self.cfg.targets.get(self.target_name, self.cfg.targets["default"])
|
|
682
|
+
self._target = target
|
|
683
|
+
host = target.host or ("local" if target.mode == "local" else target.name)
|
|
684
|
+
self._target_host = host
|
|
685
|
+
# 用户名与主机同时就绪:下面 probe_profile 要跑几秒,期间文件面板的
|
|
686
|
+
# 登录弹框就要预填「用户名」,等 _setup_interactive 之后才赋值就晚了。
|
|
687
|
+
# 这里先按连接目标给,后面再用 session.username 兜底覆盖。
|
|
688
|
+
self._target_user = target.user or ""
|
|
689
|
+
stored_pw = (load_password_alias(target.host, target.user, target.port)
|
|
690
|
+
if target.mode == "ssh" and target.host else None)
|
|
691
|
+
self.session = await open_session(
|
|
692
|
+
target,
|
|
693
|
+
password=stored_pw,
|
|
694
|
+
default_timeout=self.cfg.shell.timeout_default,
|
|
695
|
+
max_output_bytes=self.cfg.shell.max_output_bytes,
|
|
696
|
+
password_prompt=self.auth.ask_password,
|
|
697
|
+
host_key_prompt=self.ask_host_key,
|
|
698
|
+
# 补存失败经 status 可见(P0-1):同步 _emit_nowait 不阻塞建连
|
|
699
|
+
notify=lambda text: self._emit_nowait(
|
|
700
|
+
ServerMsg(type="status", text=text)),
|
|
701
|
+
)
|
|
702
|
+
# 断线自动重连成功后把历史命令重新灌进新 shell(裸 shell 阶段,
|
|
703
|
+
# 回调内不可走 run()——彼时 _runner_lock 被恢复流程持有)
|
|
704
|
+
self.session.on_reconnect = self._on_session_reconnect
|
|
705
|
+
# 阶段上报:建连(PTY/SSH+shell 启动)已完成,进入 agent 初始化
|
|
706
|
+
# (shell 能力探测 + 集成脚本注入,秒级),前端浮层据此换文案
|
|
707
|
+
await self.emit_msg(ServerMsg(
|
|
708
|
+
type="stage", text="agent_init"))
|
|
709
|
+
self.exec._install_curtain_gate()
|
|
710
|
+
# 画像选择与 CLI switch_target 一致:local 不跑探针(POSIX 探针语句
|
|
711
|
+
# 在 PowerShell 下无哨兵输出,run 会挂满超时 → ready 永不到达)
|
|
712
|
+
if target.mode == "local":
|
|
713
|
+
self.profile = replace(LOCAL_PROFILE, host=host)
|
|
714
|
+
else:
|
|
715
|
+
cache = load_host_cache(app_dir() / "hosts.toml")
|
|
716
|
+
if host in cache:
|
|
717
|
+
self.profile = replace(cache[host], host=host)
|
|
718
|
+
else:
|
|
719
|
+
self.profile = await probe_profile(self.session, host)
|
|
720
|
+
save_host_cache(app_dir() / "hosts.toml", host, self.profile)
|
|
721
|
+
if target.mode == "local":
|
|
722
|
+
self._target_user = getpass.getuser()
|
|
723
|
+
else:
|
|
724
|
+
self._target_user = (target.user
|
|
725
|
+
or getattr(self.session, "username", None)
|
|
726
|
+
or "")
|
|
727
|
+
await self._setup_interactive()
|
|
728
|
+
# Agent(deepagents/langchain/anthropic 栈)不在建连期构建——延迟到
|
|
729
|
+
# 首个 AI 任务的 _ensure_agent()。这样 `ot list`/`ot connect`/纯 shell
|
|
730
|
+
# 使用完全不付 ~4s 的 AI 栈导入与建图成本(启动瓶颈实测在 deepagents)。
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _record_history(self, source: str, command: str) -> None:
|
|
734
|
+
"""落一条命令进历史库;失败静默(历史不能打死主流程)。
|
|
735
|
+
|
|
736
|
+
注入管线内部行(_ot_inj 分片、__ot_exec__/__ot_pad)不入史:每次
|
|
737
|
+
任务重注入都记 ~8 条 ~700B 垃圾,回灌单行超 tty 缓冲会死锁黑屏。
|
|
738
|
+
"""
|
|
739
|
+
if command and self._target_host and not is_internal_line(command):
|
|
740
|
+
history.record(self._target_host, source, command)
|
|
741
|
+
|
|
742
|
+
async def _inject_shell_history(self, shell: str | None,
|
|
743
|
+
flag: str = "-d") -> None:
|
|
744
|
+
"""把该 target 近期命令灌进远端 shell 历史(history / ↑ 原生可见)。
|
|
745
|
+
|
|
746
|
+
必须在集成脚本注入前(裸 shell 阶段)发送;批处理回退下注入行的
|
|
747
|
+
回显会闪现一次,可接受。失败静默降级。
|
|
748
|
+
"""
|
|
749
|
+
if not shell or self.session is None or not self._target_host:
|
|
750
|
+
return
|
|
751
|
+
try:
|
|
752
|
+
rows = history.recent(self._target_host, HISTORY_RECALL_LIMIT)
|
|
753
|
+
cmds = [r["command"] for r in rows
|
|
754
|
+
if not is_internal_line(r["command"])]
|
|
755
|
+
# 单行注入受 tty canonical 缓冲约束(macOS ~1KB):超限从最旧丢起。
|
|
756
|
+
# 截断会让 shell 卡在续行态,后续写入 EAGAIN 自旋死锁(黑屏)。
|
|
757
|
+
line = b""
|
|
758
|
+
while cmds:
|
|
759
|
+
cand = history_inject_line(shell, cmds, flag)
|
|
760
|
+
if len(cand) < 900:
|
|
761
|
+
line = cand
|
|
762
|
+
break
|
|
763
|
+
cmds = cmds[1:]
|
|
764
|
+
if line:
|
|
765
|
+
await self.session.send_raw(line)
|
|
766
|
+
except Exception: # noqa: BLE001 - 会话关闭竞态等
|
|
767
|
+
pass
|
|
768
|
+
|
|
769
|
+
async def _on_session_reconnect(self) -> None:
|
|
770
|
+
# 重连后的新 shell 是裸的(集成脚本已随旧会话消失),用记住的
|
|
771
|
+
# shell 种类重新灌历史;种类未知(探测失败过的批处理)跳过
|
|
772
|
+
await self._inject_shell_history(self._shell_kind, self._b64flag)
|
|
773
|
+
if self._target is not None and self._target.commands:
|
|
774
|
+
self.cs._spawn_cmdset()
|
|
775
|
+
|
|
776
|
+
# --- 连接后命令集委托(实现在 core/cmdset_runner.py;tests/ 仍经此
|
|
777
|
+
# 引用旧名,长期保留——勿删)---
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
async def _setup_interactive(self) -> None:
|
|
781
|
+
"""探测 shell 能力并注入集成脚本(单管线)。
|
|
782
|
+
|
|
783
|
+
成功:_interactive=True——PTY 字节直写前端主 xterm,命令/AI 由
|
|
784
|
+
OSC 133 标记与行报告记账。失败(旧 shell、探测超时、注入后无标记
|
|
785
|
+
回显)静默回退纯终端直通,AI 不可用(interactive=0)。
|
|
786
|
+
"""
|
|
787
|
+
self._interactive = False
|
|
788
|
+
self._router = None
|
|
789
|
+
self._exec_stack = []
|
|
790
|
+
self._pending_report = None
|
|
791
|
+
self._ai_queue = []
|
|
792
|
+
if self.session is None or self.profile is None:
|
|
793
|
+
return
|
|
794
|
+
try:
|
|
795
|
+
r = await self.session.run(
|
|
796
|
+
probe_command(self.profile.os_family), timeout=10)
|
|
797
|
+
shell, flag = parse_probe(r.output or "")
|
|
798
|
+
_dbg("setup probe out=", repr((r.output or "")[:120]),
|
|
799
|
+
"exit=", r.exit_code)
|
|
800
|
+
if not shell:
|
|
801
|
+
# 慢 shell 宽限重试(真机 2026-10-16:Windows conda profile
|
|
802
|
+
# 初始化实测 40s+,首轮 10s 探测必超时/读空重连 → 集成脚本
|
|
803
|
+
# 不注入 → __ot_pad 无 hook → 补差熔断 → 卡片被夹紧截断)。
|
|
804
|
+
# 只对「探测没跑成」重试:超时中断(exit 130)/读空重连
|
|
805
|
+
# (reconnected)。探测跑成但答非所问(旧 shell/怪 shell)
|
|
806
|
+
# 重试无意义,直接降级直通——也避免测试桩(恒回 "hello")
|
|
807
|
+
# 被无谓拖一轮。
|
|
808
|
+
probe_ran = not (
|
|
809
|
+
r.exit_code == 130
|
|
810
|
+
or getattr(r, "reconnected", False)
|
|
811
|
+
or not (r.output or "").strip())
|
|
812
|
+
if probe_ran:
|
|
813
|
+
_dbg("setup: probe 未识别 shell → 纯终端直通")
|
|
814
|
+
return
|
|
815
|
+
_dbg("setup: probe 未跑成,宽限重试")
|
|
816
|
+
await asyncio.sleep(3)
|
|
817
|
+
r = await self.session.run(
|
|
818
|
+
probe_command(self.profile.os_family), timeout=45)
|
|
819
|
+
shell, flag = parse_probe(r.output or "")
|
|
820
|
+
_dbg("setup probe retry out=", repr((r.output or "")[:120]),
|
|
821
|
+
"exit=", r.exit_code)
|
|
822
|
+
if not shell:
|
|
823
|
+
_dbg("setup: probe 未识别 shell → 纯终端直通")
|
|
824
|
+
return
|
|
825
|
+
# 历史静默窗口(POSIX):此后所有注入行带前导空格,ignorespace/
|
|
826
|
+
# hist_ignore_space 让原生入史整体跳过;脚本尾部按快照还原并
|
|
827
|
+
# 清掉旧版本已落盘的垃圾(用户报障「↑ 全是 __ot_inj 分片」)
|
|
828
|
+
if shell != "powershell":
|
|
829
|
+
await self.session.send_raw(history_quiet_line())
|
|
830
|
+
# 等 shell 消化完再发下一波:rc 尾声(conda init 等)zle 尚未
|
|
831
|
+
# 读输入,行规程缓冲积压会整段丢字节——真机注入竞态根因
|
|
832
|
+
# (丢在引号中间 → shell 卡 quote> 续行 → 8s 兜底 → 直通)
|
|
833
|
+
await self.hook._drain_quiet(0.15)
|
|
834
|
+
# 历史注入须在集成脚本之前(裸 shell 阶段执行 history -r / fc -R)
|
|
835
|
+
await self._inject_shell_history(shell, flag)
|
|
836
|
+
if shell != "powershell":
|
|
837
|
+
await self.hook._drain_quiet(0.15)
|
|
838
|
+
router = StreamRouter()
|
|
839
|
+
_dbg("setup: inject shell=", shell, "flag=", flag,
|
|
840
|
+
"chunks=", len(injection_lines(shell, 1, flag)))
|
|
841
|
+
# 分片注入:单片超 tty canonical 缓冲(macOS 本地 ~1KB)会被
|
|
842
|
+
# 静默截断;片间写+读交替——此阶段泵未启动,若只写不读,zle 的
|
|
843
|
+
# 回显会塞满输出队列 → zle 停读 → 输入队列满 → 写入自旋死锁。
|
|
844
|
+
# 回显喂给 router(SWALLOW 相位只解析标记,echo 不外发)。
|
|
845
|
+
# _write_sink:注入写入 EAGAIN 停顿期间由 _write_raw 边写边读
|
|
846
|
+
# 输出喂 router,双向队列互满时不再死锁。
|
|
847
|
+
def _sink(chunk: bytes) -> None:
|
|
848
|
+
nonlocal prompt
|
|
849
|
+
for ev in router.feed(chunk):
|
|
850
|
+
if ev[0] == "prompt" and prompt is None:
|
|
851
|
+
prompt = ev[1]
|
|
852
|
+
|
|
853
|
+
prompt: str | None = None
|
|
854
|
+
|
|
855
|
+
self.session._write_sink = _sink
|
|
856
|
+
try:
|
|
857
|
+
# 注入竞态自愈:shell 若已卡进续行提示符(quote>/dquote>/
|
|
858
|
+
# cmdsubst>,输入字节被行规程丢弃、引号失配的指纹),当前
|
|
859
|
+
# 批必不执行——\x15 收残行 + ^C 回提示符后整链重灌一次,
|
|
860
|
+
# 不再烧满 8s 兜底才回退直通。b64 字母表无空格/`>`,误报面
|
|
861
|
+
# 仅限注入行回显本身;误报代价 = 一次无害清行重试。
|
|
862
|
+
for _attempt in (1, 2):
|
|
863
|
+
ps2_buf = bytearray()
|
|
864
|
+
|
|
865
|
+
def _scan_ps2(b: bytes) -> None:
|
|
866
|
+
ps2_buf.extend(b)
|
|
867
|
+
del ps2_buf[:-64]
|
|
868
|
+
|
|
869
|
+
for _ci, chunk_line in enumerate(
|
|
870
|
+
injection_lines(shell, 1, flag)):
|
|
871
|
+
_dbg("chunk", _ci, "sending", len(chunk_line), "B")
|
|
872
|
+
await self.session.send_raw(chunk_line)
|
|
873
|
+
_dbg("chunk", _ci, "sent, draining")
|
|
874
|
+
drained = await self.hook._drain_quiet(0.12, router,
|
|
875
|
+
raw_sink=_scan_ps2)
|
|
876
|
+
_dbg("chunk", _ci, "drained, prompt=", drained is not None)
|
|
877
|
+
# sink 可能在写入停顿期已捕获提示符;drain 未再见时不覆盖
|
|
878
|
+
if drained is not None:
|
|
879
|
+
prompt = drained
|
|
880
|
+
# 这里不得因「提示符已到」提前停发:bash 的注入链是
|
|
881
|
+
# base64…→ eval → bind 三段,eval 一落地 hook 的 PS1
|
|
882
|
+
# 就带 OSC 标记地画提示符——在它处 break 会把末行
|
|
883
|
+
# bind 丢下,\C-m 维持原生 accept-line,自然语言被
|
|
884
|
+
# shell 当命令执行(真机 CI:'please' not found)。
|
|
885
|
+
# 提示符只是「hook 已活」的成功凭据,留给循环结束
|
|
886
|
+
# 后的等待逻辑消费;本循环的职责是把每一行都发完。
|
|
887
|
+
if _PS2_ECHO_RE.search(bytes(ps2_buf)):
|
|
888
|
+
_dbg("setup: 续行提示符指纹,清行重试",
|
|
889
|
+
f"attempt={_attempt}")
|
|
890
|
+
await self.session.send_raw(b"\x15\x03")
|
|
891
|
+
await self.hook._drain_quiet(0.5, router)
|
|
892
|
+
break
|
|
893
|
+
else:
|
|
894
|
+
break # 全部片发完且未见续行指纹:不需要重试
|
|
895
|
+
if prompt is not None:
|
|
896
|
+
break
|
|
897
|
+
finally:
|
|
898
|
+
self.session._write_sink = None
|
|
899
|
+
loop = asyncio.get_running_loop()
|
|
900
|
+
deadline = loop.time() + 8
|
|
901
|
+
while loop.time() < deadline and prompt is None:
|
|
902
|
+
try:
|
|
903
|
+
chunk = await self.session._read_some(0.3)
|
|
904
|
+
except asyncio.TimeoutError:
|
|
905
|
+
continue
|
|
906
|
+
except (ConnectionError, EOFError):
|
|
907
|
+
return
|
|
908
|
+
if not chunk:
|
|
909
|
+
continue
|
|
910
|
+
_dbg("wait raw=", repr(chunk[:60]))
|
|
911
|
+
for ev in router.feed(chunk):
|
|
912
|
+
if ev[0] == "prompt":
|
|
913
|
+
prompt = ev[1]
|
|
914
|
+
break
|
|
915
|
+
if prompt is None:
|
|
916
|
+
# 注入行可能被 tty 行缓冲截断(macOS 本地 canonical 缓冲
|
|
917
|
+
# ~1KB,装不下 ~2KB 的注入行):残留半行会把 shell 卡在
|
|
918
|
+
# dquote cmdsubst> 续行提示,后续哨兵 run 全部超时。清行
|
|
919
|
+
# 中断后回退直通模型。
|
|
920
|
+
_dbg("setup: 注入后 8s 无提示符标记 → 纯终端直通")
|
|
921
|
+
await self.session.send_raw(b"\x15\x03")
|
|
922
|
+
return
|
|
923
|
+
self._router = router
|
|
924
|
+
self._shell_kind = shell
|
|
925
|
+
self._b64flag = flag
|
|
926
|
+
self._cur_prompt = prompt
|
|
927
|
+
self._runner = InteractiveRunner(self)
|
|
928
|
+
self._interactive = True
|
|
929
|
+
# 后台预热 AI 栈:首条自然语言不再在 _run_task 里付秒级导入
|
|
930
|
+
# (真机分析卡延迟根因之二);导入走线程池不堵事件循环
|
|
931
|
+
asyncio.get_running_loop().create_task(self.ai._prewarm_agent())
|
|
932
|
+
except Exception: # noqa: BLE001 - 探测/注入任何失败都回退直通
|
|
933
|
+
import traceback
|
|
934
|
+
_dbg("setup: 异常回退纯终端直通", traceback.format_exc())
|
|
935
|
+
self._interactive = False
|
|
936
|
+
|
|
937
|
+
@property
|
|
938
|
+
def files_identity(self) -> dict:
|
|
939
|
+
"""文件面板该连哪台机器、以谁的身份。
|
|
940
|
+
|
|
941
|
+
跳板机场景(连 A、终端里 `ssh B`)下会话的 SFTP 挂在 A 上,够不着
|
|
942
|
+
B 的文件——面板要经隧道另连 B。host/port 取**最近一层未闭合 ssh 帧**
|
|
943
|
+
(ssh_frame_target:`sudo su -` 开帧后仍要认出 B,用
|
|
944
|
+
nested_ssh_target 会退回跳板机);user 优先取提示符里的用户名
|
|
945
|
+
(`sudo su -` 之后是 root,面板以该身份连才看得到 /root),拿不到
|
|
946
|
+
就退 ssh 帧里的登录名。
|
|
947
|
+
|
|
948
|
+
`users` 是连接候选(按优先级):连不上时逐个退,直到连上或全部
|
|
949
|
+
缺凭据。`nested=False` 表示终端没跳,面板用会话自己的 SFTP 即可。
|
|
950
|
+
"""
|
|
951
|
+
from .cmdset_runner import _CS_PROMPT_ID_RE
|
|
952
|
+
|
|
953
|
+
thost, tuser, tport = self.nested.ssh_frame_target()
|
|
954
|
+
session_host = getattr(self, "_target_host", "") or ""
|
|
955
|
+
host = thost or session_host
|
|
956
|
+
m = _CS_PROMPT_ID_RE.match((self._cur_prompt or "").strip())
|
|
957
|
+
eff = m.group(1) if m else ""
|
|
958
|
+
session_user = getattr(self, "_target_user", "") or ""
|
|
959
|
+
target = getattr(self, "_target", None)
|
|
960
|
+
users = []
|
|
961
|
+
for u in (eff, tuser, session_user,
|
|
962
|
+
getattr(target, "user", None) or ""):
|
|
963
|
+
if u and u not in users:
|
|
964
|
+
users.append(u)
|
|
965
|
+
return {
|
|
966
|
+
"host": host,
|
|
967
|
+
# 非嵌套时 ssh_frame_target 全 None,但用户连的可能不是 22 端口
|
|
968
|
+
# (诊断拓扑正是 127.0.0.1:2222)——端口栏必须预填真实端口
|
|
969
|
+
"port": (tport if thost
|
|
970
|
+
else (getattr(target, "port", None) if target else None)),
|
|
971
|
+
"users": users,
|
|
972
|
+
"effective_user": eff or (users[0] if users else ""),
|
|
973
|
+
"nested": bool(thost) and (thost != session_host or tport is not None),
|
|
974
|
+
# 本机终端没有「最终服务器」(host 是字面量 "local"),
|
|
975
|
+
# 文件面板据此隐藏 📁 入口,别把 "local" 当 IP 填进表单
|
|
976
|
+
"mode": getattr(target, "mode", "") if target else "",
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async def _dispatch(self, msg: ClientMsg) -> None:
|
|
980
|
+
if msg.type == "raw":
|
|
981
|
+
await self._on_keys(msg.data)
|
|
982
|
+
elif msg.type == "submit":
|
|
983
|
+
# 与按键同队列保序(feed_msg 只入队):半行 \r 截断中文的根因修复
|
|
984
|
+
if self.session is not None and msg.text:
|
|
985
|
+
await self.ai._on_frontend_line(msg.text, msg.dirty)
|
|
986
|
+
elif msg.type == "mode":
|
|
987
|
+
await self.ai._switch_display(msg.text)
|
|
988
|
+
elif msg.type == "resize":
|
|
989
|
+
if self.session is not None:
|
|
990
|
+
await self.session.resize(msg.rows, msg.cols)
|
|
991
|
+
elif msg.type == "change_model":
|
|
992
|
+
await self.ai._change_model(msg.model)
|
|
993
|
+
elif msg.type == "close":
|
|
994
|
+
await self.close()
|
|
995
|
+
elif msg.type == "new_session":
|
|
996
|
+
# 总结卡「开启新会话」:只重置模型上下文,不清屏不动 PTY
|
|
997
|
+
#(界面等效回车,由前端发);与 /clear 的区别就是没有清屏
|
|
998
|
+
self._reset_context()
|
|
999
|
+
|
|
1000
|
+
# --- 按键 / AI 取消 ---
|
|
1001
|
+
async def _on_keys(self, data: bytes) -> None:
|
|
1002
|
+
"""键盘字节直发 PTY(§5.4:单管线下 AI 工作时用户照样敲命令;
|
|
1003
|
+
AI 注入的 Ctrl+U/Ctrl+Y 括号保护覆盖唯一竞争点——真提示符半行)。"""
|
|
1004
|
+
if self.session is None or not data:
|
|
1005
|
+
return
|
|
1006
|
+
if self._session_dead and not await self._revive_session():
|
|
1007
|
+
return
|
|
1008
|
+
if self.auth.feed_input(data):
|
|
1009
|
+
return
|
|
1010
|
+
_dbg("keys=", repr(data[:60]))
|
|
1011
|
+
if self._interactive and b"\x03" in data:
|
|
1012
|
+
await self.ai._maybe_cancel_ai()
|
|
1013
|
+
await self.session.send_raw(data)
|
|
1014
|
+
# 回车看门狗:用户敲的回车是「hook 还在不在」最自然的验活点。命令行
|
|
1015
|
+
# 回车**不经** _on_frontend_line——前端只对自然语言发 submit,
|
|
1016
|
+
# `sudo su -` 这类命令是纯按键透传(真机 [otdbg] 只有 keys= b'\r'),
|
|
1017
|
+
# 只在 submit 上武装就永远迟一步:到点时裸 \r 早已把整行自然语言交给
|
|
1018
|
+
# 落回来的裸壳执行。集成壳的回车必有 PROMPT_COMMAND 回响(133;D +
|
|
1019
|
+
# 133;A/B),只有文本没有标记 = 承接回车的不是集成壳。
|
|
1020
|
+
# 密码位上的回车不验活:那是 sudo/ssh 的读取器在读密码,本来就不发
|
|
1021
|
+
# 标记,据此判陈旧是误判。工具命令在途同理(标记由 agent 帧负责)。
|
|
1022
|
+
if (b"\r" in data or b"\n" in data) and self.hook._hook_ok() \
|
|
1023
|
+
and self._exec_future is None \
|
|
1024
|
+
and not self._pw_at_end and not self._pw_prompt_seen:
|
|
1025
|
+
self.hook._arm_hook_watch()
|
|
1026
|
+
|
|
1027
|
+
async def _revive_session(self) -> bool:
|
|
1028
|
+
"""断线后首个键触发重连:恢复会话、补启泵并通知前端。
|
|
1029
|
+
|
|
1030
|
+
持 _run_lock 防 AI 命令超时路径(_handle_timeout)并发恢复——双
|
|
1031
|
+
close/start 会交错杀进程。run() 哨兵路径可能已把会话换新(泵读到
|
|
1032
|
+
旧 fd 的 EOF 才走到这里),此时恢复是多余的但无害:新 shell 再
|
|
1033
|
+
重启一次,历史注入随 _notify_reconnect 重放。恢复期间的密码弹窗
|
|
1034
|
+
经 _pws 队列应答(不走 _inbox),此处阻塞不饿死应答。
|
|
1035
|
+
"""
|
|
1036
|
+
if self.session is None:
|
|
1037
|
+
return False
|
|
1038
|
+
async with self.session._run_lock:
|
|
1039
|
+
try:
|
|
1040
|
+
ok = await self.session.recover()
|
|
1041
|
+
except Exception: # noqa: BLE001 - 目标不可达/认证取消等
|
|
1042
|
+
ok = False
|
|
1043
|
+
if not ok:
|
|
1044
|
+
await self.emit_msg(ServerMsg(
|
|
1045
|
+
type="status", text="重连失败:目标暂不可达,稍后敲键可重试"))
|
|
1046
|
+
return False
|
|
1047
|
+
self._session_dead = False
|
|
1048
|
+
if self._pump is None or self._pump.done():
|
|
1049
|
+
self.pump.start()
|
|
1050
|
+
await self.emit_msg(ServerMsg(
|
|
1051
|
+
type="reconnected", text=f"已重新连接 {self._target_host or '目标'}"))
|
|
1052
|
+
return True
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _reset_context(self) -> None:
|
|
1056
|
+
"""模型上下文归零:换 thread_id(langgraph MemorySaver 按 thread 存
|
|
1057
|
+
历史,新任务的提示词不再带旧上下文,省 token)+ 换 transcript 文件。
|
|
1058
|
+
不碰 PTY——清屏与否由调用方决定(/clear 清屏,new_session 不清)。"""
|
|
1059
|
+
self._ctx_epoch += 1
|
|
1060
|
+
self.transcript = open_transcript(self.tab_id)
|
|
1061
|
+
|
|
1062
|
+
|
|
1063
|
+
async def _switch_target(self, line: str) -> None:
|
|
1064
|
+
from ..connections import list_ssh_hosts, load_saved_targets
|
|
1065
|
+
|
|
1066
|
+
names = list(dict.fromkeys(
|
|
1067
|
+
["local"] + [t.name for t in load_saved_targets()] + list_ssh_hosts()))
|
|
1068
|
+
parts = line.split(maxsplit=1)
|
|
1069
|
+
arg = parts[1] if len(parts) == 2 else ""
|
|
1070
|
+
if not arg:
|
|
1071
|
+
await self.emit_msg(ServerMsg(
|
|
1072
|
+
type="status", text="可用目标:" + ", ".join(names)))
|
|
1073
|
+
return
|
|
1074
|
+
target = "default" if arg == "local" else self._register_target(arg)
|
|
1075
|
+
if self._ai_task is not None:
|
|
1076
|
+
self._ai_queue.clear()
|
|
1077
|
+
self._ai_task.cancel()
|
|
1078
|
+
self._ai_task = None
|
|
1079
|
+
if self._pump is not None:
|
|
1080
|
+
self._pump.cancel()
|
|
1081
|
+
self._pump = None
|
|
1082
|
+
if self.session is not None:
|
|
1083
|
+
await self.session.close()
|
|
1084
|
+
self.session = self.profile = self.agent = None
|
|
1085
|
+
self._display = "agent"
|
|
1086
|
+
self._runner = None
|
|
1087
|
+
self.target_name = target
|
|
1088
|
+
try:
|
|
1089
|
+
await self._connect()
|
|
1090
|
+
except Exception as e: # noqa: BLE001 - 切换失败不打死 tab,回状态提示
|
|
1091
|
+
await self.emit_msg(ServerMsg(
|
|
1092
|
+
type="status", text=f"切换目标失败:{type(e).__name__}: {e}"))
|
|
1093
|
+
return
|
|
1094
|
+
await self.emit_msg(ServerMsg(
|
|
1095
|
+
type="ready", tab_id=self.tab_id, host=self._target_host,
|
|
1096
|
+
user=self._target_user, distro=self.profile.distro or "",
|
|
1097
|
+
prompt=self._cur_prompt,
|
|
1098
|
+
interactive=1 if self._interactive else 0))
|
|
1099
|
+
self.pump.start()
|
|
1100
|
+
if self._interactive:
|
|
1101
|
+
await self.session.send_raw(b"\x0c")
|
|
1102
|
+
|
|
1103
|
+
def _register_target(self, text: str) -> str:
|
|
1104
|
+
from ..config import TargetConfig
|
|
1105
|
+
from ..connections import load_saved_targets, parse_user_at_host
|
|
1106
|
+
|
|
1107
|
+
if text in self.cfg.targets or text in {"local", "default"}:
|
|
1108
|
+
return text
|
|
1109
|
+
# 记住的连接按 name 直接注册(含 host/user/port),否则 ad-hoc 重建
|
|
1110
|
+
for t in load_saved_targets():
|
|
1111
|
+
if t.name == text:
|
|
1112
|
+
self.cfg.targets[text] = t
|
|
1113
|
+
return text
|
|
1114
|
+
host, user, port = parse_user_at_host(text)
|
|
1115
|
+
name = text if user is None and port is None else host
|
|
1116
|
+
if name not in self.cfg.targets:
|
|
1117
|
+
self.cfg.targets[name] = TargetConfig(
|
|
1118
|
+
name=name, mode="ssh", host=host, user=user, port=port)
|
|
1119
|
+
return name
|
|
1120
|
+
|
|
1121
|
+
async def _manual_system(self, line: str) -> None:
|
|
1122
|
+
from ..agent.sysprobe import MANUAL_PRESETS
|
|
1123
|
+
|
|
1124
|
+
parts = line.split()
|
|
1125
|
+
if len(parts) < 2 or parts[1] not in MANUAL_PRESETS:
|
|
1126
|
+
await self.emit_msg(ServerMsg(
|
|
1127
|
+
type="status", text="可选:" + ", ".join(MANUAL_PRESETS)))
|
|
1128
|
+
return
|
|
1129
|
+
self.profile = replace(self.profile, **MANUAL_PRESETS[parts[1]])
|
|
1130
|
+
if self.agent is not None:
|
|
1131
|
+
self.ai._build_agent_now() # 已建才重建;未建由 _ensure_agent 用新画像
|
|
1132
|
+
await self.emit_msg(ServerMsg(
|
|
1133
|
+
type="status", text=f"系统方言已手动设为 {parts[1]}"))
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
# --- 输出泵委托(实现在 core/pump.py;core 内部仍经此引用,
|
|
1137
|
+
# 长期保留——勿删)---
|
|
1138
|
+
|
|
1139
|
+
async def _pump_eof(self, text: str) -> None:
|
|
1140
|
+
if self._ai_task is not None:
|
|
1141
|
+
self._ai_queue.clear()
|
|
1142
|
+
self._ai_task.cancel()
|
|
1143
|
+
self._session_dead = True
|
|
1144
|
+
await self.emit_msg(ServerMsg(type="closed", text=f"{text};敲键自动重连"))
|
|
1145
|
+
|
|
1146
|
+
# --- 集成模式:标记流事件 ---
|
|
1147
|
+
# --- 标记流/注入/幕帘委托(实现在 core/exec_events.py;tests/ 与
|
|
1148
|
+
# ai_flow 仍经此引用旧名,长期保留——勿删)---
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
class CorePresenter:
|
|
1152
|
+
"""backend 的同步回调 → 执行登记/清理(显示由 exec 状态机直写主 xterm)。"""
|
|
1153
|
+
|
|
1154
|
+
def __init__(self, worker: PipelineCore) -> None:
|
|
1155
|
+
self.w = worker
|
|
1156
|
+
|
|
1157
|
+
def on_start(self, command: str) -> None:
|
|
1158
|
+
# 模型可能一轮并发发起多个工具调用(on_start 全部先到),而真实执行
|
|
1159
|
+
# 被 run 锁串行化——命令登记进队列,真实归属由 exec 状态机在 C 标记
|
|
1160
|
+
# 时弹出(保证 ai_collapse 与输出按真实执行顺序出现)
|
|
1161
|
+
self.w._pending_agent_cmds.append(command)
|
|
1162
|
+
|
|
1163
|
+
def on_finish(self, command: str, exit_code: int) -> None:
|
|
1164
|
+
w = self.w
|
|
1165
|
+
if command in w._pending_agent_cmds:
|
|
1166
|
+
# 命令未执行(目标忙 / 执行前取消):清理登记,否则下一个命令
|
|
1167
|
+
# 会错拿这条开 ai_collapse
|
|
1168
|
+
w._pending_agent_cmds.remove(command)
|
|
1169
|
+
elif w._agent_open_cmd == command:
|
|
1170
|
+
# 兜底 C 已到但 D 迟迟不到的超时路径(Ctrl+C 后 3s 宽限仍无 D)
|
|
1171
|
+
w._agent_open_cmd = None
|
|
1172
|
+
|
|
1173
|
+
def __getattr__(name: str):
|
|
1174
|
+
# PEP 562:_HOOK_LIVENESS 已拆 core/hook_gate.py;模块级 from-import 会在
|
|
1175
|
+
# 「core 子模块先于 _core_impl 导入」的顺序下成环(子模块注册触发包桥
|
|
1176
|
+
# __setattr__ → 拉起本模块 → 再回头要半初始化 hook_gate 的符号),
|
|
1177
|
+
# 改惰性再导出供 core 包桥 getattr 读取(tests/test_nested_session.py)
|
|
1178
|
+
if name == "_HOOK_LIVENESS":
|
|
1179
|
+
from .hook_gate import _HOOK_LIVENESS
|
|
1180
|
+
return _HOOK_LIVENESS
|
|
1181
|
+
if name in ("CMDSET_ECHO_TIMEOUT", "CMDSET_QUIET", "CMDSET_PROMPT_TIMEOUT",
|
|
1182
|
+
"CMDSET_RESUME_QUIET", "CMDSET_HOOK_WAIT", "_CMDSET_PROMPT_RE",
|
|
1183
|
+
"_CMDSET_SECRET_RE", "_CMDSET_CONFIRM_RE", "_CMDSET_DONE_RE",
|
|
1184
|
+
"_CS_PROMPT_ID_RE"):
|
|
1185
|
+
from . import cmdset_runner as _cs
|
|
1186
|
+
return getattr(_cs, name)
|
|
1187
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|