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,650 @@
|
|
|
1
|
+
"""执行上下文与标记流(Task 4 自 PipelineCore 原样搬入)。
|
|
2
|
+
|
|
3
|
+
OSC 133 标记流状态机(A/B/C/D/report)、EXEC 帧栈、幕帘、pad 注入、
|
|
4
|
+
^C 回显吞除、工具卡 id 记账——泵责任链的「exec 标记流」段与注入器。
|
|
5
|
+
状态**物理存 core 实例**(P3 裁定延续:pump/AI 流/命令集与测试直读
|
|
6
|
+
core._exec_* 等;ExecEvents.__init__ 以 hasattr 守卫补建,预置值不覆
|
|
7
|
+
盖),方法体经 self.core 读写。
|
|
8
|
+
|
|
9
|
+
组件公开接口:on_stream_event / on_exec_start / on_exec_end /
|
|
10
|
+
feed_output(标记流与文本路径)、shell_pad(卡片占位注入)、
|
|
11
|
+
strip_intr_echo(^C 回显吞除)、open_cmds(用户命令帧记账,只读)。
|
|
12
|
+
InteractiveRunner(core.messages)经 core._exec_future/_exec_buf 与本
|
|
13
|
+
组件共享执行通道状态——物理位置在 core,两边同径访问。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import time
|
|
20
|
+
from collections import deque
|
|
21
|
+
|
|
22
|
+
from .messages import ServerMsg, _dbg, _ansi_free
|
|
23
|
+
from ..shell.integration import is_internal_line
|
|
24
|
+
from ..session.base import CommandResult
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _strip_pad_echo(seg: str) -> str:
|
|
28
|
+
"""滤掉 ``__ot_pad`` 注入自身的回显行(含裸壳的 command not found)。
|
|
29
|
+
|
|
30
|
+
hook 在位时它清掉自己的回显,用户看不到;hook 刚掉线(``su -`` 换壳)时
|
|
31
|
+
``_hook_ok`` 仍是陈旧真值,注入落进裸壳,回显 / ``^Y`` / ``bash: __ot_pad:
|
|
32
|
+
command not found`` 全部外显 = 真机图一的「无效命令输出」。
|
|
33
|
+
|
|
34
|
+
只滤这三样,**hook 打出的 N 个空行必须原样放行**——那是垫行本体,吞了
|
|
35
|
+
blankSpan 就永远数不满、卡被夹紧截断。整行丢弃(不是就地删词):
|
|
36
|
+
``-bash: __ot_pad: command not found`` 只删 token 会留下残句。
|
|
37
|
+
"""
|
|
38
|
+
if not seg:
|
|
39
|
+
return seg
|
|
40
|
+
out = []
|
|
41
|
+
for ln in seg.splitlines(keepends=True):
|
|
42
|
+
if "__ot_pad" in ln:
|
|
43
|
+
continue # 注入行回显 / 其 command not found
|
|
44
|
+
ln = ln.replace("\x19", "").replace("^Y", "")
|
|
45
|
+
if ln.strip() or ln:
|
|
46
|
+
out.append(ln) # 空白行(垫行)与正文都放行
|
|
47
|
+
return "".join(out)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ExecEvents:
|
|
51
|
+
def __init__(self, core) -> None:
|
|
52
|
+
self.core = core
|
|
53
|
+
for name, val in (
|
|
54
|
+
("_cur_prompt", ""), # 最近一次 B 标记捕获的提示符纯文本
|
|
55
|
+
("_pending_report", None), # C 标记前最近的行报告 (inst, kind, line)
|
|
56
|
+
("_open_cmds", {}), # 用户命令记账 inst -> line(退出码)
|
|
57
|
+
("_open_cmd_at", {}), # 用户命令帧开帧时刻(内层 ssh 提示符活性判定)
|
|
58
|
+
("_exec_stack", []), # 执行上下文栈:agent/probe/silent
|
|
59
|
+
("_hidden_exec", False), # 隐藏探测在途(ctx=probe,输出只捕获不转发)
|
|
60
|
+
("_exec_buf", bytearray()), # AI 工具命令输出捕获
|
|
61
|
+
("_exec_truncated", False),
|
|
62
|
+
("_suppress_live", False), # 注入窗口吞 live 字节(注入行回显不外显)
|
|
63
|
+
("_suppressed_echo", False), # 窗口内吞过回显:exec 开始时补 \r\n 收束行
|
|
64
|
+
("_suppressed_tail", ""), # 窗内吞掉的末尾残段(= shell 新画的提示符)
|
|
65
|
+
("_repaint_text", None), # 窗口收束后补画的 NL 行(蓝 33)
|
|
66
|
+
("_pending_agent_cmds", deque()), # 已登记未执行的 AI 工具命令
|
|
67
|
+
("_agent_open_cmd", None), # 已开 ai_collapse、尚未收束的命令
|
|
68
|
+
("_task_cmd_started", False), # 本任务是否已发 ai_collapse(首个工具命令)
|
|
69
|
+
("_tool_card_seq", 0), # 工具卡 id 全会话唯一(前端 slot/feed 按 id 迁移)
|
|
70
|
+
("_tool_idx_map", {}), # runner 局部 index → 全局卡 id
|
|
71
|
+
("_intr_swallow_until", 0.0), # ^C 回显吞除窗口(跨块悬置)
|
|
72
|
+
("_intr_pending", b""), # 悬置的块尾 "^"(等跨块 C)
|
|
73
|
+
("_user_out", bytearray()), # 当前用户命令输出捕获(救援上下文)
|
|
74
|
+
("_curtain", False), # 提示符幕帘:任务期扣住提示符重绘字节
|
|
75
|
+
("_curtain_parts", []), # 扣住的 live 文本(按序)
|
|
76
|
+
("_curtain_at", 0.0), # 关帘时刻(防呆:超时自动放行)
|
|
77
|
+
("_pad_reinject_at", 0.0), # _shell_pad hook 补注入节流阀(monotonic)
|
|
78
|
+
("_reinject_pending", False), # 任务起跑重注入在途(pad 路径免重试)
|
|
79
|
+
("_pad_task", None), # 在途 pad 打字任务(门闩排序用)
|
|
80
|
+
("_pad_inflight", False), # pad 行已敲进 PTY、回显未消化(指纹扫描窗)
|
|
81
|
+
("_pad_echo_carry", ""), # pad 回显净化的半行暂存(分片切开时防漏滤)
|
|
82
|
+
("_pad_prompt_timeout", 2.0), # pad 每块等提示符重画的上限
|
|
83
|
+
):
|
|
84
|
+
if not hasattr(core, name):
|
|
85
|
+
setattr(core, name, val)
|
|
86
|
+
|
|
87
|
+
def _install_curtain_gate(self) -> None:
|
|
88
|
+
"""把 session.send_raw 包上开帘闸:任务幕帘关着时,任何 PTY 写入
|
|
89
|
+
(命令/pad/口令/镜像注入,含 runner 直写)先放行扣住的字节再写入——
|
|
90
|
+
字节序不变,唯一的可见性变化是提示符段整体后移。实例属性遮蔽方法,
|
|
91
|
+
对持有同一 session 的 runner/backend 全部生效。"""
|
|
92
|
+
if self.core.session is None:
|
|
93
|
+
return
|
|
94
|
+
raw_send = self.core.session.send_raw
|
|
95
|
+
|
|
96
|
+
async def _gated(data: bytes, _raw=raw_send):
|
|
97
|
+
# 注入抑制窗内绝不开帘:帘一开就把窗内吞掉的提示符段成排漏
|
|
98
|
+
# 出去,一片接一片落在上一提示符的光标列上(横排提示符墙,
|
|
99
|
+
# 真机截图)。窗末由 _resync_after_suppress 统一对齐。
|
|
100
|
+
if self.core._curtain and not self.core._suppress_live:
|
|
101
|
+
await self._curtain_open()
|
|
102
|
+
await _raw(data)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
self.core.session.send_raw = _gated # type: ignore[method-assign]
|
|
106
|
+
except Exception: # noqa: BLE001 - 只读会话对象则退化为无幕帘
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
async def _curtain_open(self) -> None:
|
|
110
|
+
"""开帘:把扣住的 live 文本按原序放行(无扣留则空操作)。"""
|
|
111
|
+
if not self.core._curtain:
|
|
112
|
+
return
|
|
113
|
+
self.core._curtain = False
|
|
114
|
+
parts, self.core._curtain_parts = self.core._curtain_parts, []
|
|
115
|
+
if parts:
|
|
116
|
+
await self.core.emit_bytes("".join(parts).encode("utf-8", "replace"))
|
|
117
|
+
|
|
118
|
+
def _curtain_close(self) -> None:
|
|
119
|
+
if self.core._ai_task is None:
|
|
120
|
+
return
|
|
121
|
+
self.core._curtain = True
|
|
122
|
+
self.core._curtain_parts = []
|
|
123
|
+
self.core._curtain_at = time.monotonic()
|
|
124
|
+
|
|
125
|
+
async def _shell_pad(self, rows: int, mirror: str | None = None) -> None:
|
|
126
|
+
"""注入 ``__ot_pad N``:hook 打印 N 个空行并清掉自身回显。
|
|
127
|
+
等提示符重画完成(空行已落终端缓冲)再应答前端挂卡。
|
|
128
|
+
与 InteractiveRunner 注入共用 _runner_lock:两者都往 PTY 输入
|
|
129
|
+
缓冲送 \x15…\r 注入序列,交错会污染彼此的半行收纳。
|
|
130
|
+
单次注入限 50 行(hook 的 seq 上限),超高分块循环打满——
|
|
131
|
+
总结卡可达上百行占位,截断会让卡片悬在缓冲区外盖住提示符。
|
|
132
|
+
|
|
133
|
+
hook 在位时半行保护走 shell 侧存档/接回原语(^U 存 __ot_saved、^Y
|
|
134
|
+
接回,空行零副作用,任务期打字与注入交错也能原样接回)。hook 不在位
|
|
135
|
+
(su - 换壳探测失败等)直接 ack 返回:没有 __ot_pad 通道,旧降级往
|
|
136
|
+
提示符敲 ``__ot_pad N`` 只会连环 command not found + 提示符连排,
|
|
137
|
+
前置 \x15 还连环吞用户输入(真机「打字不显示」根因之一)。宁藏不盖:
|
|
138
|
+
不垫空行,卡由显隐/夹紧逻辑兜底。"""
|
|
139
|
+
_dbg("shell_pad enter rows=", rows, "interactive=", self.core._interactive)
|
|
140
|
+
async with self.core._runner_lock:
|
|
141
|
+
_dbg("shell_pad locked")
|
|
142
|
+
if self.core.hook._hook_ok():
|
|
143
|
+
self.core._hook_seen = True
|
|
144
|
+
elif (self.core._hook_seen and
|
|
145
|
+
time.monotonic() - self.core._pad_reinject_at > 60
|
|
146
|
+
and not self.core._reinject_pending):
|
|
147
|
+
# hook 中途掉线(su -/exec/脚本重置 PROMPT_COMMAND)时 pad 会
|
|
148
|
+
# 秒应答零空行=卡出生即夹紧截断(真机「总结卡底部被切」)。
|
|
149
|
+
# 任务起手已补注入一次,这里做 60s 节流的兜底重试:能救回就
|
|
150
|
+
# 照正常路径垫行,救不回再走宁藏不盖的降级。_hook_seen 门卫:
|
|
151
|
+
# 从未在位=结构上不可集成(非掉线),不空转注入。
|
|
152
|
+
# _reinject_pending:任务起跑的重注入已在途(外部触发链任务
|
|
153
|
+
# 必有)——pad 在它后面排队会让降级提交的分析卡迟到整段重注入
|
|
154
|
+
# (真机 Enter→卡 2.5s 根因),此时立即 ack,卡由显隐/夹紧兜底
|
|
155
|
+
self.core._pad_reinject_at = time.monotonic()
|
|
156
|
+
try:
|
|
157
|
+
# 已持 _runner_lock:走无锁实现(经 _ensure_integrated
|
|
158
|
+
# 取锁会重入死锁)
|
|
159
|
+
await self.core.hook._ensure_integrated_locked()
|
|
160
|
+
except Exception: # noqa: BLE001 - 补注入失败不碍降级
|
|
161
|
+
pass
|
|
162
|
+
if self.core.hook._hook_ok():
|
|
163
|
+
self.core._hook_seen = True
|
|
164
|
+
if not self.core.hook._hook_ok() or self.core._reinject_pending:
|
|
165
|
+
# 两类都不往壳里敲 pad:①hook 不在位(判死/探测失败)——敲
|
|
166
|
+
# 进去必撞裸壳;②任务起跑的重注入在途(外部触发链必有,至
|
|
167
|
+
# _run_task 重注入落地才清位)——窗口内壳还没痊愈,敲进去
|
|
168
|
+
# 就是真机残迹「__ot_pad: command not found + 提示符粘连」。
|
|
169
|
+
# deferred 告诉前端这次「空白没长」是窗口内暂时现象,不计
|
|
170
|
+
# 熔断连败(慢链路重注入 1-4s,连续 ack 不增长会误熔断);
|
|
171
|
+
# 痊愈后下一轮 pad 自会补齐。
|
|
172
|
+
# **只有永久死通道才不带 deferred**:`_hook_gone`(探测判死 /
|
|
173
|
+
# 回显指纹判死)=这条 pad 通道确实没了,前端照常计数熔断、
|
|
174
|
+
# 停掉空转重试。其余都是暂时垫不进——最典型的是**连接期
|
|
175
|
+
# hook 尚未注入**(`_last_mark_at` 还是 0、`_hook_ok` 假),
|
|
176
|
+
# 旧实现按 `_reinject_pending` 单独判 deferred,把这一类报成
|
|
177
|
+
# 非 deferred,前端连吃两次空应答就 `_fusePads()` 把 pad 通道
|
|
178
|
+
# 永久焊死:此后每张卡都垫不满、全被夹紧成只剩卡头的细条
|
|
179
|
+
# (真机图一「⚠ 系统警告只剩标题」的上游根因)。
|
|
180
|
+
_dbg("shell_pad ack-only pending=", self.core._reinject_pending,
|
|
181
|
+
"gone=", self.core._hook_gone)
|
|
182
|
+
await self.core.emit_msg(ServerMsg(
|
|
183
|
+
type="event", event={"kind": "padded", "rows": rows,
|
|
184
|
+
"deferred": self.core._reinject_pending
|
|
185
|
+
or not self.core._hook_gone}))
|
|
186
|
+
return
|
|
187
|
+
remaining = max(1, rows)
|
|
188
|
+
first = True
|
|
189
|
+
hook = self.core.hook._hook_ok()
|
|
190
|
+
# 指纹扫描窗:pad 行已敲进 PTY,回显若带「__ot_pad: command not
|
|
191
|
+
# found」即壳里没有 hook 函数(_hook_ok 标记流假阳性,su - 换壳
|
|
192
|
+
# 主因),泵侧当场置 _hook_gone——本循环下一轮条件即断,不再把
|
|
193
|
+
# 后续块敲进裸壳连环刷 command not found + 新提示符(提示符墙)
|
|
194
|
+
self.core._pad_inflight = True
|
|
195
|
+
try:
|
|
196
|
+
while remaining > 0 and not self.core._hook_gone:
|
|
197
|
+
n = min(50, remaining)
|
|
198
|
+
remaining -= n
|
|
199
|
+
self.core._ev_prompt.clear()
|
|
200
|
+
if hook or mirror is None or (first and mirror):
|
|
201
|
+
await self.core.session.send_raw(b"\x15") # 收纳用户半行输入
|
|
202
|
+
# mirror == ""(hook 不在位):半行为空,不收纳(\x15 会误杀
|
|
203
|
+
# 历史召回等残影)
|
|
204
|
+
await self.core.session.send_raw(f"__ot_pad {n}\r".encode())
|
|
205
|
+
if hook:
|
|
206
|
+
# 接回存档半行:shell 侧 ^Y 原语(存档非空才接回)
|
|
207
|
+
await self.core.session.send_raw(b"\x19")
|
|
208
|
+
first = False
|
|
209
|
+
# hook 不在位时不补 \x19(原生 yank 会把 kill ring 里刚提交
|
|
210
|
+
# 的自然语言行复活回提示符行,此后每块 pad 再 kill 再 yank,
|
|
211
|
+
# 死循环挂在输入行上——降级走 mirror 原样重打)
|
|
212
|
+
try:
|
|
213
|
+
await asyncio.wait_for(self.core._ev_prompt.wait(),
|
|
214
|
+
self.core._pad_prompt_timeout)
|
|
215
|
+
except asyncio.TimeoutError:
|
|
216
|
+
pass
|
|
217
|
+
if not hook and mirror:
|
|
218
|
+
# 复原收纳的半行:等价用户原样敲入(不发 \r,不触发执行)
|
|
219
|
+
await self.core.session.send_raw(mirror.encode())
|
|
220
|
+
finally:
|
|
221
|
+
self.core._pad_inflight = False
|
|
222
|
+
# 残留半行是回显尾巴,随窗口收束丢弃(不留到下一轮 pad)
|
|
223
|
+
self.core._pad_echo_carry = ""
|
|
224
|
+
await self.core.emit_msg(ServerMsg(
|
|
225
|
+
type="event", event={"kind": "padded", "rows": rows}))
|
|
226
|
+
|
|
227
|
+
def _reinject_inst_base(self) -> int:
|
|
228
|
+
"""换壳重注入的实例号基址:每次重注入分配一个新的大号段。
|
|
229
|
+
|
|
230
|
+
嵌套 shell(su - / ssh 跳板机)里的 hook 实例号从基址起算,与外层
|
|
231
|
+
会话的帧号空间完全隔离——外层 hook 的用户命令帧(如第一条 `ssh
|
|
232
|
+
跳板机` = inst 1)在嵌套会话存续期间始终开着,若嵌套 hook 也从 1
|
|
233
|
+
起算,首个工具命令的 C/D 标记就会撞掉外层帧(误触救援、_hook_gone
|
|
234
|
+
提前复位)。setup 连接注入仍从 1 起(彼时无任何帧,计数器即本会话)。"""
|
|
235
|
+
self.core._reinject_seq = getattr(self.core, "_reinject_seq", 0) + 1
|
|
236
|
+
return 100000 * self.core._reinject_seq
|
|
237
|
+
|
|
238
|
+
def _arm_intr_swallow(self) -> None:
|
|
239
|
+
self.core._intr_swallow_until = time.monotonic() + 1.0
|
|
240
|
+
self.core._intr_pending = b""
|
|
241
|
+
|
|
242
|
+
def _strip_intr_echo(self, data: bytes) -> bytes:
|
|
243
|
+
"""吞掉自动 \x03 后 readline 回显的 "^C" 两字节(真机 bash 讨中断时
|
|
244
|
+
在用户已回显的半行尾补打 ^C——用户视角的「奇怪符号+c」)。
|
|
245
|
+
|
|
246
|
+
只吞这两字节:随后的 \r\n 与提示符重画照常透传,光标被 \r\n 重新
|
|
247
|
+
锚定,无失步。"^" 可跨块到达:悬置到下一块,非 C 开头或窗口过期
|
|
248
|
+
时原样放回(不丢用户内容的字面 ^)。"""
|
|
249
|
+
now = time.monotonic()
|
|
250
|
+
if now >= self.core._intr_swallow_until:
|
|
251
|
+
self.core._intr_swallow_until = 0.0
|
|
252
|
+
pending, self.core._intr_pending = self.core._intr_pending, b""
|
|
253
|
+
return pending + data
|
|
254
|
+
if self.core._intr_pending:
|
|
255
|
+
self.core._intr_pending = b""
|
|
256
|
+
if data[:1] == b"C":
|
|
257
|
+
self.core._intr_swallow_until = 0.0
|
|
258
|
+
data = data[1:]
|
|
259
|
+
else:
|
|
260
|
+
return b"^" + data # ^ 后面不是 C:非回显,原样放回
|
|
261
|
+
idx = data.find(b"^C")
|
|
262
|
+
if idx != -1:
|
|
263
|
+
self.core._intr_swallow_until = 0.0
|
|
264
|
+
return data[:idx] + data[idx + 2:]
|
|
265
|
+
if data.endswith(b"^"):
|
|
266
|
+
self.core._intr_pending = b"^"
|
|
267
|
+
return data[:-1]
|
|
268
|
+
return data
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def _proactive_reinject(self) -> None:
|
|
272
|
+
"""嵌套 ssh 登录成功后后台补注入:把首条自然语言的探测+分片注入成本
|
|
273
|
+
摊进登录后的空闲期(真机「等几秒才出分析卡」根因之一)。幂等:已在
|
|
274
|
+
位的 hook 只重定义同名函数;与 submit 降级路径并发时由 _runner_lock
|
|
275
|
+
串行。失败静默——首条 submit 仍会走降级自愈。
|
|
276
|
+
|
|
277
|
+
空闲门闩:_ensure_integrated 前置 \\x15 会收纳半行——用户登录后已在
|
|
278
|
+
目标提示符敲字时主动注入会销毁其输入。settle 后确认无 live 字节
|
|
279
|
+
(readline 回显 = 半行在场的凭据)才动手。"""
|
|
280
|
+
if not self.core._interactive or self.core.session is None or self.core._closed:
|
|
281
|
+
return
|
|
282
|
+
if self.core._cs_tail is not None:
|
|
283
|
+
return # 命令集在跑:它逐条过 _cs_hook_gate,主动注入只会并发
|
|
284
|
+
await asyncio.sleep(0.6)
|
|
285
|
+
if time.monotonic() - self.core._last_live_at < 0.4:
|
|
286
|
+
return # 目标提示符上有回显(用户敲字中):放弃主动注入
|
|
287
|
+
try:
|
|
288
|
+
await self.core.hook._ensure_integrated()
|
|
289
|
+
except Exception: # noqa: BLE001 - 主动注入失败不阻断,submit 降级自愈
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
async def _on_stream_event(self, ev: tuple) -> None:
|
|
293
|
+
kind = ev[0]
|
|
294
|
+
_dbg("ev", kind, "sup=", self.core._suppress_live,
|
|
295
|
+
(ev[1][:60] if isinstance(ev[1], str) else ev[1:])
|
|
296
|
+
if kind in ("live", "exec", "prompt") else ev[1:])
|
|
297
|
+
if kind == "live":
|
|
298
|
+
self.core._ev_stream.set() # 回显到达:重注入分片的逐片应答凭据
|
|
299
|
+
self.core.hook._note_hook_text()
|
|
300
|
+
if self.core._pad_inflight and "__ot_pad: command not found" in ev[1]:
|
|
301
|
+
# pad 行敲进了没有 hook 函数的壳:su - 换壳后嵌套壳的 PS1/
|
|
302
|
+
# PROMPT_COMMAND 照发 OSC 标记,_hook_ok 靠标记流假阳性,
|
|
303
|
+
# pad 循环于是把 __ot_pad 连环敲进裸壳 = 连环 command not
|
|
304
|
+
# found + 新提示符(抑制窗吞掉回显与 \r\n,新提示符块在窗
|
|
305
|
+
# 外迟到,落在上一条提示符的光标列上)= 横排提示符墙(真机
|
|
306
|
+
# 101.33 su - 后截图)。当场判死:后续 pad 走 ack-only,健康
|
|
307
|
+
# 路径转降级;重注入成功/嵌套壳退出照既有语义复位
|
|
308
|
+
self.core._hook_gone = True
|
|
309
|
+
_dbg("pad fingerprint: hook 函数不在位,判死转 ack-only")
|
|
310
|
+
if self.core._pad_inflight:
|
|
311
|
+
# 注入自身回显净化(见 _strip_pad_echo):hook 掉线后注入落进
|
|
312
|
+
# 裸壳时 `__ot_pad N` / `^Y` / `command not found` 不许外显。
|
|
313
|
+
# 半行 carry:回显常被分片切开(`__ot_pad ` 与 `7\r\n` 两块),
|
|
314
|
+
# 按块独立过滤会漏掉尾巴上的孤立 `7`。
|
|
315
|
+
carry = getattr(self.core, "_pad_echo_carry", "")
|
|
316
|
+
seg = _strip_pad_echo(carry + ev[1])
|
|
317
|
+
keep = ""
|
|
318
|
+
nl = max(seg.rfind("\n"), seg.rfind("\r"))
|
|
319
|
+
if nl >= 0:
|
|
320
|
+
keep, seg = seg[nl + 1:], seg[: nl + 1]
|
|
321
|
+
else:
|
|
322
|
+
keep, seg = seg, ""
|
|
323
|
+
self.core._pad_echo_carry = keep
|
|
324
|
+
ev = (ev[0], seg)
|
|
325
|
+
if not seg:
|
|
326
|
+
return
|
|
327
|
+
if self.core._probe_buf is not None:
|
|
328
|
+
# live 的 ev[1] 是 str(本分支末尾 emit 前也要 .encode),
|
|
329
|
+
# bytearray.extend(str) 抛 TypeError 当场打死泵任务;而任务
|
|
330
|
+
# 引用被 self.core._pump 持有,asyncio 连「未取回异常」都不打印
|
|
331
|
+
# ——真机表现为终端彻底沉默(无回显、无输出、无 closed 帧)。
|
|
332
|
+
# 触发条件恰是裸壳:探测行的回显是纯文本 → 走 live(集成壳
|
|
333
|
+
# 里走 silent exec 帧,所以这条路径平时无声无息)。
|
|
334
|
+
self.core._probe_buf.extend(ev[1].encode("utf-8", "replace"))
|
|
335
|
+
if self.core._suppress_live:
|
|
336
|
+
# 注入窗内一律吞(§5.5):注入行回显与 shell 提示符重绘都
|
|
337
|
+
# 不外显。判定顺序必须先于 _curtain——窗内 prompt_start 会
|
|
338
|
+
# 关帘,提示符段若进帘,下一次 send_raw(闸)就把它成排放
|
|
339
|
+
# 行 = 横排提示符墙。提示符文本另记在 _suppressed_tail:窗
|
|
340
|
+
# 末按 shell 真实状态重画(有没有 OSC 标记都成立)。
|
|
341
|
+
self.core._suppressed_echo = True
|
|
342
|
+
_seg = _ansi_free(ev[1])
|
|
343
|
+
_nl = max(_seg.rfind('\n'), _seg.rfind('\r'))
|
|
344
|
+
self.core._suppressed_tail = _seg[_nl + 1:] if _nl >= 0 \
|
|
345
|
+
else self.core._suppressed_tail + _seg
|
|
346
|
+
elif self.core._curtain:
|
|
347
|
+
# 幕帘扣留:live 文本(提示符段及后续杂散回显)按序暂存,
|
|
348
|
+
# 下一次注入(闸)或任务收尾才放行
|
|
349
|
+
self.core._curtain_parts.append(ev[1])
|
|
350
|
+
if time.monotonic() - self.core._curtain_at > 600.0:
|
|
351
|
+
await self._curtain_open() # 防呆:扣满 10 分钟强制放行
|
|
352
|
+
else:
|
|
353
|
+
self.core._last_live_at = time.monotonic()
|
|
354
|
+
await self.core.emit_bytes(ev[1].encode("utf-8", "replace"))
|
|
355
|
+
elif kind == "exec":
|
|
356
|
+
self.core._ev_stream.set()
|
|
357
|
+
self.core.hook._note_hook_text()
|
|
358
|
+
if self.core._probe_buf is not None:
|
|
359
|
+
self.core._probe_buf.extend(ev[1].encode("utf-8", "replace"))
|
|
360
|
+
await self._on_exec_text(ev[1])
|
|
361
|
+
elif kind == "prompt_start":
|
|
362
|
+
self.core._ev_prompt.set() # 模式切换等提示符重画
|
|
363
|
+
self.core.hook._note_hook_mark()
|
|
364
|
+
# 任务运行期关帘:这段提示符重绘扣住不发——前端流式卡期间底部
|
|
365
|
+
# 不出现空提示符(用户诉求:对话完全结束才显示命令提示符)
|
|
366
|
+
self._curtain_close()
|
|
367
|
+
elif kind == "prompt":
|
|
368
|
+
self.core._cur_prompt = ev[1]
|
|
369
|
+
self.core.hook._note_hook_mark()
|
|
370
|
+
elif kind == "report":
|
|
371
|
+
self.core.hook._note_hook_mark()
|
|
372
|
+
inst, k, line = ev[1], ev[2], ev[3]
|
|
373
|
+
if k == "AI":
|
|
374
|
+
# 外部触发链健康路径:本行是补发 \r 后 hook 的原地蓝色重绘上报
|
|
375
|
+
# ——上报即任务触发(与英文自然语言同一条 proven 路径)。
|
|
376
|
+
# _suppress_ai_report:兜底已按同文启动任务,迟到上报吞掉防双跑
|
|
377
|
+
if self.core._suppress_ai_report is not None and \
|
|
378
|
+
line.strip() == self.core._suppress_ai_report:
|
|
379
|
+
self.core._suppress_ai_report = None
|
|
380
|
+
else:
|
|
381
|
+
if self.core._hook_report_pending is not None and \
|
|
382
|
+
line.strip() == self.core._hook_report_pending:
|
|
383
|
+
self.core._hook_report_pending = None
|
|
384
|
+
# hooked=True:hook 上报即 hook 在位的凭据,任务启动
|
|
385
|
+
# 跳过重注入——其吞窗口会吃掉在途的蓝色重绘字节
|
|
386
|
+
# (真机「提交行消失」根因:stty 快照后重绘慢几 ms 落进窗口)
|
|
387
|
+
await self.core.ai._on_ai_line(line, hooked=True)
|
|
388
|
+
else:
|
|
389
|
+
# hook 把这行分类成了命令(CMD/EXEC)并会就地执行:清掉等 AI
|
|
390
|
+
# 上报的兜底挂起,否则 _submit_fallback 会在 1.2s 后对同一行
|
|
391
|
+
# \x03 + 重注入 + 强起 AI 任务(真机:hook 已报 command not
|
|
392
|
+
# found,随后 ^C 残行 + 提示符连排)
|
|
393
|
+
if self.core._hook_report_pending is not None and \
|
|
394
|
+
line.strip() == self.core._hook_report_pending:
|
|
395
|
+
self.core._hook_report_pending = None
|
|
396
|
+
self.core._pending_report = (inst, k, line)
|
|
397
|
+
elif kind == "exec_start":
|
|
398
|
+
self.core.hook._note_hook_mark()
|
|
399
|
+
await self._on_exec_start(ev[1])
|
|
400
|
+
elif kind == "exec_end":
|
|
401
|
+
self.core.hook._note_hook_mark()
|
|
402
|
+
await self._on_exec_end(ev[1], ev[2], ev[3])
|
|
403
|
+
|
|
404
|
+
async def _on_exec_start(self, inst: int) -> None:
|
|
405
|
+
"""C 标记:AI/探测/静默注入开帧;用户命令不进栈,仅记账。"""
|
|
406
|
+
rep = self.core._pending_report
|
|
407
|
+
self.core._pending_report = None
|
|
408
|
+
kind, line = (rep[1], rep[2]) if rep else ("", "")
|
|
409
|
+
if kind != "EXEC":
|
|
410
|
+
if is_internal_line(line):
|
|
411
|
+
# 注入分片装配 / eval 解包:静默开帧。两条症状的共同来源——
|
|
412
|
+
# ①每片 __ot_run 的 C 标记各补一个 \r\n → 任务启动连打空行;
|
|
413
|
+
# ②重注入 export -f 报错走无帧 exec 直通前端。internal 行不补
|
|
414
|
+
# 收束换行、输出全吞(__ot_exec__ 恒走 EXEC 分支,不受影响)
|
|
415
|
+
top = self.core._exec_stack[-1] if self.core._exec_stack else None
|
|
416
|
+
if top is not None and top.get("inj"):
|
|
417
|
+
# 注入行串行执行:上一条内部行的 D 丢了(换壳 / 分片被打断)
|
|
418
|
+
# 就别让它压在栈里——inj 帧泄漏 = 此后所有 exec 字节被当静默
|
|
419
|
+
# 输出吞掉(真机:命令输出与提示符全不上屏,只剩 live 回显)
|
|
420
|
+
_dbg("exec_start: 丢弃未闭合注入帧", top["inst"], "->", inst)
|
|
421
|
+
self.core._exec_stack.pop()
|
|
422
|
+
self.core._exec_stack.append(
|
|
423
|
+
{"ctx": "silent", "line": line, "inst": inst, "inj": True})
|
|
424
|
+
return
|
|
425
|
+
# 用户命令(CMD 报告或无报告):显示全靠 PTY 原样字节,这里只
|
|
426
|
+
# 记账(transcript 在 exec_end 带退出码记一次,避免重复)
|
|
427
|
+
self.core._open_cmds[inst] = line
|
|
428
|
+
self.core._open_cmd_at[inst] = time.monotonic()
|
|
429
|
+
self.core._nested_pw_attempts = 0 # 新命令帧:嵌套密码自动填充资格重置
|
|
430
|
+
self.core._user_out = bytearray() # 救援上下文从本条命令输出起算
|
|
431
|
+
if line:
|
|
432
|
+
self.core._record_history("user", line)
|
|
433
|
+
await self._close_suppressed_line()
|
|
434
|
+
return
|
|
435
|
+
if self.core._hidden_exec:
|
|
436
|
+
ctx = "probe"
|
|
437
|
+
else:
|
|
438
|
+
ctx = "agent" if self.core._exec_future is not None else "silent"
|
|
439
|
+
_dbg("exec_start inst=", inst, "ctx=", ctx, "line=", line[:60],
|
|
440
|
+
"fut=", self.core._exec_future is not None)
|
|
441
|
+
self.core._exec_stack.append({"ctx": ctx, "line": line, "inst": inst})
|
|
442
|
+
await self._close_suppressed_line()
|
|
443
|
+
if ctx == "agent":
|
|
444
|
+
# AI 工具命令真正开始执行:此刻登记命令与首个收束事件——保证
|
|
445
|
+
# ai_collapse 与输出按真实执行顺序交错(多命令并发发起时同理)
|
|
446
|
+
#
|
|
447
|
+
# 命令身份以 **C 标记上报的 line 为准**,登记队列只兜底:并行
|
|
448
|
+
# tool_call 时 on_start 全部先到(登记序 = 宣告序),真实执行序却是
|
|
449
|
+
# InteractiveRunner._runner_lock 的抢锁序,两者会错位——盲目 popleft
|
|
450
|
+
# 会把 ai_collapse / 输出归属记到别的命令头上(真机「四张卡一条
|
|
451
|
+
# 输出」同族)。命中队列里的同名登记就地销账,否则退化取队首。
|
|
452
|
+
# 命令身份取**登记串**(= 卡片上那条 $ 命令),上报串只用来认领
|
|
453
|
+
# 「这是哪一条」:并行 tool_call 时 on_start 全部先到(登记序 =
|
|
454
|
+
# 宣告序),真实执行序却是抢锁序,盲目取队首会把归属记到别的命令
|
|
455
|
+
# 头上(真机「四张卡只看得见一条输出」同族)。上报串是包装行而
|
|
456
|
+
# 对不上登记时退回队首——认不出就不改判,只保证不空转。
|
|
457
|
+
if line in self.core._pending_agent_cmds:
|
|
458
|
+
self.core._pending_agent_cmds.remove(line)
|
|
459
|
+
cmd = line
|
|
460
|
+
elif self.core._pending_agent_cmds:
|
|
461
|
+
cmd = self.core._pending_agent_cmds.popleft()
|
|
462
|
+
else:
|
|
463
|
+
cmd = line
|
|
464
|
+
self.core._agent_open_cmd = cmd
|
|
465
|
+
self.core._record_history("agent", cmd)
|
|
466
|
+
self.core._exec_stack[-1]["cmd"] = cmd
|
|
467
|
+
# 输出归属括号(开):前端据此把这条命令的输出切块挂到它的卡下。
|
|
468
|
+
# execute 工具本就依赖 hook 通道(_hook_channel_ready 挂了直接
|
|
469
|
+
# CHANNEL_DEAD_MSG),所以 C/D 标记在 execute 期间必然在位——
|
|
470
|
+
# 不违反 P1「布局不得依赖 shell 集成」。
|
|
471
|
+
# 回显行一并带过去:它由 hook 画成 `提示符 + 空格 + 命令`,随后
|
|
472
|
+
# Readline 一句「上移一格+擦行」会随机啃掉它。让前端去读 xterm 缓冲
|
|
473
|
+
# 是竞态(write 异步解析),这里按 `_cur_prompt`(B 标记捕获)拼出来
|
|
474
|
+
# 是确定的,前端丢没丢都能补上。
|
|
475
|
+
echo = ""
|
|
476
|
+
if self.core._cur_prompt and cmd:
|
|
477
|
+
echo = self.core._cur_prompt.rstrip() + " " + cmd
|
|
478
|
+
await self.core.emit_msg(ServerMsg(
|
|
479
|
+
type="event",
|
|
480
|
+
event={"kind": "term_open", "command": cmd, "echo": echo}))
|
|
481
|
+
if not self.core._task_cmd_started:
|
|
482
|
+
self.core._task_cmd_started = True
|
|
483
|
+
await self.core.emit_msg(ServerMsg(
|
|
484
|
+
type="event",
|
|
485
|
+
event={"kind": "ai_collapse", "command": cmd}))
|
|
486
|
+
|
|
487
|
+
@staticmethod
|
|
488
|
+
def _disp_width(s: str) -> int:
|
|
489
|
+
"""终端显示列宽:CJK/全角按 2 列(与 hook __ot_repaint 同式:
|
|
490
|
+
字符数 + UTF-8 额外字节/2)。"""
|
|
491
|
+
by = len(s.encode("utf-8", "replace"))
|
|
492
|
+
return len(s) + (by - len(s)) // 2
|
|
493
|
+
|
|
494
|
+
async def _repaint_line(self, text: str | None, echoed: bool = True) -> None:
|
|
495
|
+
"""降级/直通路径补画 hook 的 __ot_repaint "$t" 33 观感。
|
|
496
|
+
|
|
497
|
+
hook 不在位时重绘不会自己发生(函数已随 su - 丢掉),由 worker 侧
|
|
498
|
+
画同样的三段:提示符原样保留 + 蓝 38;5;33 原文 + 换行。提示符不必
|
|
499
|
+
在 worker 侧重建 [u@h cwd]#——光标退回半行行首(提示符右侧 N 显示
|
|
500
|
+
列处)再清到行尾即可,屏幕上那个提示符就是 shell 自己画的。
|
|
501
|
+
|
|
502
|
+
两种落笔法,按手头有没有提示符原文分:
|
|
503
|
+
- 有(抑制窗里捕获到 shell 刚画的提示符段):整行重建,与
|
|
504
|
+
__ot_repaint 逐字同形,长行折行也对;
|
|
505
|
+
- 没有:光标退回已回显原文的行首,**只清到行尾**(\x1b[K,不是
|
|
506
|
+
\x1b[2K——后者会把提示符一并擦掉),屏幕上 shell 自己画的提示
|
|
507
|
+
符原样保留。echoed=False(typed=False 回放 / 兜底 ^C 后的新提示
|
|
508
|
+
符行)时该行本就只有提示符,直接在行尾接上原文。
|
|
509
|
+
"""
|
|
510
|
+
tail, self.core._suppressed_tail = self.core._suppressed_tail, ''
|
|
511
|
+
self.core._suppressed_echo = False
|
|
512
|
+
if not text:
|
|
513
|
+
return
|
|
514
|
+
body = "\x1b[38;5;33m" + text + "\x1b[0m\n"
|
|
515
|
+
if tail:
|
|
516
|
+
seq = "\r\x1b[2K" + tail + body
|
|
517
|
+
elif echoed:
|
|
518
|
+
back = self._disp_width(text)
|
|
519
|
+
seq = (f"\x1b[{back}D" if back else "") + "\x1b[K" + body
|
|
520
|
+
else:
|
|
521
|
+
seq = "\x1b[K" + body
|
|
522
|
+
await self.core.emit_bytes(seq.encode("utf-8", "replace"))
|
|
523
|
+
|
|
524
|
+
async def _resync_after_suppress(self) -> None:
|
|
525
|
+
"""注入抑制窗收束:把 xterm 的光标与行内容对齐回 shell 真实状态。
|
|
526
|
+
|
|
527
|
+
窗内吞掉的字节(注入行回显 + 换行 + shell 新画的提示符)在 xterm
|
|
528
|
+
上等于什么都没发生,光标还停在开窗前那行的行尾——后续字节直接放
|
|
529
|
+
行会落在那个列上叠成横排提示符墙。窗末 \r+清行回到行首,重画被
|
|
530
|
+
吞掉的提示符段;幕帘关着就并进扣留(任务期不冒空提示符,收尾才
|
|
531
|
+
放行),与 hook 在位时「重绘行 + 空提示符被幕帘扣住」的观感一致。
|
|
532
|
+
"""
|
|
533
|
+
tail, self.core._suppressed_tail = self.core._suppressed_tail, ''
|
|
534
|
+
repaint, self.core._repaint_text = self.core._repaint_text, None
|
|
535
|
+
swallowed, self.core._suppressed_echo = self.core._suppressed_echo, False
|
|
536
|
+
if repaint is not None:
|
|
537
|
+
await self._repaint_line(repaint, echoed=True)
|
|
538
|
+
return
|
|
539
|
+
if not swallowed:
|
|
540
|
+
return
|
|
541
|
+
chunk = "\r\x1b[2K" + (tail or self.core._cur_prompt)
|
|
542
|
+
if self.core._curtain:
|
|
543
|
+
self.core._curtain_parts.append(chunk)
|
|
544
|
+
else:
|
|
545
|
+
await self.core.emit_bytes(chunk.encode("utf-8", "replace"))
|
|
546
|
+
|
|
547
|
+
async def _close_suppressed_line(self) -> None:
|
|
548
|
+
"""注入窗口吞过回显时补一个换行收束提示符行(§5.5):前端看不到
|
|
549
|
+
被吞的注入行,不补换行的话命令输出会接在提示符同一行。"""
|
|
550
|
+
if self.core._suppressed_echo:
|
|
551
|
+
self.core._suppressed_echo = False
|
|
552
|
+
await self.core.emit_bytes(b"\r\n")
|
|
553
|
+
|
|
554
|
+
async def _on_exec_text(self, text: str) -> None:
|
|
555
|
+
frame = self.core._exec_stack[-1] if self.core._exec_stack else None
|
|
556
|
+
ctx = frame["ctx"] if frame else None
|
|
557
|
+
if ctx in ("agent", "probe"):
|
|
558
|
+
# 捕获进 _exec_buf:exec_end 用它组装 run() 的返回值
|
|
559
|
+
raw = text.encode("utf-8", "replace")
|
|
560
|
+
limit = self.core.cfg.shell.max_output_bytes
|
|
561
|
+
if len(self.core._exec_buf) < limit:
|
|
562
|
+
room = limit - len(self.core._exec_buf)
|
|
563
|
+
self.core._exec_buf.extend(raw[:room])
|
|
564
|
+
if len(raw) > room:
|
|
565
|
+
self.core._exec_truncated = True
|
|
566
|
+
else:
|
|
567
|
+
self.core._exec_truncated = True
|
|
568
|
+
if ctx == "agent":
|
|
569
|
+
await self.core.emit_bytes(raw) # 输出直写主 xterm(§5.3)
|
|
570
|
+
# probe:只捕获不转发(隐藏探测无前端副作用)
|
|
571
|
+
elif ctx == "silent":
|
|
572
|
+
pass # 模式开关注入:吞掉(无显示)
|
|
573
|
+
else:
|
|
574
|
+
# 用户命令输出(无帧):直通前端;抑制窗口内(su - 后重注入、C 帧
|
|
575
|
+
# 未闭)这段 exec 字节是分片回显/eval 杂行——base64 墙,一并吞掉
|
|
576
|
+
if self.core._suppress_live:
|
|
577
|
+
return
|
|
578
|
+
# 救援上下文:尾部截断保留(4KB 足够 AI 判定失败原因)
|
|
579
|
+
self.core._user_out.extend(text.encode("utf-8", "replace"))
|
|
580
|
+
if len(self.core._user_out) > 8192:
|
|
581
|
+
del self.core._user_out[: len(self.core._user_out) - 4096]
|
|
582
|
+
await self.core.emit_bytes(text.encode("utf-8", "replace"))
|
|
583
|
+
|
|
584
|
+
async def _on_exec_end(self, inst: int, ec, cwd) -> None:
|
|
585
|
+
_dbg("exec_end inst=", inst, "ec=", ec, "stack=",
|
|
586
|
+
[(f["ctx"], f["inst"]) for f in self.core._exec_stack])
|
|
587
|
+
self.core._ev_exec_end.set()
|
|
588
|
+
if cwd and self.core._runner is not None:
|
|
589
|
+
self.core._runner.cwd = cwd
|
|
590
|
+
if inst in self.core._open_cmds:
|
|
591
|
+
line = self.core._open_cmds.pop(inst)
|
|
592
|
+
self.core._open_cmd_at.pop(inst, None)
|
|
593
|
+
if line:
|
|
594
|
+
self.core.transcript.append("direct", command=line, exit_code=ec)
|
|
595
|
+
if not self.core._open_cmds:
|
|
596
|
+
# 嵌套 shell(su - 等)退出:外层 hook 的标记/函数从未丢过,
|
|
597
|
+
# 复位换壳判定,健康路径/pad 恢复
|
|
598
|
+
self.core._hook_gone = False
|
|
599
|
+
self.core._hook_stale = False
|
|
600
|
+
await self.core.ai._maybe_rescue(line, ec)
|
|
601
|
+
if not self.core._exec_stack:
|
|
602
|
+
return # 孤儿 D:无帧的执行周期,忽略
|
|
603
|
+
top = self.core._exec_stack[-1]
|
|
604
|
+
if inst != top["inst"]:
|
|
605
|
+
if top.get("inj"):
|
|
606
|
+
# 注入 eval 行的 C 标记带旧实例号、它自己的 D 带新实例号(eval
|
|
607
|
+
# 刚把 __ot_i 改写成新基址)。按「旧实例的 D」一律忽略,这个
|
|
608
|
+
# silent 帧就永不闭合,此后每条 exec 字节都被当静默帧输出吞掉
|
|
609
|
+
# ——真机表现:命令集/换壳后密码提示、Last login、提示符全不
|
|
610
|
+
# 上屏,只剩 live 回显;缺了 exec 里的换行,后续重绘还会把
|
|
611
|
+
# 历史逐条砸在同一行上(屏幕塌成两三行)。跨实例收束这一帧。
|
|
612
|
+
self.core._exec_stack.pop()
|
|
613
|
+
_dbg("exec_end: 跨实例收束注入帧", top["inst"], "->", inst)
|
|
614
|
+
return # 旧实例的 D:忽略(注入帧已跨实例收束)
|
|
615
|
+
frame = self.core._exec_stack.pop()
|
|
616
|
+
if frame["ctx"] in ("agent", "probe"):
|
|
617
|
+
fut = self.core._exec_future
|
|
618
|
+
if fut is not None and not fut.done():
|
|
619
|
+
fut.set_result(CommandResult(
|
|
620
|
+
output=bytes(self.core._exec_buf).decode("utf-8", "replace"),
|
|
621
|
+
exit_code=ec if ec is not None else 0,
|
|
622
|
+
truncated=self.core._exec_truncated,
|
|
623
|
+
cwd=cwd or (self.core._runner.cwd if self.core._runner else "")))
|
|
624
|
+
# 立即恢复 live:D 之后的提示符重画字节不能被抑制窗口吞掉
|
|
625
|
+
self.core._suppress_live = False
|
|
626
|
+
self.core._suppressed_echo = False
|
|
627
|
+
self.core._suppressed_tail = ""
|
|
628
|
+
if frame["ctx"] == "agent":
|
|
629
|
+
# 输出归属括号(闭):这条命令的输出到此为止,前端据此切块归位
|
|
630
|
+
await self.core.emit_msg(ServerMsg(
|
|
631
|
+
type="event",
|
|
632
|
+
event={"kind": "term_close",
|
|
633
|
+
"command": frame.get("cmd") or frame.get("line") or ""}))
|
|
634
|
+
if self.core._agent_open_cmd is not None:
|
|
635
|
+
self.core._agent_open_cmd = None # 输出块已自然收束于主 xterm
|
|
636
|
+
|
|
637
|
+
# --- 失败救援(reactive)---
|
|
638
|
+
|
|
639
|
+
# --- 组件公开接口绑定 ---
|
|
640
|
+
feed_output = _on_exec_text
|
|
641
|
+
on_stream_event = _on_stream_event
|
|
642
|
+
on_exec_start = _on_exec_start
|
|
643
|
+
on_exec_end = _on_exec_end
|
|
644
|
+
shell_pad = _shell_pad
|
|
645
|
+
strip_intr_echo = _strip_intr_echo
|
|
646
|
+
|
|
647
|
+
@property
|
|
648
|
+
def open_cmds(self) -> dict[int, str]:
|
|
649
|
+
"""用户命令帧记账(NestedSshTracker 等经 core._open_cmds 读)。"""
|
|
650
|
+
return self.core._open_cmds
|