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.
Files changed (91) hide show
  1. openterminal/__init__.py +3 -0
  2. openterminal/agent/__init__.py +39 -0
  3. openterminal/agent/backend.py +180 -0
  4. openterminal/agent/build.py +115 -0
  5. openterminal/agent/middleware.py +111 -0
  6. openterminal/agent/model.py +41 -0
  7. openterminal/agent/prompt.py +63 -0
  8. openterminal/agent/runner.py +340 -0
  9. openterminal/agent/sysprobe.py +164 -0
  10. openterminal/agent/token_est.py +49 -0
  11. openterminal/app.py +52 -0
  12. openterminal/approval.py +7 -0
  13. openterminal/backend.py +3 -0
  14. openterminal/banner.py +7 -0
  15. openterminal/cli.py +426 -0
  16. openterminal/cmdset.py +7 -0
  17. openterminal/config.py +322 -0
  18. openterminal/connections/__init__.py +20 -0
  19. openterminal/connections/db.py +240 -0
  20. openterminal/connections/models.py +289 -0
  21. openterminal/connections/secrets.py +68 -0
  22. openterminal/connections_db.py +6 -0
  23. openterminal/core/__init__.py +41 -0
  24. openterminal/core/ai_flow.py +781 -0
  25. openterminal/core/auth.py +499 -0
  26. openterminal/core/cmdset_runner.py +352 -0
  27. openterminal/core/exec_events.py +650 -0
  28. openterminal/core/history.py +79 -0
  29. openterminal/core/hook_gate.py +415 -0
  30. openterminal/core/messages.py +193 -0
  31. openterminal/core/nested_ssh.py +178 -0
  32. openterminal/core/pipeline.py +1187 -0
  33. openterminal/core/pump.py +190 -0
  34. openterminal/core/transcript.py +26 -0
  35. openterminal/frontend/__init__.py +21 -0
  36. openterminal/frontend/approval.py +40 -0
  37. openterminal/frontend/banner.py +40 -0
  38. openterminal/frontend/headless.py +290 -0
  39. openterminal/frontend/render.py +41 -0
  40. openterminal/frontend/taskview.py +184 -0
  41. openterminal/frontend/term.py +1417 -0
  42. openterminal/headless.py +7 -0
  43. openterminal/history_db.py +7 -0
  44. openterminal/local_pty.py +3 -0
  45. openterminal/local_win.py +3 -0
  46. openterminal/policy.py +362 -0
  47. openterminal/render.py +7 -0
  48. openterminal/secrets_store.py +5 -0
  49. openterminal/session/__init__.py +17 -0
  50. openterminal/session/base.py +328 -0
  51. openterminal/session/local_posix.py +184 -0
  52. openterminal/session/local_win.py +173 -0
  53. openterminal/session/ssh.py +321 -0
  54. openterminal/shell/__init__.py +17 -0
  55. openterminal/shell/cmdset.py +49 -0
  56. openterminal/shell/integration.py +1298 -0
  57. openterminal/shell_integration.py +7 -0
  58. openterminal/shell_session.py +8 -0
  59. openterminal/skills/__init__.py +45 -0
  60. openterminal/skills/markdown-tables/SKILL.md +247 -0
  61. openterminal/ssh_pty.py +6 -0
  62. openterminal/sysprobe.py +7 -0
  63. openterminal/taskview.py +7 -0
  64. openterminal/term_frontend.py +7 -0
  65. openterminal/token_est.py +3 -0
  66. openterminal/transcript.py +7 -0
  67. openterminal/web/__init__.py +1 -0
  68. openterminal/web/files_api.py +789 -0
  69. openterminal/web/frontend/index.html +37 -0
  70. openterminal/web/frontend/static/app.js +3632 -0
  71. openterminal/web/frontend/static/block_stream.js +274 -0
  72. openterminal/web/frontend/static/block_view.js +383 -0
  73. openterminal/web/frontend/static/files_panel.js +782 -0
  74. openterminal/web/frontend/static/md_lite.js +127 -0
  75. openterminal/web/frontend/static/modal.js +122 -0
  76. openterminal/web/frontend/static/pad_ledger.js +46 -0
  77. openterminal/web/frontend/static/style.css +915 -0
  78. openterminal/web/frontend/static/ui/ui.css +1 -0
  79. openterminal/web/frontend/static/ui/ui.js +6292 -0
  80. openterminal/web/frontend/vendor/addon-fit.js +2 -0
  81. openterminal/web/frontend/vendor/xterm.css +8 -0
  82. openterminal/web/frontend/vendor/xterm.js +8 -0
  83. openterminal/web/protocol.py +41 -0
  84. openterminal/web/server.py +322 -0
  85. openterminal/web/worker.py +62 -0
  86. ot_cli-0.2.1.dist-info/METADATA +439 -0
  87. ot_cli-0.2.1.dist-info/RECORD +91 -0
  88. ot_cli-0.2.1.dist-info/WHEEL +5 -0
  89. ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
  90. ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
  91. ot_cli-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,79 @@
1
+ """history_db:连接级命令历史长期记忆(SQLite 后端)。
2
+
3
+ 每个 target 的用户命令与 AI 工具命令按序落库(append-only),重连/换设备
4
+ 时由 worker 取最近 N 条注入远端 shell 历史——`history` 与 ↑ 键由 shell
5
+ 原生支持,前端零改动。库位置跟随 OPENTERMINAL_HOME(用户主目录,不入
6
+ git 仓库),WAL 模式允许多进程(Web worker + CLI)并发读写。
7
+ """
8
+
9
+ import sqlite3
10
+ import time
11
+
12
+ _SCHEMA = """
13
+ CREATE TABLE IF NOT EXISTS history(
14
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
15
+ target TEXT NOT NULL,
16
+ ts REAL NOT NULL,
17
+ source TEXT NOT NULL, -- user | agent
18
+ command TEXT NOT NULL
19
+ );
20
+ CREATE INDEX IF NOT EXISTS idx_history_target ON history(target, id);
21
+ """
22
+
23
+ MAX_COMMAND_LEN = 4096
24
+ #: 重连/换设备时回灌远端 shell 历史的条数上限
25
+ HISTORY_RECALL_LIMIT = 1000
26
+
27
+
28
+ def db_path(path=None):
29
+ """默认库位置(跟随 OPENTERMINAL_HOME);显式传入 path 则原样返回。"""
30
+ if path is not None:
31
+ return path
32
+ from ..config import app_dir
33
+
34
+ return app_dir() / "history.db"
35
+
36
+
37
+ def _connect(path) -> sqlite3.Connection:
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ conn = sqlite3.connect(str(path))
40
+ try:
41
+ conn.execute("PRAGMA journal_mode=WAL")
42
+ except sqlite3.DatabaseError:
43
+ pass
44
+ conn.executescript(_SCHEMA)
45
+ return conn
46
+
47
+
48
+ def record(target: str, source: str, command: str, *, path=None) -> None:
49
+ """追加一条命令;空命令丢弃,超长截断。任何异常静默(历史不能打死主流程)。"""
50
+ command = (command or "").strip()
51
+ if not command:
52
+ return
53
+ command = command[:MAX_COMMAND_LEN]
54
+ try:
55
+ conn = _connect(db_path(path))
56
+ try:
57
+ conn.execute(
58
+ "INSERT INTO history(target, ts, source, command) "
59
+ "VALUES (?, ?, ?, ?)",
60
+ (target, time.time(), source, command))
61
+ conn.commit()
62
+ finally:
63
+ conn.close()
64
+ except Exception: # noqa: BLE001 - 记历史失败不影响命令执行
65
+ pass
66
+
67
+
68
+ def recent(target: str, limit: int = 1000, *, path=None) -> list[dict]:
69
+ """某 target 最近 limit 条命令,旧→新升序。"""
70
+ conn = _connect(db_path(path))
71
+ try:
72
+ rows = conn.execute(
73
+ "SELECT ts, source, command FROM history "
74
+ "WHERE target = ? ORDER BY id DESC LIMIT ?",
75
+ (target, limit)).fetchall()
76
+ finally:
77
+ conn.close()
78
+ return [{"ts": r[0], "source": r[1], "command": r[2]}
79
+ for r in reversed(rows)]
@@ -0,0 +1,415 @@
1
+ """hook 集成与验活(Task 5 自 PipelineCore 原样搬入)。
2
+
3
+ shell 族探测与分片重注入(su - / login shell 重置后自愈)、能力探测、
4
+ hook 在位判定、回车看门狗验活、登出特征识别(TMOUT/exit/logout)与
5
+ 死壳处置——自然语言健康路径的门禁与活性凭据都在本组件。
6
+ 状态**物理存 core 实例**(P3 裁定延续:pump/命令集/InteractiveRunner
7
+ 与测试直读 core._hook_* 等;HookGate.__init__ 以 hasattr 守卫补建,
8
+ 预置值不覆盖),方法体经 self.core 读写。
9
+
10
+ 组件公开接口:ensure_integrated / probe_shell_kind(重注入与探测)、
11
+ hook_ok(在位门禁)、note_mark / note_text(标记流活性凭据)、
12
+ observe_output(登出特征识别,泵责任链调用)、drain_quiet(静默排水)。
13
+ InteractiveRunner(core.messages)经 core._shell_kind/_b64flag/_hook_seen
14
+ 与本组件共享探测结论——物理位置在 core,两边同径访问。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import re
21
+ import time
22
+
23
+ from . import pipeline as _impl
24
+ from .messages import CHANNEL_DEAD_EC, CommandResult, ServerMsg, _dbg
25
+ from ..shell.integration import (history_quiet_line, injection_lines,
26
+ is_internal_line, parse_probe, probe_command)
27
+
28
+
29
+ # shell 自行退出的文字特征(bash TMOUT 自动登出 / ssh 客户端断链)。
30
+ # 认「视觉行」而不是字节行:真机告别语紧跟提示符重画落进流里,前缀是
31
+ # ESC[?2004l + CR + BEL,字节行首锚点跨不过那个 CR,一律漏检(真机审批期
32
+ # TMOUT 后 __ot_pad 连环 command not found 的根因)。故先按 CR/LF 切出视觉
33
+ # 行、剥掉转义与控制符,再整行全文匹配——告别语前只允许一段提示符宽度的
34
+ # 普通字符(bash 是紧贴已打出的提示符写的),后面只许空白;命令输出里恰好
35
+ # 含这些词的长行(grep 日志、带时间戳的行)撑不进这个宽度或后面还拖着正文,
36
+ # 不误判。裸 logout 只在挂起窗口认(见 _shell_exit_window)。
37
+ # (模块后段另有一个喂 str 的 _ANSI_RE,字节流这里必须另起名字,否则被覆盖)
38
+ _EXIT_ANSI_RE = re.compile(
39
+ rb"\x1b\[[0-9;?]*[ -/]*[@-~]" # CSI:颜色/光标/模式
40
+ rb"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC:标题 / 133 标记
41
+ rb"|\x1b[@-_]" # 其余单字符转义
42
+ )
43
+ _EXIT_CTRL_RE = re.compile(rb"[\x00-\x1f\x7f]")
44
+ #: 强特征:任意提示符位置都认(空闲提示符上的 TMOUT 最常见),但整行须是
45
+ #: 「≤48 字符前缀 + 告别语 + 行尾空白」这一形状
46
+ _SHELL_EXIT_RULES = (
47
+ (re.compile(rb".{0,48}timed out waiting for input: auto-logout\s*",
48
+ re.IGNORECASE), "timed out waiting for input: auto-logout"),
49
+ (re.compile(rb"Connection to .*?\b(?:closed|dropped)\b.*",
50
+ re.IGNORECASE), "Connection closed"),
51
+ )
52
+ #: 弱特征:整行就是一个裸 logout——仅审批挂起窗口认
53
+ _SHELL_EXIT_WEAK_RULES = (
54
+ (re.compile(rb"logout\s*", re.IGNORECASE), "logout"),
55
+ )
56
+ # 回车看门狗时限:健康路径补发 \r 后,集成壳必在 PROMPT_COMMAND 里回 OSC 标记
57
+ # (133;D + 133;A/B)。到点只收到纯文本、一个标记都没有 = 承接这个回车的根本
58
+ # 不是集成壳(嵌套 shell 被 TMOUT/exit 收走后落回的裸壳)——此时健康路径的裸
59
+ # \r 会把整行自然语言交给它当命令执行(真机 -bash: 执行: command not found)
60
+ _HOOK_LIVENESS = 1.0
61
+
62
+
63
+ class HookGate:
64
+ def __init__(self, core) -> None:
65
+ self.core = core
66
+ for name, val in (
67
+ ("_interactive", False), # hook 集成就绪;False = 纯终端直通
68
+ ("_shell_kind", None), # bash | zsh | powershell(探测结论)
69
+ ("_b64flag", "-d"),
70
+ # _hook_gone:_ensure_integrated 探测不到可集成的 shell(bash 3.2 /
71
+ # 其他族 / 前台占用)——标记流不足以证明 hook 在位,pad/健康路径/
72
+ # 兜底按 hook 不在位降级。嵌套 shell 退出(_open_cmds 清空)复位。
73
+ ("_hook_gone", False),
74
+ # _hook_stale:带 hook 的 shell 整体消失(TMOUT/exit/logout,bash
75
+ # 不发告别标记)。由回车看门狗置位;任一 OSC 标记到达即撤销(外层
76
+ # 集成壳重新画出提示符 = 自愈),重注入落地亦作废。
77
+ ("_hook_stale", False),
78
+ ("_hook_seen", False), # hook 曾在位(掉线才补注入)
79
+ ("_hook_watch", None), # 回车看门狗任务(_HOOK_LIVENESS)
80
+ ("_hook_watch_text", False), # 看门狗窗口内收到过纯文本
81
+ ("_probe_buf", None), # 探测行输出收集(bytearray | None)
82
+ ("_last_mark_at", 0.0), # 最近一次 OSC 标记事件(A/B/D/report)
83
+ ("_last_live_at", 0.0), # 最近一次未被吞除的纯文本输出
84
+ ("_exit_tail", bytearray()), # 登出特征滚动窗(_detect_shell_exit)
85
+ ("_hook_report_pending", None), # 补发 \r 后等待 AI 上报的行
86
+ ):
87
+ if not hasattr(core, name):
88
+ setattr(core, name, val)
89
+
90
+ async def _ensure_integrated(self) -> bool:
91
+ """su - / sudo su - 等 login shell 会重置 PROMPT_COMMAND、函数与 Enter
92
+ 绑定,丢掉 OSC 133:D 提示符标记与 __ot_exec__/__ot_pad 通道,导致自然语言
93
+ 任务挂起。任务启动前重发分片注入脚本:幂等(healthy shell 只重定义同名函数/
94
+ 重设同款提示符,无副作用;su - 后则恢复全部集成)。回显经 _suppress_live 吞掉,
95
+ 等 OSC:D 提示符标记确认注入落地。
96
+
97
+ 注入前先探测提示符后面的真实 shell 族(su - 换壳):外层会话的
98
+ _shell_kind 是连接时探测的(如 zsh),而 macOS root = /bin/sh =
99
+ bash 3.2——把 zsh 脚本打进 bash 定义不出任何函数,且 zsh 版 PS1 在
100
+ bash 里照样发 OSC 133 标记,_hook_ok 由此假阳性(真机 su - 后
101
+ __ot_pad 连环 command not found、自然语言整行被 root shell 当命令
102
+ 执行、\x15 连环吞输入)。探测不到可集成 shell 时置 _hook_gone 转
103
+ 降级,本次任务外部启动。"""
104
+ if not self.core._interactive or self.core.session is None:
105
+ return False
106
+ async with self.core._runner_lock:
107
+ return await self._ensure_integrated_locked()
108
+
109
+ async def _ensure_integrated_locked(self) -> bool:
110
+ """重注入本体:调用方必须已持有 _runner_lock。
111
+
112
+ 拆出来是因为 InteractiveRunner.run(P0-1 门禁)与 _shell_pad 的 60s
113
+ 兜底补注入自己就持着这把锁,再经 _ensure_integrated 取一次就是重入
114
+ 死锁(asyncio.Lock 不可重入)——旧代码 _shell_pad 那条路径正踩着。
115
+ """
116
+ self.core._ev_prompt.clear()
117
+ self.core._suppress_live = True
118
+ self.core._suppressed_tail = ""
119
+ try:
120
+ # 前置 \x15 收纳半行但不补 \x19 恢复:任务启动即丢弃该半行
121
+ #(若 yank 回 kill ring 里的旧文本,会在提示符行复活污染输入)。
122
+ # 探测行必须敲在干净提示符上——半行没清,探测行会拼在用户
123
+ # 已敲文本后面整行执行。
124
+ await self.core.session.send_raw(b"\x15")
125
+ if self.core._shell_kind != "powershell":
126
+ # 静默窗口先行:hook 不在位(su - 换壳)时探测行/分片行
127
+ # 靠前导空格 + ignorespace 原生跳过入史;hook 在位时本行
128
+ # 被 widget 按内部行(__ot_hc= 前缀)静默处理
129
+ await self.core.session.send_raw(history_quiet_line())
130
+ shell, flag = await self._probe_shell_kind()
131
+ if shell is None:
132
+ if not self.core._hook_gone and not self.core._hook_stale:
133
+ # 首次判定不可集成:明确告知前端,别让每个任务静默失败。
134
+ # _hook_stale(壳已自行退出)时不发:根因已由
135
+ # _note_shell_dead 报过,再发一句「bash 3.2 / 前台占用」
136
+ # 是互相打架的误导
137
+ await self.core.emit_msg(ServerMsg(
138
+ type="status",
139
+ text="当前 shell 无法集成(bash 3.2 等旧壳 / 前台占用),"
140
+ "AI 工具执行不可用;请换可集成的 shell 后重试"))
141
+ self.core._hook_gone = True
142
+ return False
143
+ self.core._hook_gone = False
144
+ self.core._hook_stale = False # 重注入落地:陈旧判定作废
145
+ self._cancel_hook_watch()
146
+ if shell != self.core._shell_kind:
147
+ self.core._shell_kind = shell
148
+ self.core._b64flag = flag
149
+ for _ci, line in enumerate(
150
+ injection_lines(self.core._shell_kind,
151
+ self.core.exec._reinject_inst_base(),
152
+ self.core._b64flag)):
153
+ self.core._ev_stream.clear()
154
+ await self.core.session.send_raw(line)
155
+ # 逐片等本片回显到达再发下一片:固定短间隔会让分片堆进
156
+ # shell 执行间隙的 canonical 缓冲(4096 溢出丢块,真机实测
157
+ # 0.03s 间隔 md5 不复现)。回显即 readline 已读入本片的凭据;
158
+ # 回显绝迹(裸模式残影)时退回实测安全的 0.12s 间隔。
159
+ # 嵌套 ssh 隧道(跳板机)里 ZLE 对赋值行不回显——回显等待
160
+ # 总是超时、分片全速连发被 ssh 通道合并成大包,远端 pty
161
+ # 输入队列溢出**静默丢字节**,恰好丢掉末行 eval → 注入整链
162
+ # 无声失败。因此无条件再垫 0.04s 最小间隔,保证远端逐片
163
+ # 消化、批次不合并。
164
+ try:
165
+ await asyncio.wait_for(self.core._ev_stream.wait(), 0.12)
166
+ except asyncio.TimeoutError:
167
+ pass
168
+ await asyncio.sleep(0.04)
169
+ try:
170
+ await asyncio.wait_for(self.core._ev_prompt.wait(), 2.0)
171
+ # zsh POSTEDIT 重绘可能落在 A 标记之后:抑制窗口多留 80ms
172
+ # 把它一并吞掉,否则每任务注入的重绘行泄漏进主屏(§5.5)
173
+ await asyncio.sleep(0.08)
174
+ return True
175
+ except asyncio.TimeoutError:
176
+ return False
177
+ finally:
178
+ self.core._suppress_live = False
179
+ await self.core.exec._resync_after_suppress()
180
+
181
+ async def _probe_shell_kind(self) -> tuple[str | None, str]:
182
+ """在当前提示符敲一行能力探测,识别 su - / 嵌套 shell 后的真实 shell 族。
183
+
184
+ 复用 probe_command + parse_probe(bash>=4 门槛与 base64 参数判定与
185
+ 连接时探测同一套);行首加 ``_ot_inj=`` 前缀使 is_internal_line 成立
186
+ ——hook 恰好在位时该行按内部行静默记账,不入历史、不占用户命令帧;
187
+ 再加一个前导空格——hook 不在位时靠 _ensure_integrated 先发的静默行
188
+ (ignorespace/hist_ignore_space)原生跳过入史。
189
+ 输出经 _probe_buf 收集(_on_stream_event 的 live/exec 分支塞入),
190
+ 回显与输出由调用方持有的 _suppress_live 窗口吞掉,不外显。
191
+
192
+ 返回 (shell|None, b64flag):超时 / 无标记(前台占用吃掉探测行、
193
+ 其他 shell 族、bash 3.2)→ (None, "-d")。Windows 无 su - 语义,
194
+ 维持连接时探测的原判。"""
195
+ if self.core.session is None:
196
+ return None, "-d"
197
+ if self.core.profile is not None and self.core.profile.os_family == "windows":
198
+ return self.core._shell_kind, self.core._b64flag
199
+ self.core._probe_buf = bytearray()
200
+ try:
201
+ await self.core.session.send_raw(
202
+ (' _ot_inj=""; ' + probe_command(self.core.profile.os_family)
203
+ ).encode() + b"\r")
204
+ deadline = time.monotonic() + _impl._PROBE_TIMEOUT
205
+ while time.monotonic() < deadline:
206
+ shell, flag = parse_probe(
207
+ self.core._probe_buf.decode("utf-8", "replace"))
208
+ if shell is not None:
209
+ return shell, flag
210
+ await asyncio.sleep(0.05)
211
+ return None, "-d"
212
+ finally:
213
+ self.core._probe_buf = None
214
+
215
+ async def _drain_quiet(self, quiet: float = 0.12,
216
+ router: "StreamRouter | None" = None,
217
+ raw_sink: "Callable[[bytes], None] | None" = None
218
+ ) -> str | None:
219
+ """排空输出直到静默;喂 router 时返回其间出现的提示符(无则 None)。
220
+
221
+ setup 注入期专用:泵未启动,必须主动读,否则 zle 回显塞满输出队列
222
+ 会导致 shell 停读输入、写入端 EAGAIN 自旋死锁(Task 注入排障结论)。
223
+ raw_sink:原始字节旁路(PS2 续行指纹扫描等),不影响 router 解析。
224
+ """
225
+ prompt = None
226
+ while True:
227
+ try:
228
+ chunk = await self.core.session._read_some(quiet)
229
+ except asyncio.TimeoutError:
230
+ return prompt
231
+ except (ConnectionError, EOFError):
232
+ return prompt
233
+ if not chunk:
234
+ return prompt
235
+ _dbg("drain raw=", repr(chunk[:60]))
236
+ if raw_sink is not None:
237
+ raw_sink(chunk)
238
+ if router is not None:
239
+ for ev in router.feed(chunk):
240
+ if ev[0] == "prompt" and prompt is None:
241
+ prompt = ev[1]
242
+
243
+ def _hook_ok(self) -> bool:
244
+ """hook 在位判定:本会话见过 OSC 标记,且探测未判定「换了壳」。
245
+
246
+ 旧口径「标记时间戳不落后于纯文本」会把**按键回显**(纯文本)误判成
247
+ 标记绝迹:用户只要敲过字,live 必然新于最后一次提示符标记,健康路径
248
+ 永久退化——\\x03 + 整行重发,且 suppress 窗口吞掉 ^C 后的提示符重画
249
+ 使前端光标与 PTY 失步,重发回显拼在首段回显后(真机「同一行输入重复
250
+ 两遍」根因)。su - 等重置发生在用户命令帧内(_open_cmds 开着),已由
251
+ 空闲门闩覆盖;帧闭合后外层 hook 仍在、标记恢复。
252
+
253
+ 但仅凭标记流也不够:su - 换壳后注入的外族 PS1 照样发标记而函数全空
254
+ (真机 zsh PS1 落进 bash 3.2),故 _ensure_integrated 探测失败置
255
+ _hook_gone 转假;嵌套 shell 退出(_open_cmds 清空)复位,外层 hook
256
+ 的标记与函数从未丢过,自然恢复在位。
257
+
258
+ _hook_stale 补的是第三类:**带 hook 的 shell 自己没了**。root 空闲
259
+ TMOUT 自动登出、exit、logout 都不发任何标记,_hook_gone 的两个入口全
260
+ 够不着,而死壳留下的 _last_mark_at 会让 _inner_ssh_prompt_live 继续
261
+ 放行健康路径——补发的 \r 把自然语言整行送进落回来的裸壳执行(真机
262
+ 跳板机 → testuser → sudo su - root → TMOUT 掉回 → 再 sudo su - 后
263
+ 「输入自然语言就不行了」= -bash: 执行: command not found)。
264
+ 由回车看门狗置位,任一标记到达即撤销。"""
265
+ return (self.core._interactive and not self.core._hook_gone
266
+ and not self.core._hook_stale and self.core._last_mark_at > 0)
267
+
268
+ # --- 回车看门狗:健康路径的「hook 还在不在」验活 -----------------------
269
+
270
+ def _note_hook_mark(self) -> None:
271
+ """任一 OSC 标记到达:集成壳就在当前提示符上活着。
272
+
273
+ 既是 _inner_ssh_prompt_live 的时间戳凭据,也是陈旧判定的撤销凭据——
274
+ 外层集成壳重新画出提示符(嵌套裸壳退出后落回来)即自愈,健康路径
275
+ 立刻恢复,不必白付一次探测 + 分片重注入。"""
276
+ self.core._last_mark_at = time.monotonic()
277
+ self.core._hook_stale = False
278
+ self._cancel_hook_watch()
279
+
280
+ def _note_hook_text(self) -> None:
281
+ """纯文本(无标记)到达:看门狗窗口内记一笔「壳有响应」。
282
+
283
+ 只有文本、没有标记才判陈旧——一个标记都没有且毫无输出(链路卡死)时
284
+ 不作结论,交给既有的超时/断线机制。"""
285
+ if self.core._hook_watch is not None:
286
+ self.core._hook_watch_text = True
287
+
288
+ def _arm_hook_watch(self) -> None:
289
+ """健康路径补发回车后武装看门狗。
290
+
291
+ 集成壳的回车必有回响:PROMPT_COMMAND 发 133;D,提示符重画发 133;A/B。
292
+ _HOOK_LIVENESS 内只收到纯文本 = 承接回车的不是集成壳,转降级(下一条
293
+ 自然语言走探测 + 重注入,而不是把整行交给裸壳执行)。误判自愈:真正的
294
+ 标记一到即撤销;期间最多多付一次幂等重注入。"""
295
+ if self.core._display == "ssh":
296
+ # Shell 模式 pump 纯透传,标记根本不进路由器:无从验活,不作结论
297
+ return
298
+ self._cancel_hook_watch()
299
+ self.core._hook_watch_text = False
300
+ self.core._hook_watch = asyncio.create_task(self._hook_liveness())
301
+
302
+ def _cancel_hook_watch(self) -> None:
303
+ t = self.core._hook_watch
304
+ if t is not None:
305
+ self.core._hook_watch = None
306
+ t.cancel()
307
+
308
+ async def _hook_liveness(self) -> None:
309
+ me = asyncio.current_task()
310
+ try:
311
+ await asyncio.sleep(_HOOK_LIVENESS)
312
+ finally:
313
+ # 只清自己的把手:撤销后紧接着重新武装时,新任务不得被旧任务的
314
+ # finally 抹掉(否则看门狗失控、再也撤不掉)
315
+ if self.core._hook_watch is me:
316
+ self.core._hook_watch = None
317
+ if self.core._closed or not self.core._hook_watch_text:
318
+ return
319
+ _dbg("hook stale: 回车后只有文本没有标记 → 转降级")
320
+ self.core._hook_stale = True
321
+
322
+ # --- shell 自行退出感知(TMOUT 自动登出 / logout / 断链)--------------
323
+
324
+ async def _detect_shell_exit(self, data: bytes) -> None:
325
+ """在输出字节流里认 shell 自行退出的文字特征。
326
+
327
+ bash 退出不发任何 OSC 标记,_hook_gone 的两个入口与回车看门狗都够
328
+ 不着(见 _hook_ok 注释的「第三类」);而 TMOUT 最常命中在**审批等待
329
+ 期**——那段 core 对 PTY 零字节发送,看门狗根本无从武装。故只能在字节
330
+ 流里认文字,匹配口径见 _match_shell_exit。
331
+ """
332
+ self.core._exit_tail.extend(data)
333
+ if len(self.core._exit_tail) > 4096:
334
+ del self.core._exit_tail[:-2048]
335
+ why = self._match_shell_exit(bytes(self.core._exit_tail))
336
+ if why is None:
337
+ return
338
+ self.core._exit_tail.clear()
339
+ await self._note_shell_dead(why)
340
+
341
+ def _match_shell_exit(self, tail: bytes) -> str | None:
342
+ """在滚动窗里认 shell 告别语,回命中片段;没命中回 None。
343
+
344
+ 视觉行=按 CR / LF 断开(CR 重画、提示符转义都并进同一行),剥掉转义
345
+ 与控制符后**整行**全文匹配。两重收敛防误判:告别语前只允许一段提示符
346
+ 宽度的普通字符、后面只许空白;裸 logout 只在审批挂起窗口认。只翻窗尾
347
+ 几行:命中即清窗处置,再往前翻只会翻出早已处置过(或与本次无关)的
348
+ 旧行。
349
+ """
350
+ lines = [x for x in re.split(rb"[\r\n]", tail) if x.strip()]
351
+ for line in lines[-8:]:
352
+ plain = _EXIT_CTRL_RE.sub(b"", _EXIT_ANSI_RE.sub(b"", line))
353
+ for pat, label in _SHELL_EXIT_RULES:
354
+ if pat.fullmatch(plain):
355
+ return plain.decode("utf-8", "replace")[:80] or label
356
+ if self._shell_exit_window():
357
+ for pat, label in _SHELL_EXIT_WEAK_RULES:
358
+ if pat.fullmatch(plain):
359
+ return label
360
+ return None
361
+
362
+ def _shell_exit_window(self) -> bool:
363
+ """退出特征的弱匹配窗口:审批挂起。
364
+
365
+ 审批等待期 core 对 PTY 零字节发送、没有命令输出在途,行首 logout
366
+ 只可能是 shell 自己的告别;窗口外认了会把 history/ps/grep 输出里
367
+ 恰好成行的 logout 误判成壳死了。强特征(TMOUT 告别语 / ssh 断链)
368
+ 贴行首且专用,不受此窗口限制——空闲提示符上的 TMOUT 最常见,而那段
369
+ 既不在审批挂起也不在执行等待。"""
370
+ return self.core._pending_approval is not None
371
+
372
+ async def _note_shell_dead(self, why: str) -> None:
373
+ """壳已死:作废 hook 活凭据,立刻解阻塞在途工具执行。
374
+
375
+ 后续提交与工具注入经 _hook_ok 转假走门禁/降级(P0-1、P1-4),不再把
376
+ 工具注入行、Ctrl+C、__ot_pad 注进落回来的裸壳。任一 OSC 标记到达即由
377
+ _note_hook_mark 撤销陈旧判定(外层集成壳重画提示符=自愈)。
378
+ """
379
+ if not self.core._interactive:
380
+ return
381
+ first = not self.core._hook_stale
382
+ self.core._hook_stale = True # _hook_ok 转假:注入/健康路径全降级
383
+ self.core._last_mark_at = 0.0 # 死壳留下的旧时间戳不得再当活凭据
384
+ self._cancel_hook_watch()
385
+ fut = self.core._exec_future
386
+ if fut is not None and not fut.done():
387
+ # 明确错误立即收束,不等超时打 ^C(那也是落进裸壳的垃圾)
388
+ fut.set_result(CommandResult(
389
+ output=f"[ot] 远端 shell 已退出({why}),命令未执行;"
390
+ f"请重新登录后重试",
391
+ exit_code=CHANNEL_DEAD_EC, truncated=False, cwd=""))
392
+ if first:
393
+ await self.core.emit_msg(ServerMsg(
394
+ type="status",
395
+ text=f"远端 shell 已退出({why}):AI 工具执行不可用,"
396
+ f"请重新登录后重试"))
397
+
398
+ # --- 组件公开接口绑定 ---
399
+ ensure_integrated = _ensure_integrated
400
+ ensure_integrated_locked = _ensure_integrated_locked
401
+ probe_shell_kind = _probe_shell_kind
402
+ hook_ok = _hook_ok
403
+ note_mark = _note_hook_mark
404
+ note_text = _note_hook_text
405
+ observe_output = _detect_shell_exit
406
+
407
+ @property
408
+ def shell_kind(self) -> str | None:
409
+ """探测出的 shell 族(InteractiveRunner 经 core._shell_kind 读)。"""
410
+ return self.core._shell_kind
411
+
412
+ @property
413
+ def b64flag(self) -> str:
414
+ """base64 解码旗标(InteractiveRunner 经 core._b64flag 读)。"""
415
+ return self.core._b64flag