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,328 @@
1
+ """持久 shell 会话接口与哨兵捕获主循环(local / ssh 共用)。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from typing import Callable, Protocol
10
+
11
+ BEGIN_MARKER = "__OT_BEGIN__"
12
+ END_MARKER = "__OT_END__"
13
+ _BEGIN_RE = re.compile(rb"__OT_BEGIN__\r?\n")
14
+ _END_RE = re.compile(rb"__OT_END__(-?\d+)__(.*)__\r?\n")
15
+ _ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)")
16
+
17
+ # 中文提示符(密码:/口令:)单独以 str 编码拼进 bytes 正则(bytes 字面量
18
+ # 不能含非 ASCII)
19
+ _PASSWORD_PROMPT_RE = re.compile(
20
+ rb"(\[sudo\] password for |'s password:|\([^)\r\n]{1,80}\)\s*password:"
21
+ rb"|^password:|" + "密码[::]|口令[::]".encode() + rb")",
22
+ re.IGNORECASE | re.MULTILINE,
23
+ )
24
+ _PASSWORD_NEEDING_PREFIXES = {"sudo", "su", "ssh", "scp", "rsync"}
25
+
26
+
27
+ def _needs_password_prompt(command: str) -> bool:
28
+ head = command.strip().split()[0] if command.strip() else ""
29
+ return os.path.basename(head) in _PASSWORD_NEEDING_PREFIXES
30
+
31
+
32
+ @dataclass
33
+ class CommandResult:
34
+ output: str
35
+ exit_code: int
36
+ truncated: bool
37
+ cwd: str
38
+ reconnected: bool = False
39
+
40
+
41
+ class ShellSession(Protocol):
42
+ cwd: str
43
+
44
+ async def start(self) -> None: ...
45
+ async def run(
46
+ self,
47
+ command: str,
48
+ *,
49
+ timeout: int | None = None,
50
+ on_output: Callable[[str], None] | None = None,
51
+ ) -> CommandResult: ...
52
+ async def send_raw(self, data: bytes) -> None: ...
53
+ async def resize(self, rows: int, cols: int) -> None: ...
54
+ async def close(self) -> None: ...
55
+
56
+
57
+ def wrap_command(command: str) -> str:
58
+ """把用户命令包进 BEGIN/END 哨兵的单行复合命令。
59
+
60
+ 必须是单行:多行输入可能被会读 stdin 的命令(如 cat)偷吃掉。
61
+ """
62
+ one_line = command.replace("\n", " ; ").replace("\r", " ")
63
+ return (
64
+ f"printf '{BEGIN_MARKER}\\n'; {one_line}; "
65
+ f"printf '{END_MARKER}%s__%s__\\n' \"$?\" \"$PWD\"\n"
66
+ )
67
+
68
+
69
+ def wrap_passthrough(command: str, marker: str) -> str:
70
+ """交互式内联直通的单行包装:命令 ; printf 随机哨兵(带退出码)。
71
+
72
+ 与 wrap_command 的差别:不打 BEGIN 标记(活块从命令回显起显示),哨兵用
73
+ 调用方给的随机 marker。sudo su - / vim 等退出后 shell 会接着执行 printf,
74
+ worker 在字节流里扫描它即可自动检测「交互命令已结束」并取到退出码。
75
+ """
76
+ one_line = command.replace("\n", " ; ").replace("\r", " ")
77
+ return f"{one_line} ; printf '{marker}_%s__\\n' \"$?\"\r"
78
+
79
+
80
+ def _clean(raw: bytes) -> str:
81
+ raw = _ANSI_RE.sub(b"", raw)
82
+ return raw.decode("utf-8", errors="replace").replace("\r", "").rstrip("\n")
83
+
84
+
85
+ def slice_capture(data: bytes) -> tuple[str, int, str] | None:
86
+ """从 PTY 字节流里切出(命令输出, exit_code, cwd);哨兵未到齐返回 None。"""
87
+ # 必须锚定"标记 + 真实换行":tty 回显的命令行里也含字面 __OT_BEGIN__\n,
88
+ # 但那是引号内的反斜杠+n 两字符,后面并非真正的 CR/LF(真实 printf 输出
89
+ # 在 cooked PTY 上是 __OT_BEGIN__\r\n)。
90
+ m_begin = _BEGIN_RE.search(data)
91
+ if not m_begin:
92
+ return None
93
+ body_start = m_begin.end()
94
+ end_idx = data.find(END_MARKER.encode(), body_start)
95
+ if end_idx < 0:
96
+ return None
97
+ line_end = data.find(b"\n", end_idx)
98
+ # 切片右端是排他的,必须把行尾 \n 包进窗口,否则要求 \r?\n 收尾的
99
+ # _END_RE 永不匹配。
100
+ search_end = line_end + 1 if line_end > 0 else len(data)
101
+ m = _END_RE.search(data[end_idx:search_end])
102
+ if not m:
103
+ return None
104
+ output = _clean(data[body_start:end_idx])
105
+ cwd = m.group(2).decode("utf-8", errors="replace")
106
+ return output, int(m.group(1)), cwd
107
+
108
+
109
+ def truncate_output(text: str, limit: int) -> tuple[str, bool]:
110
+ if len(text) <= limit:
111
+ return text, False
112
+ head = text[: limit // 2]
113
+ tail = text[-limit // 2 :]
114
+ return f"{head}\n\n... 输出已截断(中间省略)...\n{tail}", True
115
+
116
+
117
+ class BasePtySession:
118
+ """子类提供字节级读写,本类实现哨兵主循环、超时、截断、断线恢复。"""
119
+
120
+ def __init__(self, *, default_timeout: int = 120, max_output_bytes: int = 102_400):
121
+ self.cwd = ""
122
+ self._buf = bytearray()
123
+ self._default_timeout = default_timeout
124
+ self._max_output = max_output_bytes
125
+ # 一个 PTY 就是一条交互式 shell:模型一轮并行发起多个 execute 时,
126
+ # langgraph 会并发调用 run(),而哨兵捕获/cwd 跟踪都要求命令严格串行,
127
+ # 否则两条命令的写入与回读互相截胡(命令拼坏、输出互吞、超时重置)。
128
+ self._run_lock = asyncio.Lock()
129
+ # 断线自动重连成功后的回调(async def () -> None),worker 用于把
130
+ # 历史命令重新注入新 shell;None = 无操作。
131
+ self.on_reconnect = None
132
+
133
+ def _wrap(self, command: str) -> str:
134
+ """把命令包进哨兵;POSIX shell 语法,PowerShell 会话覆写此方法。"""
135
+ return wrap_command(command)
136
+
137
+ def _wrap_passthrough(self, command: str, marker: str) -> str:
138
+ """交互式内联直通包装;PowerShell 会话覆写此方法。"""
139
+ return wrap_passthrough(command, marker)
140
+
141
+ # --- 子类实现 ---
142
+ async def _write_raw(self, data: bytes) -> None:
143
+ raise NotImplementedError
144
+
145
+ async def drain_to_silence(self, quiet: float = 0.2) -> None:
146
+ """排空积压输出直到静默(默认无操作,本地会话覆写)。
147
+
148
+ 只在**哨兵批写入前**调用:本地 tty 回显/重绘会把哨兵锚点搞乱
149
+ (zle cooked 模式整行回显、ConPTY 提示符重绘)。交互按键
150
+ (send_raw)绝不能排空——那会与 _pump_loop 抢读、吞掉提示符/
151
+ OSC 标记字节,还给每次按键加固定 quiet 延迟。
152
+ """
153
+ return
154
+
155
+ async def _read_some(self, idle_timeout: float) -> bytes:
156
+ raise NotImplementedError
157
+
158
+ async def _interrupt(self) -> None:
159
+ raise NotImplementedError
160
+
161
+ async def recover(self) -> bool:
162
+ """外部(worker 键盘路径)触发的断线恢复;成功时已回调 on_reconnect。"""
163
+ return await self._recover_connection()
164
+
165
+ async def _recover_connection(self) -> bool:
166
+ return False
167
+
168
+ async def _notify_reconnect(self) -> None:
169
+ """重连成功后通知 worker(历史注入等);回调异常不外泄。"""
170
+ cb = self.on_reconnect
171
+ if cb is None:
172
+ return
173
+ try:
174
+ await cb()
175
+ except Exception: # noqa: BLE001 - 钩子失败不能打断恢复流程
176
+ pass
177
+
178
+ async def _still_alive(self) -> bool:
179
+ """超时中断后哨兵缺失时的存活探测;默认不探测(视为已死)。
180
+
181
+ PowerShell 的 Ctrl-C 会中止整行提交、finally 里的 END 哨兵也不
182
+ 输出,但 shell 本身还活着——ConPTY 会话覆写此方法保住会话状态。
183
+ """
184
+ return False
185
+
186
+ # --- 主循环 ---
187
+ async def run(
188
+ self,
189
+ command: str,
190
+ *,
191
+ timeout: int | None = None,
192
+ on_output: Callable[[str], None] | None = None,
193
+ ) -> CommandResult:
194
+ async with self._run_lock:
195
+ return await self._run_locked(
196
+ command, timeout=timeout, on_output=on_output)
197
+
198
+ async def _run_locked(
199
+ self,
200
+ command: str,
201
+ *,
202
+ timeout: int | None = None,
203
+ on_output: Callable[[str], None] | None = None,
204
+ ) -> CommandResult:
205
+ effective = timeout or self._default_timeout
206
+ self._buf.clear()
207
+ tee_started = False
208
+ deadline = asyncio.get_running_loop().time() + effective
209
+ try:
210
+ # 哨兵批写入前排空到静默(本地会话:防回显/重绘错位锚点;
211
+ # SSH 无此问题,基类默认 no-op)。交互按键不经过这里。
212
+ await self.drain_to_silence()
213
+ await self._write_raw(self._wrap(command).encode())
214
+ except (ConnectionError, EOFError):
215
+ if await self._recover_connection():
216
+ return self._reconnected_result()
217
+ raise
218
+
219
+ while True:
220
+ remaining = deadline - asyncio.get_running_loop().time()
221
+ if remaining <= 0:
222
+ return await self._handle_timeout()
223
+ try:
224
+ chunk = await self._read_some(min(5.0, remaining))
225
+ except asyncio.TimeoutError:
226
+ continue
227
+ except (ConnectionError, EOFError):
228
+ if await self._recover_connection():
229
+ return self._reconnected_result()
230
+ raise
231
+ if not chunk:
232
+ if await self._recover_connection():
233
+ return self._reconnected_result()
234
+ raise ConnectionError("shell 通道已关闭")
235
+
236
+ prev_len = len(self._buf)
237
+ self._buf.extend(chunk)
238
+ if on_output:
239
+ # 实时回显:从真实 BEGIN 标记行起、到 END 止,原样 tee(保留颜色)。
240
+ # 用 _BEGIN_RE(标记+真实换行)锚定,跳过命令行 tty 回显。
241
+ if not tee_started:
242
+ m_begin = _BEGIN_RE.search(self._buf)
243
+ if m_begin:
244
+ tee_started = True
245
+ head = bytes(self._buf[m_begin.end() :])
246
+ if END_MARKER.encode() in head:
247
+ head = head[: head.find(END_MARKER.encode())]
248
+ if head:
249
+ on_output(head.decode("utf-8", errors="replace"))
250
+ else:
251
+ view = chunk
252
+ if END_MARKER.encode() in view:
253
+ view = view[: view.find(END_MARKER.encode())]
254
+ if view:
255
+ on_output(view.decode("utf-8", errors="replace"))
256
+
257
+ sliced = slice_capture(bytes(self._buf))
258
+ if sliced is not None:
259
+ output, code, cwd = sliced
260
+ output, truncated = truncate_output(output, self._max_output)
261
+ if not output:
262
+ output = "<no output>"
263
+ self.cwd = cwd or self.cwd
264
+ return CommandResult(output=output, exit_code=code,
265
+ truncated=truncated, cwd=self.cwd)
266
+
267
+ # 兜底:sudo/su/ssh 出现密码提示 → 提前中断,别等 120s 超时丢会话。
268
+ # 仅当提示串位于缓冲末尾(真实提示阻塞等待输入、其后无换行)才触发,
269
+ # 避免误杀输出里恰好含 password 字样的正常命令。
270
+ m = _PASSWORD_PROMPT_RE.search(self._buf)
271
+ if m and b"\n" not in self._buf[m.end():] and _needs_password_prompt(command):
272
+ await self._interrupt()
273
+ hint = (
274
+ "检测到密码/凭据提示:该命令需要交互输入密码,命令模式无法完成。\n"
275
+ "请按 Ctrl+R 进入终端透传模式手动执行(Web 端切换透传模式),"
276
+ "或先执行 sudo -v 刷新凭据后再试。"
277
+ )
278
+ if on_output is not None:
279
+ on_output(hint + "\n")
280
+ return CommandResult(
281
+ output=hint, exit_code=130, truncated=False, cwd=self.cwd,
282
+ )
283
+
284
+ # 防止异常输出无限占内存:保留尾部,哨兵在尾部 64KB 内必能再次出现
285
+ if len(self._buf) > max(self._max_output * 4, 1_000_000):
286
+ del self._buf[: len(self._buf) - 262_144]
287
+
288
+ def _reconnected_result(self) -> CommandResult:
289
+ return CommandResult(
290
+ output="[ssh] 连接中断后已自动重连;为避免非幂等重复执行,原命令未重跑,"
291
+ "请确认后重试。",
292
+ exit_code=1,
293
+ truncated=False,
294
+ cwd=self.cwd,
295
+ reconnected=True,
296
+ )
297
+
298
+ async def _handle_timeout(self) -> CommandResult:
299
+ # 先 Ctrl-C 给命令优雅退出的机会,再等 3 秒哨兵
300
+ await self._interrupt()
301
+ loop = asyncio.get_running_loop()
302
+ deadline = loop.time() + 3
303
+ while loop.time() < deadline:
304
+ try:
305
+ self._buf.extend(await self._read_some(deadline - loop.time()))
306
+ except asyncio.TimeoutError:
307
+ break
308
+ except (ConnectionError, EOFError):
309
+ break
310
+ if slice_capture(bytes(self._buf)) is not None:
311
+ output, code, cwd = slice_capture(bytes(self._buf)) # type: ignore[misc]
312
+ self.cwd = cwd or self.cwd
313
+ return CommandResult(
314
+ output=f"{output}\n\n[命令在 {self._default_timeout}s 后被中断 (Ctrl-C)",
315
+ exit_code=130, truncated=False, cwd=self.cwd,
316
+ )
317
+ if await self._still_alive():
318
+ return CommandResult(
319
+ output=f"[命令在 {self._default_timeout}s 后被中断 (Ctrl-C),"
320
+ "shell 仍存活,会话状态保留。]",
321
+ exit_code=130, truncated=False, cwd=self.cwd,
322
+ )
323
+ if await self._recover_connection():
324
+ return self._reconnected_result()
325
+ return CommandResult(
326
+ output=f"Error: 命令超时({self._default_timeout}s),shell 已重置,会话状态丢失。",
327
+ exit_code=124, truncated=False, cwd=self.cwd,
328
+ )
@@ -0,0 +1,184 @@
1
+ """macOS/Linux 本地持久 shell:openpty + 非阻塞读取。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import fcntl
7
+ import os
8
+ import pty
9
+ import signal
10
+ import struct
11
+ import termios
12
+
13
+ from .base import BasePtySession, CommandResult
14
+
15
+
16
+ def _stall_dbg(n: int, first: bool = False) -> None:
17
+ """临时排障:写入 EAGAIN 停滞计数(OT_WEB_DEBUG=1 时可见)。"""
18
+ import os as _os
19
+ if _os.environ.get("OT_WEB_DEBUG"):
20
+ import sys
21
+ print(f"[otdbg] write stalled x{n}{'' if first else ' (recovered)'}",
22
+ flush=True)
23
+
24
+
25
+ class LocalPtySession(BasePtySession):
26
+ def __init__(self, shell: str | None = None, **kwargs) -> None:
27
+ super().__init__(**kwargs)
28
+ self._shell = shell or os.environ.get("SHELL", "/bin/bash")
29
+ self._pid: int | None = None
30
+ self._fd: int | None = None
31
+ # 写入停顿期的输出接收器(asyncio 单线程,同步回调):setup 注入期
32
+ # 由 worker 设为喂 StreamRouter,打破回显塞满输出队列的死锁
33
+ self._write_sink = None
34
+
35
+ async def start(self) -> None:
36
+ pid, fd = pty.fork()
37
+ if pid == 0: # 子进程
38
+ try:
39
+ env = os.environ.copy()
40
+ env["TERM"] = "xterm-256color"
41
+ os.execve(self._shell, [self._shell, "-l"], env)
42
+ except Exception:
43
+ os._exit(127)
44
+ self._pid, self._fd = pid, fd
45
+ # 壳继承进程 CWD(pty.fork 不 chdir):文件根 _local_root 在构造时读
46
+ # session.cwd,这里不填就会空着回退主目录——用户/测试的相对文件全踩空
47
+ self.cwd = os.getcwd()
48
+ flags = fcntl.fcntl(fd, fcntl.F_GETFL)
49
+ fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
50
+ await self.resize(40, 120)
51
+ # 等初始提示符输出排空
52
+ await self._quiet()
53
+
54
+ async def _quiet(self) -> None:
55
+ # 偏离计划(最小):必须先等到 shell 的首批输出,再做静默排空。
56
+ # 本机登录 zsh 加载 rc 时约 2.4s 内无任何输出;原实现首次 0.5s
57
+ # 超时即返回,会让命令在行编辑器就绪前写入,zsh 随后重绘该行,
58
+ # 重绘文本里的字面哨兵会把 slice_capture 的锚点永久锁死。
59
+ try:
60
+ if not await self._read_some(5.0):
61
+ return
62
+ while True:
63
+ await self._read_some(0.5)
64
+ except (asyncio.TimeoutError, OSError):
65
+ pass
66
+
67
+ async def _write_raw(self, data: bytes) -> None:
68
+ assert self._fd is not None
69
+ # 直写,不排空:交互按键(send_raw)走这里,排空会与主循环读者
70
+ # 抢 fd 数据、吞掉提示符/OSC 标记字节,还引入固定 200ms 延迟。
71
+ # fd 是 O_NONBLOCK(读泵需要):tty 输入队列满(TTYHOG)时 write
72
+ # 抛 BlockingIOError,须等待消费者(shell 行编辑器)排空后重试。
73
+ # 死锁防护:若写方回显已塞满输出队列(泵未启动的 setup 注入期),
74
+ # 行规程因 echo 无处写而拒绝接受输入 → 双向永久 EAGAIN。此时
75
+ # _write_sink(setup 注入期设为喂 router)边写边读输出,打破死锁;
76
+ # 无 sink 时丢弃——该输出本就无读者,不丢则死锁。
77
+ stalls = 0
78
+ while data:
79
+ try:
80
+ n = os.write(self._fd, data)
81
+ data = data[n:]
82
+ if stalls:
83
+ _stall_dbg(stalls)
84
+ stalls = 0
85
+ except BlockingIOError:
86
+ stalls += 1
87
+ if stalls == 100:
88
+ _stall_dbg(stalls, first=True)
89
+ got = b""
90
+ if self._write_sink is not None:
91
+ try:
92
+ got = os.read(self._fd, 65_536)
93
+ except (BlockingIOError, OSError):
94
+ got = b""
95
+ if got:
96
+ self._write_sink(got)
97
+ await asyncio.sleep(0.02)
98
+
99
+ async def drain_to_silence(self, quiet: float = 0.2) -> None:
100
+ # 哨兵批写入前:排空到静默。上一条命令的哨兵返回时,zsh 往往还没
101
+ # 画完新提示符,此刻 tty 处于 cooked 模式:写入会被行规范整行回显,
102
+ # 再被 zle 重绘,产生多份带真实换行的字面哨兵,使 slice_capture
103
+ # 锚点永久错位。等提示符画完(zle 就绪)再写。仅批路径调用。
104
+ while True:
105
+ try:
106
+ chunk = await self._read_some(quiet)
107
+ except (asyncio.TimeoutError, OSError):
108
+ break
109
+ if not chunk:
110
+ break
111
+
112
+ async def _read_some(self, idle_timeout: float) -> bytes:
113
+ assert self._fd is not None
114
+ loop = asyncio.get_running_loop()
115
+ fut: asyncio.Future[bytes] = loop.create_future()
116
+
117
+ def _readable() -> None:
118
+ if fut.done():
119
+ return
120
+ try:
121
+ data = os.read(self._fd, 65_536)
122
+ except BlockingIOError:
123
+ return
124
+ except OSError:
125
+ fut.set_result(b"")
126
+ return
127
+ fut.set_result(data)
128
+
129
+ loop.add_reader(self._fd, _readable)
130
+ try:
131
+ return await asyncio.wait_for(fut, timeout=idle_timeout)
132
+ finally:
133
+ loop.remove_reader(self._fd)
134
+
135
+ async def _interrupt(self) -> None:
136
+ if self._fd is not None:
137
+ os.write(self._fd, b"\x03") # Ctrl-C
138
+
139
+ async def _recover_connection(self) -> bool:
140
+ """本地:关掉死掉的进程组并重启 shell,语义等同"重新连上"。"""
141
+ await self.close()
142
+ try:
143
+ await self.start()
144
+ await self._notify_reconnect()
145
+ return True
146
+ except OSError:
147
+ return False
148
+
149
+ async def _handle_timeout(self) -> CommandResult:
150
+ # 偏离计划(最小):Ctrl-C 后 zsh 会直接丢弃当前命令行、不打印哨兵,
151
+ # 基类于是重启 shell 并返回 reconnected(exit 1)。本地重启后会话
152
+ # 状态确已丢失,按超时语义返回 124(Task 4 测试约定 124/130)。
153
+ result = await super()._handle_timeout()
154
+ if result.reconnected:
155
+ return CommandResult(
156
+ output=f"Error: 命令超时({self._default_timeout}s),shell 已重置,会话状态丢失。",
157
+ exit_code=124, truncated=False, cwd=self.cwd,
158
+ )
159
+ return result
160
+
161
+ async def resize(self, rows: int, cols: int) -> None:
162
+ if self._fd is None:
163
+ return
164
+ size = struct.pack("HHHH", rows, cols, 0, 0)
165
+ fcntl.ioctl(self._fd, termios.TIOCSWINSZ, size)
166
+
167
+ async def send_raw(self, data: bytes) -> None:
168
+ await self._write_raw(data)
169
+
170
+ async def close(self) -> None:
171
+ if self._pid is not None:
172
+ try:
173
+ os.killpg(os.getpgid(self._pid), signal.SIGKILL)
174
+ except (ProcessLookupError, OSError):
175
+ try:
176
+ os.kill(self._pid, signal.SIGKILL)
177
+ except (ProcessLookupError, OSError):
178
+ pass
179
+ if self._fd is not None:
180
+ try:
181
+ os.close(self._fd)
182
+ except OSError:
183
+ pass
184
+ self._pid = self._fd = None
@@ -0,0 +1,173 @@
1
+ """Windows 本地持久 shell:ConPTY(pywinpty)+ 专职读者线程。
2
+
3
+ 与 LocalPtySession 实现同一个 ShellSession 协议(BasePtySession 哨兵主循环
4
+ 复用),差别只在字节层:ConPTY 没有非阻塞 fd,用线程阻塞读并经线程安全
5
+ 队列投递给事件循环。命令包装用 PowerShell 语法覆写 _wrap。
6
+
7
+ 仅 Windows 可导入;Linux/macOS 路径不经过本模块。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import threading
14
+
15
+ from .base import BasePtySession
16
+
17
+
18
+ class WinPtySession(BasePtySession):
19
+ def __init__(self, shell: str | None = None, **kwargs) -> None:
20
+ super().__init__(**kwargs)
21
+ self._shell = shell or "powershell.exe -NoLogo"
22
+ self._proc = None
23
+ self._loop: asyncio.AbstractEventLoop | None = None
24
+ self._queue: asyncio.Queue[bytes] | None = None
25
+ self._reader: threading.Thread | None = None
26
+
27
+ # --- PowerShell 哨兵包装(与 wrap_command 的 POSIX 版对应)---
28
+ def _wrap(self, command: str) -> str:
29
+ one_line = command.replace("\n", " ; ").replace("\r", " ")
30
+ # 结尾必须 \r:PSReadLine 把 LF 当 Shift-Enter(编辑缓冲区换行、
31
+ # 出 >> 续行提示),命令不会被提交执行
32
+ # 退出码:先清 $LASTEXITCODE——原生命令码只在它非空时取(cmd /c
33
+ # exit 3 时 $? 也是 False,不能用它兜底);为空说明只跑了 cmdlet,
34
+ # 用 $? 判成败。try/finally 让正常路径的 END 一定输出
35
+ return (
36
+ "$LASTEXITCODE = $null; Write-Output '__OT_BEGIN__'; "
37
+ "try { " + one_line + " } finally { "
38
+ "$otc = if ($null -eq $LASTEXITCODE) { "
39
+ "if ($?) { 0 } else { 1 } } else { $LASTEXITCODE }; "
40
+ "Write-Output ('__OT_END__' + $otc + '__'"
41
+ " + (Get-Location).Path + '__') }\r"
42
+ )
43
+
44
+ def _wrap_passthrough(self, command: str, marker: str) -> str:
45
+ one_line = command.replace("\n", " ; ").replace("\r", " ")
46
+ # 与 POSIX 版一致:交互程序退出后输出带随机 marker 的哨兵;
47
+ # $LASTEXITCODE 为空说明只跑了 cmdlet/无原生命令退出码,按 0 处理
48
+ return (one_line + " ; Write-Output ('" + marker + "_' + "
49
+ "$(if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 })"
50
+ " + '__')\r")
51
+
52
+ # --- 生命周期 ---
53
+ async def start(self) -> None:
54
+ from winpty import PtyProcess
55
+
56
+ self._proc = PtyProcess.spawn(
57
+ self._shell.split(), dimensions=(40, 120))
58
+ # 同 local_posix:壳继承进程 CWD,文件根构造时读 session.cwd
59
+ self.cwd = os.getcwd()
60
+ self._loop = asyncio.get_running_loop()
61
+ self._queue = asyncio.Queue()
62
+ self._reader = threading.Thread(
63
+ target=self._pump, name="ot-conpty-reader", daemon=True)
64
+ self._reader.start()
65
+ await self._quiet() # 等 PowerShell 横幅/提示符排空
66
+
67
+ async def _quiet(self) -> None:
68
+ try:
69
+ if not await self._read_some(10.0):
70
+ return
71
+ while True:
72
+ await self._read_some(0.5)
73
+ except (asyncio.TimeoutError, OSError):
74
+ pass
75
+
76
+ def _pump(self) -> None:
77
+ """专职读者线程:ConPTY 无非阻塞读,阻塞读并投递进事件循环。"""
78
+ assert self._loop is not None and self._queue is not None
79
+ while self._proc is not None:
80
+ try:
81
+ data = self._proc.read(65_536)
82
+ except (EOFError, OSError):
83
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, b"")
84
+ return
85
+ if not data:
86
+ continue
87
+ payload = data if isinstance(data, bytes) else \
88
+ data.encode("utf-8", errors="replace")
89
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, payload)
90
+
91
+ # --- 字节层 ---
92
+ async def _write_raw(self, data: bytes) -> None:
93
+ # 直写,不排空:交互按键(send_raw)走这里,排空会与 pump 的
94
+ # 读者线程抢读、吞掉提示符/OSC 标记字节,还引入固定 200ms 延迟。
95
+ # 哨兵批写入需要静默的场景由 drain_to_silence()(仅批路径调用)承担。
96
+ if self._proc is not None:
97
+ self._proc.write(data.decode("utf-8", errors="replace"))
98
+
99
+ async def drain_to_silence(self, quiet: float = 0.2) -> None:
100
+ # 哨兵批写入前:排空到静默,避免回显/重绘把哨兵锚点搞乱
101
+ while True:
102
+ try:
103
+ chunk = await self._read_some(quiet)
104
+ except (asyncio.TimeoutError, OSError):
105
+ break
106
+ if not chunk:
107
+ break
108
+
109
+ async def _read_some(self, idle_timeout: float) -> bytes:
110
+ assert self._queue is not None
111
+ return await asyncio.wait_for(self._queue.get(), timeout=idle_timeout)
112
+
113
+ async def _interrupt(self) -> None:
114
+ if self._proc is not None:
115
+ try:
116
+ self._proc.sendintr() # Ctrl-C
117
+ except OSError:
118
+ pass
119
+
120
+ async def _recover_connection(self) -> bool:
121
+ """本地 shell 死掉:关掉重启,语义等同"重新连上"。"""
122
+ await self.close()
123
+ try:
124
+ await self.start()
125
+ await self._notify_reconnect()
126
+ return True
127
+ except OSError:
128
+ return False
129
+
130
+ async def _still_alive(self) -> bool:
131
+ """超时 Ctrl-C 后 shell 通常还活着(PS 的中断只作废当前行,
132
+ finally 里的 END 哨兵不会输出)。发一条探针命令确认,避免整个
133
+ 会话被无谓重启、cd 等状态丢失。"""
134
+ probe = "Write-Output ('ot-al' + 'ive-ok')\r"
135
+ try:
136
+ await self._write_raw(probe.encode())
137
+ except (ConnectionError, EOFError, OSError):
138
+ return False
139
+ loop = asyncio.get_running_loop()
140
+ deadline = loop.time() + 5
141
+ buf = b""
142
+ while loop.time() < deadline:
143
+ try:
144
+ chunk = await self._read_some(min(2.0, deadline - loop.time()))
145
+ except asyncio.TimeoutError:
146
+ continue # 高负载下回显可能迟于单次 2s 读:等满 5s 总预算
147
+ except (ConnectionError, EOFError, OSError):
148
+ return False
149
+ # 探针命令回显里是 'ot-al'+'ive-ok' 拆开的,只有真实输出才出现
150
+ # 连续的 ot-alive-ok;标记可能跨 chunk 拆分,累积后再判断
151
+ buf += chunk
152
+ if b"ot-alive-ok" in buf:
153
+ return True
154
+ return False
155
+
156
+ async def resize(self, rows: int, cols: int) -> None:
157
+ if self._proc is not None:
158
+ try:
159
+ self._proc.setwinsize(rows, cols)
160
+ except OSError:
161
+ pass
162
+
163
+ async def send_raw(self, data: bytes) -> None:
164
+ await self._write_raw(data)
165
+
166
+ async def close(self) -> None:
167
+ proc, self._proc = self._proc, None
168
+ if proc is not None:
169
+ try:
170
+ proc.terminate(force=True)
171
+ except OSError:
172
+ pass
173
+ # 读者线程随 proc 关闭读到 EOFError 自行退出