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,781 @@
1
+ """AI 行处理与任务(Task 7 自 PipelineCore 原样搬入)。
2
+
3
+ 自然语言入口(start_ai / on_ai_line / on_frontend_line)、任务执行
4
+ (run_task / _ai_flow)、斜杠命令(handle_slash)、失败救援决策、
5
+ 兜底上报、模型与显示切换——AI 栈装配(agent 会话/预热)也在本组件。
6
+ 状态**物理存 core 实例**(P3 裁定延续:pump/close/_switch_target 与
7
+ 测试直读 core._ai_task/_display 等;_display 读点 _switch_target/
8
+ _pump_loop 留 core,故 _display 一并留 core,本组件经 core 读写;
9
+ AiFlow.__init__ 以 hasattr 守卫补建,预置值不覆盖),方法体经
10
+ self.core 访问;实例 __getattr__ 转发未知属性到 core(简报直读形态)。
11
+
12
+ 补丁缝:build_agent / build_chat_model / SUBMIT_FALLBACK_DELAY 经
13
+ _impl 运行期解析——tests 从 web.worker 经双跳补丁桥落到 _core_impl
14
+ dict,裸读模块全局会漏补丁(auth/store_password 缝先例)。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import time
21
+ from dataclasses import replace
22
+
23
+ from . import pipeline as _impl
24
+ from .messages import ServerMsg, _dbg
25
+ from ..shell.integration import toggle_line
26
+
27
+
28
+ class _SegBuffer:
29
+ """paced 段缓冲:Web 模式下 token/think 攒到段边界一次性下发(ai_result)。
30
+
31
+ 前端不攒 token 流(浏览器零压力);段边界 = tool_start(轮内文本先于
32
+ tool_start 全部到达)/ final(吞正文留思考)/ 失败路径(冲 failed 段)/
33
+ 中断(不冲,零残留)。双空段不发(纯工具轮不长空分析卡)。
34
+ """
35
+
36
+ __slots__ = ("text", "think", "chars", "beat", "runner")
37
+
38
+ def __init__(self) -> None:
39
+ self.text = ""
40
+ self.think = ""
41
+ self.chars = 0 # 累计字数(跨段,徽标显示用)
42
+ self.beat = 0.0 # 上次心跳时刻(monotonic)
43
+ self.runner = None # TaskRunner(心跳读 token 计数)
44
+
45
+ def add(self, text: str, think: str) -> None:
46
+ self.text += text
47
+ self.think += think
48
+ self.chars += len(text) + len(think)
49
+
50
+ def take(self) -> tuple[str, str]:
51
+ text, think = self.text, self.think
52
+ self.text = ""
53
+ self.think = ""
54
+ return text, think
55
+
56
+
57
+ class AiFlow:
58
+ def __init__(self, core) -> None:
59
+ self.core = core
60
+ for name, val in (
61
+ ("_ai_task", None), # 在跑的 AI 任务(None = 空闲)
62
+ ("_ai_ping_at", 0.0), # AI 任务最近一次有产出(僵尸判据)
63
+ ("_ai_queue", []), # AI 忙时排队 (line, hooked)
64
+ ("_suppress_ai_report", None), # 兜底已启动后迟到的重复上报
65
+ ("_fallback_task", None), # _submit_fallback 句柄(测试/关闭可取消)
66
+ ("_rescue_open", False), # 救援卡待决策(最多一张,防连败刷屏)
67
+ ("_rescue_ctx", None), # (line, ec, output):决策时的任务上下文
68
+ ("_runner", None), # InteractiveRunner(探测就绪后建)
69
+ ("_runner_lock", None), # runner 换壳串行锁(下两行修正为 Lock)
70
+ ("_display", "agent"), # agent = hook 集成 / ssh = 纯透传
71
+ ):
72
+ if not hasattr(core, name):
73
+ setattr(core, name, val)
74
+ if not hasattr(core, "_runner_lock") or core._runner_lock is None:
75
+ core._runner_lock = asyncio.Lock()
76
+
77
+ def _agent_session(self):
78
+ """agent 工具执行用的会话:交互式 = InteractiveRunner(同 PTY 注入),
79
+ 否则原始 session(哨兵批处理,仅记账,无 AI 输入通道)。"""
80
+ return self.core._runner if self.core._interactive else self.core.session
81
+ def _build_agent_now(self) -> None:
82
+ """立即构建/重建 agent。AI 栈经 _impl._load_agent_stack 惰性载入模块级名字
83
+ (deepagents/langchain 约 4s 导入由此与启动路径解耦);测试 monkeypatch
84
+ 的 _impl.build_agent 桩在此生效(非 None 即跳过真导入)。"""
85
+ _impl._load_agent_stack()
86
+ # 远端目标(SSH/嵌套 ssh)的 session.cwd 是远端路径,不能当本机文件根
87
+ target = getattr(self.core, "_target", None)
88
+ remote = bool(target is not None and target.mode != "local")
89
+ self.core.agent, self.core.allowed, self.core.backend = _impl.build_agent(
90
+ self.core.profile, self._agent_session(), self.core.cfg, self.core.policy,
91
+ model=_impl.build_chat_model(self.core._model),
92
+ remote_target=remote)
93
+ def _ensure_agent(self) -> None:
94
+ """首个 AI 任务时构建 agent,已建则复用。self.core.agent/backend 从 None
95
+ 变就绪的唯一入口(建连不再预建)。"""
96
+ if self.core.agent is None:
97
+ self._build_agent_now()
98
+ async def _prewarm_agent(self) -> None:
99
+ """后台预热 AI 栈导入(只导入不构建:构建依赖 profile/model,留主
100
+ 线程)。建连完成即跑,首条自然语言的 task_start 不再等秒级 import;
101
+ 失败静默——首任务回退按需导入。"""
102
+ def _import_stack() -> None:
103
+ _impl._load_agent_stack()
104
+ from .. import agent # noqa: F401 - 预导入模块本体,_run_task
105
+ # 调用点 `from ..agent.runner import TaskRunner` 命中 sys.modules 零成本
106
+ try:
107
+ await asyncio.to_thread(_import_stack)
108
+ except Exception: # noqa: BLE001 - 预热失败不阻断,首任务按需导入
109
+ pass
110
+
111
+ # --- 命令历史长期记忆(SQLite)---
112
+ def _start_ai(self, line: str, hooked: bool = False) -> None:
113
+ self.core._ai_ping_at = time.monotonic() # 僵尸回收基准:任务起点即有锚
114
+ # 任务起跑未走 hook 上报(外部触发链)→ _run_task 必有重注入在途:
115
+ # _shell_pad 的 60s 节流补注入对本次任务冗余(pad ack 不必等它)
116
+ self.core._reinject_pending = not hooked
117
+ self.core._ai_task = asyncio.create_task(self._ai_flow(line, hooked))
118
+ async def _ai_flow(self, line: str, hooked: bool = False) -> None:
119
+ try:
120
+ if line.startswith("/"):
121
+ if await self._handle_slash(line.strip()):
122
+ return
123
+ # 未知斜杠命令(/clearn 打错字、无 +x 的 /x.sh):不静默吞——
124
+ # 落回正常任务(与 CLI 语义一致:未知 / 交 LLM 判定),否则
125
+ # hook 蓝色重绘后用户零反馈(真机:以为终端卡死)
126
+ text = line.lstrip("?").strip() or line
127
+ await self._run_task(text, hooked)
128
+ except asyncio.CancelledError:
129
+ # 用户中止(Ctrl+C/⏹)语义发 interrupt(不是 error):前端按
130
+ # 「中止」收束——流式未渲染完毕的卡整卡移除,不留下半截卡
131
+ await self.core.emit_msg(ServerMsg(
132
+ type="event", event={"kind": "interrupt", "text": "已停止"}))
133
+ except Exception as e: # noqa: BLE001 - 兜底:前端靠 error 停表收卡片
134
+ await self.core.emit_msg(ServerMsg(
135
+ type="event", event={"kind": "error",
136
+ "text": f"处理失败:{type(e).__name__}: {e}"}))
137
+ finally:
138
+ _dbg("ai_flow finally")
139
+ self.core._ai_task = None
140
+ self.core._reinject_pending = False
141
+ try:
142
+ await self.core.exec._curtain_open() # 任务收尾:扣住的提示符放行
143
+ _dbg("curtain opened")
144
+ except Exception: # noqa: BLE001 - 收尾放行不能盖错误
145
+ pass
146
+ if self.core._ai_queue and not self.core._closed:
147
+ _q = self.core._ai_queue.pop(0)
148
+ self._start_ai(_q[0], _q[1] if len(_q) > 1 else False)
149
+ _dbg("ai_flow done")
150
+
151
+ # --- Agent/Shell 显示模式切换 ---
152
+ async def _on_ai_line(self, line: str, hooked: bool = False) -> None:
153
+ if self.core._ai_task is not None:
154
+ self.core._ai_queue.append((line, hooked)) # 忙:排队,结束后依序回放
155
+ return
156
+ self._start_ai(line, hooked)
157
+ async def _on_frontend_line(self, text: str, dirty: bool = False,
158
+ typed: bool = True) -> None:
159
+ """外部触发链:前端在 agent 模式拦下的整行。
160
+
161
+ 健康路径(hook 在位 + shell 空闲在提示符):补发回车——hook 的 AI 分支
162
+ 原地把默认色回显重画为「提示符+蓝色文本」(§5.6 Workbench 观感:用户
163
+ 输入蓝、工具命令青),再以 6337 AI 上报触发任务(自然语言不入 shell
164
+ 历史——↑/↓ 只召回历史命令;上报即
165
+ 启动,与英文自然语言同一条 proven 路径,无 \x03 残行)。hook 在位时
166
+ shell BUFFER 即真相:镜像 dirty(被无法镜像的转义/控制键清过,如历史
167
+ 召回)也走健康路径,只是不挂兜底——镜像文本与 hook 上报必不相等,
168
+ 挂了会在 1.2s 后按残镜像误起任务(真机「帮我把ll设置为"ls -al"的别名」
169
+ 行内方向键编辑后 AI 只收到光标后尾巴的根因之一)。
170
+ 「shell 空闲」由 _open_cmds 空判定:用户命令执行帧开着(前台占用,典型
171
+ su - 整段会话——其 C 帧不闭合还会让 _hook_ok 的时间戳比较永久误判在位)
172
+ 时 readline 不在读行,补发的 \r 只落进命令 stdin,hook 永不重绘/上报。
173
+
174
+ 降级路径(hook 标记绝迹 = su - 重置):任务先行——task_start→分析卡
175
+ 毫秒级出场(文字由卡的 text 承接,与直通修复同形态);清场(\x15 可见
176
+ 地收掉已回显半行 + ^C 把 shell 从可能的前台命令里要回提示符)放后台
177
+ 且持 _runner_lock,_run_task 里 if not hooked 的 _ensure_integrated
178
+ 等锁串行在其后完成重注入(su - 后下一条自然语言即恢复健康路径)。
179
+ 旧序先擦行再同步重注入 4-6s 才见蓝行/起任务——用户对着空行干等光标
180
+ 闪几秒(真机根因,已废)。前一任务在跑时只排队、不发任何终端字节
181
+ (擦行/^C/重注入都会与在跑任务的工具注入赛跑)。
182
+ typed=False(验证窗口 hold 输入的回放):字符当时被 hold、不在 shell
183
+ buffer——健康路径补发整行+回车,降级路径跳过清场(无半行回显可丢)。
184
+ 验证窗口(_pw_verifying)内整行与按键一并 hold(见 _begin_pw_verify),
185
+ 验证结束由 _flush_pw_hold 依序释放。"""
186
+ if self.core.session is None:
187
+ return
188
+ t = text.strip()
189
+ _dbg("submit text=", repr(t[:60]), "dirty=", dirty,
190
+ "exec_busy=", self.core._exec_future is not None,
191
+ "open_cmds=", len(self.core._open_cmds))
192
+ if self.core._pw_verifying:
193
+ # 验证窗口:抢跑的整行 hold 住——补发的 \r 会落进 ssh 输入队列
194
+ # 成一次空密码尝试(真机连排 Permission denied 根因)
195
+ if t and _impl._looks_ai(t):
196
+ self.core._pw_hold.append(("submit", t, dirty))
197
+ else:
198
+ self.core._pw_hold.append(("raw", b"\r"))
199
+ return
200
+ if not t or self.core._exec_future is not None or not _impl._looks_ai(t):
201
+ await self.core.session.send_raw(b"\r")
202
+ # 命令行回车同样要验活:承接它的若不是集成壳(嵌套 shell 被 TMOUT
203
+ # 收走后落回的裸壳),下一条自然语言的健康路径 \r 就会把整行当命令
204
+ # 执行。工具命令在途不武装——那段的标记由 agent 帧负责。
205
+ if t and self.core._exec_future is None and self.core.hook._hook_ok():
206
+ self.core.hook._arm_hook_watch()
207
+ return
208
+ if self.core._pw_prompt_seen:
209
+ # 密码提示符在前台(ssh/sudo 讨密码):绝不能拦成自然语言——
210
+ # 否则密码行被当任务挂起,\r 被 ^C/重注入链吞掉,登录失败。
211
+ # 字符已原样透传(echo 关闭),这里只补发回车提交密码;
212
+ # CJK/IME 整行路径的回车同时冻结手输捕获的候选(P1-3)。
213
+ self.core.auth._pw_capture_submit()
214
+ await self.core.session.send_raw(b"\r")
215
+ return
216
+ if self.core._interactive and self.core.hook._hook_ok() and (
217
+ not self.core._open_cmds or self.core._inner_ssh_prompt_live()):
218
+ await self.core.session.send_raw(b"\r" if typed else (t + "\r").encode())
219
+ # 兜底只保本次任务不悬空;看门狗保的是**下一条**:hook 其实不在位时
220
+ # 置 _hook_stale,后续提交不再重复把整行自然语言送进裸壳
221
+ self.core.hook._arm_hook_watch()
222
+ if not dirty:
223
+ self.core._hook_report_pending = t
224
+ self.core._fallback_task = asyncio.create_task(self._submit_fallback(t))
225
+ return
226
+ if not self.core._interactive:
227
+ # 纯终端直通(hook 注入失败/不可集成):清缓冲与起任务并行。
228
+ # 旧序先 \x15+^C+0.52s 再起任务——用户已回显的整行当场被擦掉,
229
+ # 文字只能等分析卡渲染 task_start 的 text 才重现,净空白 1-2s
230
+ # (真机「Enter 后空白然后才显示文字」根因)。task_start→分析卡
231
+ # 不依赖终端状态,先起;^C 引发的提示符重绘由幕帘扣住(_ai_task
232
+ # 已同步置位,prompt_start 关帘),任务收尾才放行,不会写进卡的
233
+ # 预留空白区。清缓冲只须赶在首个工具命令注入(模型思考秒级)
234
+ # 之前——直通工具走哨兵批(_agent_session 返回裸 session),
235
+ # 残留半行会拼进注入命令。
236
+ if typed:
237
+ if self.core._ai_task is not None:
238
+ # 前一任务在跑:清缓冲须同步完成再排队(后台跑会与
239
+ # 在跑任务的下一发工具注入赛跑)
240
+ await self._passthrough_reset(t)
241
+ else:
242
+ asyncio.create_task(self._passthrough_reset(t))
243
+ else:
244
+ # 回放行(验证窗口 hold 过):没有半行可擦,但蓝行仍要补画
245
+ await self.core.exec._repaint_line(t, echoed=False)
246
+ await self._on_ai_line(t)
247
+ return
248
+ # 降级路径(hook 标记绝迹 = su - 重置等):任务先行——task_start→分析卡
249
+ # 毫秒级出场,文字由卡的 text 承载(与直通修复同形态)。旧序先**可见地**
250
+ # 擦掉已回显半行,再同步重注入(探测 + 27 分片 ≈ 4-6s)成功才重发蓝行/
251
+ # 失败才起任务——用户对着空行干看光标闪几秒(真机「Enter 后光标闪几秒
252
+ # 才见字和卡」根因)。清场(\x15 收残行 + ^C 救回提示符)放后台且持
253
+ # _runner_lock:_run_task 里 if not hooked 的 _ensure_integrated 等锁
254
+ # 串行在其后——探测行必须敲在干净提示符上,且重注入与工具命令注入
255
+ # (InteractiveRunner 同锁)互斥,无赛跑窗口。
256
+ if self.core._ai_task is not None:
257
+ # 前一任务在跑:只排队,不发任何终端字节——此刻擦行/^C/重注入
258
+ # 都会与在跑任务的工具注入赛跑。回放行的重注入由 _run_task
259
+ # 既有逻辑(not hooked 分支)兜住
260
+ await self._on_ai_line(t)
261
+ return
262
+ if typed:
263
+ asyncio.create_task(self._degraded_reset(t))
264
+ else:
265
+ # 回放行(验证窗口 hold 过):没有半行可擦,但蓝行仍要补画
266
+ await self.core.exec._repaint_line(t, echoed=False)
267
+ await self._on_ai_line(t)
268
+ async def _degraded_reset(self, text: str | None = None) -> None:
269
+ """降级提交的后台清场:\x15 收掉已回显半行 + ^C 把 shell 从可能的
270
+ 前台命令里要回提示符(su - 重置是主因)。持 _runner_lock:与任务内
271
+ _ensure_integrated(探测行须敲在干净提示符上)和工具命令注入互斥。
272
+ 镜像 dirty 也照发 \x15——降级不再重发整行,不存在「保留残行等重发」
273
+ 的语义,宁可多擦(残半行会拼进工具命令);擦掉的画面由分析卡的 text
274
+ 承接(卡先于清理完成出现)。"""
275
+ if self.core.session is None:
276
+ return
277
+ try:
278
+ async with self.core._runner_lock:
279
+ # 清场全程开抑制窗:\x15 的擦行回显、^C 回显、bash 补画的
280
+ # 新提示符全吞(否则 ^C 与提示符先到、NL 原文后补画就叠在
281
+ # 它们上面 = 又一层错位)。窗末统一按 hook 观感补画蓝行。
282
+ self.core._suppress_live = True
283
+ self.core._suppressed_tail = ""
284
+ try:
285
+ _dbg("dg-reset: send \\x15")
286
+ await self.core.session.send_raw(b"\x15")
287
+ await asyncio.sleep(0.12)
288
+ self.core.exec._arm_intr_swallow() # bash 会在半行尾回显 "^C":吞掉这两字节
289
+ _dbg("dg-reset: send \\x03")
290
+ await self.core.session.send_raw(b"\x03")
291
+ # readline 的 SIGINT 恢复窗口(^C 回显 + 退格重画)会丢弃期间
292
+ # 到达的输入:紧贴 ^C 写分片会丢 chunk0 头部(真机实测:解码
293
+ # 脚本缺字节、注释行断成命令报 "对策: …: command not found";
294
+ # settle 0.4s 后消失)。^C 回显由 _strip_intr_echo 精确吞除
295
+ await asyncio.sleep(0.4) # settle 躲开 SIGINT 恢复窗口
296
+ _dbg("dg-reset: done")
297
+ finally:
298
+ self.core._suppress_live = False
299
+ await self.core.exec._repaint_line(text)
300
+ except Exception as e: # noqa: BLE001 - 清理失败不追责:任务已起跑
301
+ _dbg("dg-reset: exc", repr(e))
302
+ async def _passthrough_reset(self, text: str | None = None) -> None:
303
+ """直通模式的行缓冲后台清理:\x15 收掉已回显/残存半行 + ^C 把 shell
304
+ 从可能的前台命令里要回提示符,语义与降级路径的清场相同但不挡任务
305
+ 起跑。镜像 dirty 也照发 \x15——直通不重发整行,不存在「保留残行等
306
+ 重发」的语义,宁可多擦(残半行会拼进工具命令);擦掉的画面由分析卡
307
+ 的 text 承接(卡先于清理完成出现)。"""
308
+ if self.core.session is None:
309
+ return
310
+ try:
311
+ # 与降级路径同形:清场全程抑制,窗末补画蓝行(否则半行被擦掉后
312
+ # 用户自然语言在屏上完全不可见,只能等分析卡的 text 才重现)
313
+ self.core._suppress_live = True
314
+ self.core._suppressed_tail = ""
315
+ try:
316
+ _dbg("pt-reset: send \\x15")
317
+ await self.core.session.send_raw(b"\x15")
318
+ await asyncio.sleep(0.12)
319
+ self.core.exec._arm_intr_swallow() # bash 会在半行尾回显 "^C":吞掉这两字节
320
+ _dbg("pt-reset: send \\x03")
321
+ await self.core.session.send_raw(b"\x03")
322
+ await asyncio.sleep(0.4) # settle 躲开 SIGINT 恢复窗口(同降级路径)
323
+ _dbg("pt-reset: done")
324
+ finally:
325
+ self.core._suppress_live = False
326
+ await self.core.exec._repaint_line(text)
327
+ except Exception as e: # noqa: BLE001 - 清理失败不追责:任务已起跑
328
+ _dbg("pt-reset: exc", repr(e))
329
+
330
+ # --- hook 集成/验活过渡委托(实现已拆 core/hook_gate.py;src 内部
331
+ # 已直呼 self.core.hook.*,委托仅供 tests/ 与 core 子组件旧名过渡,Task 9 随
332
+ # _core_impl 删除)---
333
+ async def _maybe_rescue(self, line: str, ec) -> None:
334
+ """用户命令非零退出 → 挂一张救援卡(一键交 AI 判定原意/修复)。
335
+
336
+ 分类启发式判 CMD 就直接执行(零延迟、对真命令零误伤),失败即信号
337
+ (market 先例:Copilot 终端建议 / JetBrains 内联修复 / thefuck)。
338
+ 手动卡而非自动起任务:grep 无匹配(exit 1)、测试挂掉这类「正常失败」
339
+ 不能被劫持进 AI。130/143 = 用户自己 Ctrl+C/kill,不是失败。
340
+ """
341
+ if (not self.core._interactive or not isinstance(ec, int) or ec == 0
342
+ or ec in (130, 143)
343
+ or self.core._ai_task is not None # AI 在跑:用户侧命令不掺和
344
+ or self.core._rescue_open # 已有待决策救援卡:不刷屏
345
+ or self.core._exec_future is not None # runner 占用(理论不可达,双保险)
346
+ or self.core._suppress_live # 注入窗口内的杂行不是用户命令
347
+ or not line.strip()):
348
+ return
349
+ self.core._rescue_open = True
350
+ out = bytes(self.core._user_out[-2048:]).decode("utf-8", "replace").strip()
351
+ self.core._rescue_ctx = (line, ec, out)
352
+ await self.core.emit_msg(ServerMsg(type="event", event={
353
+ "kind": "rescue", "line": line, "ec": ec, "output": out}))
354
+ async def _on_rescue_decision(self, accept: bool) -> None:
355
+ """救援卡决策:accept → 以失败上下文起 AI 任务(自然语言优先解读)。"""
356
+ if not self.core._rescue_open:
357
+ return
358
+ self.core._rescue_open = False
359
+ ctx, self.core._rescue_ctx = self.core._rescue_ctx, None
360
+ if not accept or ctx is None:
361
+ return
362
+ line, ec, out = ctx
363
+ prompt = (
364
+ f"我在终端里输入了「{line}」并回车,它被当作命令执行但失败了"
365
+ f"(退出码 {ec})。\n"
366
+ + (f"命令输出:\n{out}\n" if out else "(无输出)\n")
367
+ + "\n请判断我的真实意图:如果我想输入的是自然语言请求,直接完成它;"
368
+ "如果是命令写错了(拼写/参数/路径),修正后用 execute 工具执行并"
369
+ "给我结果。")
370
+ # hooked=True:这条 CMD 上报+C/D 帧刚走通,hook 在位是既成事实,
371
+ # 重注入的吞窗口只会白吃在途字节
372
+ await self._on_ai_line(prompt, hooked=True)
373
+
374
+ # --- AI 行(自然语言,经 hook AI 报告进入)---
375
+ async def _submit_fallback(self, t: str) -> None:
376
+ """健康路径兜底:补发 \\r 后 hook AI 上报迟迟未到(重置误判/路由丢报)
377
+ → 中断残行改走外部启动,保证任务不悬空。迟到的同文上报由
378
+ _suppress_ai_report 吞掉,不会双跑。"""
379
+ await asyncio.sleep(_impl.SUBMIT_FALLBACK_DELAY)
380
+ if self.core._closed or self.core._hook_report_pending != t:
381
+ return
382
+ self.core._hook_report_pending = None
383
+ self.core._suppress_ai_report = t
384
+ if self.core.session is not None:
385
+ self.core.exec._arm_intr_swallow() # 同降级路径:吞 ^C 回显残迹
386
+ await self.core.session.send_raw(b"\x03")
387
+ # 同降级路径:settle 躲开 SIGINT 恢复窗口的输入丢弃;不吞 ^C 回显
388
+ # (吞掉会使前端光标失步,重发回显拼在旧行后)
389
+ await asyncio.sleep(0.4)
390
+ # ^C 已可见地收掉残行并补画了新提示符:行上只剩提示符,按
391
+ # echoed=False 接上蓝行即可(与 hook 的 __ot_repaint 同观感)
392
+ await self.core.exec._repaint_line(t, echoed=False)
393
+ await self._on_ai_line(t)
394
+ async def _handle_slash(self, line: str) -> bool:
395
+ """斜杠命令处理;返回 True 表示已消费(经 hook AI 报告进入)。"""
396
+ if line == "/help":
397
+ await self.core.emit_msg(ServerMsg(
398
+ type="status",
399
+ text="/target 切换主机 /clear 清屏新任务 /model 查看模型"
400
+ " /system 手动设系统方言 /exit 关闭会话"))
401
+ return True
402
+ if line == "/model":
403
+ await self.core.emit_msg(ServerMsg(
404
+ type="status",
405
+ text=f"{self.core.cfg.model.model} @ {self.core.cfg.model.base_url}"))
406
+ return True
407
+ if line == "/exit":
408
+ await self.core.close()
409
+ return True
410
+ if line == "/clear":
411
+ self.core._reset_context()
412
+ await self.core.emit_msg(ServerMsg(
413
+ type="status", text="已开启新任务。"))
414
+ # 通知前端清掉本会话的全部 AI 卡片
415
+ await self.core.emit_msg(ServerMsg(
416
+ type="event", event={"kind": "session_cleared"}))
417
+ # 清屏交原生:Ctrl+L 让 shell 清屏重画提示符(历史进 scrollback)
418
+ if self.core.session is not None and self.core._interactive:
419
+ await self.core.session.send_raw(b"\x0c")
420
+ return True
421
+ if line.startswith("/target"):
422
+ await self.core._switch_target(line)
423
+ return True
424
+ if line.startswith("/system"):
425
+ await self.core._manual_system(line)
426
+ return True
427
+ return False
428
+ def _flush_seg(self, failed: bool = False) -> None:
429
+ """paced 段冲刷:一次性 ai_result。双空 no-op(幂等——limit/error 会
430
+ 被 _live 与 _handle_events 双路过,第二发自然空转)。"""
431
+ seg = getattr(self, "_seg", None)
432
+ if seg is None:
433
+ return
434
+ text, think = seg.take()
435
+ if not text and not think:
436
+ return
437
+ self.core._emit_nowait(ServerMsg(
438
+ type="event",
439
+ event={"kind": "ai_result", "text": text, "think": think,
440
+ "failed": failed}))
441
+
442
+ def _seg_beat(self, force: bool = False) -> None:
443
+ """徽标心跳(1s 节流):累计字数 + token 计数(runner 逐调用更新)。"""
444
+ seg = getattr(self, "_seg", None)
445
+ if seg is None or not self.core.paced_display:
446
+ return
447
+ now = time.monotonic()
448
+ if not force and now - seg.beat < 1.0:
449
+ return
450
+ seg.beat = now
451
+ r = seg.runner
452
+ self.core._emit_nowait(ServerMsg(
453
+ type="event", event={
454
+ "kind": "progress", "chars": seg.chars,
455
+ "tokens_in": ((r.input_tokens or r.est_input_tokens) if r else 0),
456
+ "tokens_out": ((r.output_tokens or r.est_output_tokens) if r else 0)}))
457
+
458
+ async def _run_task(self, text: str, hooked: bool = False) -> None:
459
+ self.core.transcript.append("user", text=text)
460
+ self.core._task_cmd_started = False
461
+ self.core._tool_idx_map = {} # 跨任务残留的局部 index 映射作废(序号不回绕)
462
+ # paced 段缓冲(Web 一次性落格用;CLI paced_display=False 不进缓冲)。
463
+ # 任务级 finally 清空:中断取消同样收口,残段不得泄进下个排队任务
464
+ self._seg = _SegBuffer()
465
+ # task_start 先于 AI 栈导入/重注入:首任务 deepagents/langchain 秒级
466
+ # 导入与换壳重注入不得挡在分析卡出场前(真机「输完自然语言等几秒
467
+ # 才出卡」根因);构建失败照发 error 事件,卡片原地定格为已中止
468
+ _dbg("task_start emit", repr(text[:40]))
469
+ await self.core.emit_msg(ServerMsg(
470
+ type="event", event={"kind": "task_start", "text": text}))
471
+ try:
472
+ # 首任务 AI 栈导入(deepagents/langchain 秒级)是**同步阻塞**——
473
+ # 事件循环整个冻住:实测 sleep(0.12) 被拖成 8.9s,task_start 虽已
474
+ # emit 却同样迟到 9s 才达前端(分析卡/思考徽标一起迟到)。放线程
475
+ # 池跑构建,循环不被挡:task_start 毫秒级达前端,卡即出
476
+ await asyncio.to_thread(self._ensure_agent)
477
+ except Exception as e: # noqa: BLE001 - 构建失败发 error 事件让前端收尾
478
+ await self.core.emit_msg(ServerMsg(
479
+ type="event", event={"kind": "error",
480
+ "text": f"AI 初始化失败:{type(e).__name__}: {e}"}))
481
+ return
482
+ # hook 上报触发的任务不重注入:上报即在位凭据,而注入吞窗口会吃掉
483
+ # 在途蓝色重绘字节(真机「提交行消失」);外部启动(无上报)才需要
484
+ if not hooked:
485
+ await self.core.hook._ensure_integrated() # su - 等 login shell 重置后先重注入
486
+ self.core._reinject_pending = False # 在途重注入已落定:pad 恢复 60s 兜底
487
+ presenter = _impl.CorePresenter(self)
488
+ self.core.backend.on_start = presenter.on_start
489
+ self.core.backend.on_output = None # 输出由 exec 状态机直写主 xterm
490
+ self.core.backend.on_finish = presenter.on_finish
491
+ self.core.backend.on_boundary = self.core._boundary_gate
492
+
493
+ def _live(ev) -> None:
494
+ self.core._ai_ping_at = time.monotonic() # 事件产出 = 任务在世,僵尸判据刷新
495
+ if ev.kind == "token":
496
+ if self.core.paced_display:
497
+ # 段攒流:不逐 token 下发(浏览器零压力),段边界一次性落格
498
+ self._seg.add(ev.text, "")
499
+ self._seg_beat()
500
+ else:
501
+ self.core._emit_nowait(ServerMsg(
502
+ type="event",
503
+ event={"kind": "ai_token", "text": ev.text}))
504
+ elif ev.kind == "think":
505
+ if self.core.paced_display:
506
+ self._seg.add("", ev.text)
507
+ self._seg_beat()
508
+ else:
509
+ self.core._emit_nowait(ServerMsg(
510
+ type="event",
511
+ event={"kind": "ai_think", "text": ev.text}))
512
+ elif ev.kind == "denied":
513
+ self._flush_seg()
514
+ self.core._emit_nowait(ServerMsg(
515
+ type="event", event={"kind": "denied", "text": ev.text}))
516
+ elif ev.kind == "tool_start":
517
+ # 段边界:先落格(轮内 token 全部先于 tool_start),再宣告工具
518
+ self._flush_seg()
519
+ # 阶段边界先定格当前分析卡(工具卡挂流底,之后模型输出开新卡,
520
+ # 叙事节奏与分析卡→命令→分析卡一致);再挂工具调用小卡。
521
+ # 工具执行期无 PTY 输出,不需要 boundary 门闩。execute 也发卡
522
+ #(远程目标上模型几乎只走 execute),但不再补 ai_collapse:
523
+ # 命令定格走 boundary 门闩既有路径,双发会把 CLI 的执行面板打两遍
524
+ self.core._tool_card_seq += 1
525
+ self.core._tool_idx_map[ev.index] = self.core._tool_card_seq
526
+ if ev.name != "execute":
527
+ self.core._emit_nowait(ServerMsg(
528
+ type="event", event={"kind": "ai_collapse"}))
529
+ self.core._emit_nowait(ServerMsg(
530
+ type="event",
531
+ event={"kind": "ai_tool", "phase": "start",
532
+ "id": self.core._tool_card_seq,
533
+ "name": ev.name, "args": ev.text,
534
+ # 命令卡出生预判(谓词单源 Policy):needs_approval
535
+ # 为真前端直接摆「等待审批」按钮簇;容忍审批不至
536
+ #(allowed 只增,中途命中直通)——tool_end 直通 ✓ 合法迁移
537
+ "command": ev.command,
538
+ "needs_approval": bool(ev.command) and
539
+ self.core.policy.needs_approval(
540
+ ev.command, self.core.allowed),
541
+ "reasons": self.core.policy.classify(
542
+ ev.command).reasons if ev.command else [],
543
+ "risk": self.core.policy.risk(ev.command)
544
+ if ev.command else ""}))
545
+ elif ev.kind == "tool_end":
546
+ gid = self.core._tool_idx_map.pop(ev.index, None)
547
+ if gid is not None:
548
+ self.core._emit_nowait(ServerMsg(
549
+ type="event",
550
+ event={"kind": "ai_tool", "phase": "end", "id": gid,
551
+ "failed": ev.failed}))
552
+ elif ev.kind == "limit":
553
+ self._flush_seg(failed=True)
554
+ self.core._emit_nowait(ServerMsg(
555
+ type="event", event={"kind": "limit", "text": ev.text}))
556
+ elif ev.kind == "error":
557
+ self._flush_seg(failed=True)
558
+ self.core._emit_nowait(ServerMsg(
559
+ type="event", event={"kind": "error", "text": ev.text}))
560
+
561
+ # 调用点取 TaskRunner:预热已导入 agent 模块(sys.modules 命中零
562
+ # 成本);测试桩打 amod.TaskRunner,缓存进 core 会跨测试串味
563
+ from ..agent.runner import TaskRunner
564
+ runner = TaskRunner(
565
+ self.core.agent, f"{self.core.tab_id}#c{self.core._ctx_epoch}",
566
+ max_tool_turns=self.core.cfg.shell.max_tool_turns,
567
+ on_event=_live,
568
+ )
569
+ self._seg.runner = runner # 心跳读 token 计数
570
+ try:
571
+ events = await runner.run(text)
572
+ await self._handle_events(runner, events)
573
+ except Exception as e: # noqa: BLE001 - 任务异常发 error 事件让前端收尾
574
+ # 不能只靠 _run 兜底的 status——前端靠 error/final 停计时、收卡片
575
+ self._flush_seg(failed=True) # 残段落格标失败(非中断留痕)
576
+ await self.core.emit_msg(ServerMsg(
577
+ type="event", event={"kind": "error",
578
+ "text": f"任务失败:{type(e).__name__}: {e}"}))
579
+ return
580
+ finally:
581
+ _dbg("run_task finally")
582
+ self._seg = _SegBuffer() # 清缓冲:中断取消同口径,残段不泄进下个任务
583
+ self.core.backend.on_output = None
584
+ self.core.backend.on_start = None
585
+ self.core.backend.on_finish = None
586
+ self.core.backend.on_boundary = None
587
+ # 收尾保证:final 空文(纯工具任务)也要关徽标/停计时/扫尾——
588
+ # 旧实现该路径什么都不发,徽标永转。CLI 忽略未知事件,Web 幂等收尾
589
+ _dbg("task_end emit")
590
+ await self.core.emit_msg(ServerMsg(
591
+ type="event", event={"kind": "task_end"}))
592
+ _dbg("task_end emitted")
593
+ # 累计会话 token 用量并上报前端状态栏。口径:真实 usage_metadata
594
+ # 优先;网关不回传时用 tiktoken 估算(token_est),前端以 ≈ 标注
595
+ task_in = runner.input_tokens or runner.est_input_tokens
596
+ task_out = runner.output_tokens or runner.est_output_tokens
597
+ if task_in or task_out:
598
+ self.core._tokens_in += task_in
599
+ self.core._tokens_out += task_out
600
+ await self.core.emit_msg(ServerMsg(
601
+ type="usage", tokens_in=self.core._tokens_in,
602
+ tokens_out=self.core._tokens_out,
603
+ estimated=not (runner.input_tokens or runner.output_tokens)))
604
+ async def _change_model(self, model_id: str) -> None:
605
+ """运行时切换模型:记录新 ModelConfig;agent 已建则重建(保留 profile/session)。"""
606
+ if not model_id or model_id == self.core._model.model:
607
+ return
608
+ self.core._model = replace(self.core._model, model=model_id)
609
+ if self.core.agent is None:
610
+ # 尚未构建 agent(还没发起过 AI 任务)——只记录模型,_ensure_agent 会用它
611
+ await self.core.emit_msg(ServerMsg(
612
+ type="status", text=f"已切换模型:{model_id}"))
613
+ return
614
+ try:
615
+ self._build_agent_now()
616
+ await self.core.emit_msg(ServerMsg(
617
+ type="status", text=f"已切换模型:{model_id}"))
618
+ except Exception as e: # noqa: BLE001 - 切换失败提示,不影响会话
619
+ await self.core.emit_msg(ServerMsg(
620
+ type="status", text=f"切换模型失败:{type(e).__name__}: {e}"))
621
+ async def _handle_events(self, runner, events) -> None:
622
+ while True:
623
+ for ev in events:
624
+ if ev.kind == "tool_call":
625
+ self.core.transcript.append("tool_call", command=ev.command)
626
+ elif ev.kind == "tool_start" and ev.name != "execute":
627
+ # execute 已由 tool_call 记账,不重复落 transcript
628
+ self.core.transcript.append(
629
+ "tool_call",
630
+ command=ev.name + (f" {ev.text}" if ev.text else ""))
631
+ elif ev.kind == "denied":
632
+ self.core.transcript.append("denied", text=ev.text)
633
+ elif ev.kind in ("limit", "error"):
634
+ return
635
+ elif ev.kind == "final":
636
+ self.core.transcript.append("final", text=ev.text)
637
+ # 吞正文留思考:缓冲 text 就是 final 答案本体(_final_text
638
+ # 重读同一 AIMessage),再冲刷就是分析段+总结卡同文烧两遍。
639
+ # 思考文本不丢:非空冲「仅思考段」。
640
+ seg = getattr(self, "_seg", None)
641
+ if seg is not None:
642
+ think = seg.think
643
+ seg.text = ""
644
+ seg.think = ""
645
+ if think.strip():
646
+ self.core._emit_nowait(ServerMsg(
647
+ type="event", event={
648
+ "kind": "ai_result", "text": "",
649
+ "think": think, "failed": False}))
650
+ await self.core.emit_msg(ServerMsg(
651
+ type="event",
652
+ event={"kind": "final", "text": ev.text}))
653
+ # final 总结 markdown → 前端挂总结装饰卡(§5.3)
654
+ await self.core.emit_msg(ServerMsg(
655
+ type="event",
656
+ event={"kind": "ai_card", "markdown": ev.text}))
657
+ if runner.interrupt_payload is None:
658
+ return
659
+ requests = runner.interrupt_payload["action_requests"]
660
+ if len(requests) > 1:
661
+ await self.core.emit_msg(ServerMsg(
662
+ type="status", text=f"模型一轮发起 {len(requests)} 个命令,请逐个确认"))
663
+ decisions: list[dict] = []
664
+ for req in requests:
665
+ command = req.get("args", {}).get("command", "")
666
+ name = req.get("name") or "execute"
667
+ # 认领工具卡(ActionRequest 无 tool_call id):按 (name, command)
668
+ # 有序消歧,审批事件带卡 id,按钮摆进同一张命令卡(二合一)
669
+ seq = runner.claim_seq(name, command)
670
+ card_id = (self.core._tool_idx_map.get(seq, 0)
671
+ if seq is not None else 0)
672
+ description = req.get("description") or "高危命令需要审批"
673
+ self.core.transcript.append("approval", command=command)
674
+ decision = await self.core.ask_approval(
675
+ command, description, self.core._target_host,
676
+ self.core._approval_risk(command), id=card_id)
677
+ if isinstance(decision, dict) and decision.get("allow"):
678
+ # 「始终允许」入会话免审集:同令后续直通(_when 同源谓词)。
679
+ # 旧实现从不读 allow 字段——按钮等于「执行」(假功能修复)。
680
+ self.core.allowed.add(command)
681
+ decision = {k: v for k, v in decision.items() if k != "allow"}
682
+ decisions.append(decision)
683
+ events = await runner.resume(decisions)
684
+ async def _switch_display(self, mode: str) -> None:
685
+ _dbg("switch_display", mode, "cur=", self.core._display,
686
+ "stack=", [(f["ctx"], f["inst"]) for f in self.core._exec_stack],
687
+ "ai=", self.core._ai_task is not None)
688
+ if mode not in ("ssh", "agent"):
689
+ return
690
+ if not self.core._interactive:
691
+ self.core._display = mode
692
+ return
693
+ if mode == self.core._display:
694
+ return
695
+ if mode == "ssh":
696
+ # 先停 AI 并等其工具执行收尾:开关行与 runner 的 \x15/注入
697
+ # 序列交错会污染 PTY 输入缓冲(开关不生效)
698
+ t = self.core._ai_task
699
+ await self._maybe_cancel_ai()
700
+ if t is not None:
701
+ try:
702
+ await asyncio.wait_for(asyncio.shield(t), 3)
703
+ except Exception: # noqa: BLE001 - 取消/超时都继续
704
+ pass
705
+ if not await self._wait_exec_idle(5):
706
+ await self.core.emit_msg(ServerMsg(
707
+ type="status",
708
+ text="AI 命令执行中,退出后再切 Shell 模式。"))
709
+ return
710
+ # su - / sudo su - / 嵌套 shell 会把 hook 集成整个丢掉:此时
711
+ # toggle 的 __ot_exec__ 变普通命令执行(command not found),
712
+ # 确认标记永不回来 → 下面死等 5s 才清屏(真机「切 Shell 没反应」)。
713
+ # 先补注入(幂等,healthy shell 只重定义同名函数)再开关注入。
714
+ await self.core.hook._ensure_integrated()
715
+ self.core._ev_exec_end.clear()
716
+ self.core._suppress_live = True # 开关注入行的回显吞掉(§5.5)
717
+ try:
718
+ await self.core.session.send_raw(toggle_line(self.core._shell_kind, False))
719
+ try:
720
+ await asyncio.wait_for(self.core._ev_exec_end.wait(), 5)
721
+ except asyncio.TimeoutError:
722
+ pass # 开关未确认也切显示(hook off 失败时字节仍原样透传)
723
+ finally:
724
+ self.core._suppress_live = False
725
+ self.core._suppressed_echo = False
726
+ self.core._suppressed_tail = ""
727
+ self.core._display = "ssh"
728
+ await self.core.session.send_raw(b"\x0c") # 清屏重画,纯透传从干净屏开始
729
+ else:
730
+ self.core._display = "agent"
731
+ self.core._router.reset()
732
+ self.core._ev_prompt.clear()
733
+ # Shell 模式期间 pump 纯透传不产生标记事件,时间戳已失效;重置为
734
+ # 保守态(标记绝迹)——首个自然语言提交走降级路径重注入确认 hook
735
+ self.core._last_mark_at = 0.0
736
+ self.core._hook_stale = False
737
+ self.core.hook._cancel_hook_watch()
738
+ self.core._last_live_at = time.monotonic()
739
+ self.core._suppress_live = True
740
+ try:
741
+ await self.core.session.send_raw(toggle_line(self.core._shell_kind, True))
742
+ try:
743
+ await asyncio.wait_for(self.core._ev_prompt.wait(), 5)
744
+ except asyncio.TimeoutError:
745
+ pass
746
+ finally:
747
+ self.core._suppress_live = False
748
+ self.core._suppressed_echo = False
749
+ self.core._suppressed_tail = ""
750
+ await self.core.session.send_raw(b"\x0c") # hook 已回 agent:清屏重画
751
+ async def _maybe_cancel_ai(self) -> None:
752
+ """Ctrl+C/⏹:AI 任务在跑且 shell 空闲(无打开的执行)→ 取消任务。
753
+ 有执行打开时不取消——\\x03 会直达 PTY 中断正在跑的命令。"""
754
+ if self._ai_cancellable():
755
+ self.core._ai_queue.clear()
756
+ self.core._ai_task.cancel()
757
+ def _ai_cancellable(self) -> bool:
758
+ return self.core._ai_task is not None and not self.core._exec_stack
759
+ async def _wait_exec_idle(self, timeout: float) -> bool:
760
+ """等待 shell 空闲(无打开的执行上下文)。"""
761
+ loop = asyncio.get_running_loop()
762
+ deadline = loop.time() + timeout
763
+ while self.core._exec_stack:
764
+ if loop.time() >= deadline:
765
+ return False
766
+ await asyncio.sleep(0.05)
767
+ return True
768
+
769
+ # --- 组件公开接口绑定 ---
770
+ start_ai = _start_ai
771
+ on_ai_line = _on_ai_line
772
+ on_frontend_line = _on_frontend_line
773
+ handle_slash = _handle_slash
774
+ run_task = _run_task
775
+ maybe_rescue = _maybe_rescue
776
+ switch_display = _switch_display
777
+ change_model = _change_model
778
+
779
+ def __getattr__(self, name):
780
+ """状态物理存 core(P3):未知属性转发 core(_ai_task/_display 等)。"""
781
+ return getattr(self.core, name)