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,1298 @@
1
+ """Agent 模式交互式 shell 集成:注入脚本 + OSC 标记流解析器。
2
+
3
+ 单管线真终端(设计文档 §5.5/§5.6):前端唯一渲染管线是全屏 xterm 的 PTY
4
+ 字节直写,shell 集成只负责两件事——用带内标记向 worker 记账(历史库/
5
+ 退出码/AI 上下文),以及在提交时做输入回显美化(青色重绘 + 提示符配色)。
6
+ 终端能力(Tab 补全、历史、全屏程序、嵌套 shell、clear)全部归原生。
7
+
8
+ 两组带内标记:
9
+
10
+ 1. OSC 133 语义标记(带实例号 i,区分父/嵌套 shell):
11
+ - ``ESC]133;A;<i>BEL`` 提示符开始
12
+ - ``ESC]133;B;<i>BEL`` 提示符结束(用户输入开始)
13
+ - ``ESC]133;C;<i>BEL`` 命令开始执行(Enter hook 放行前发出)
14
+ - ``ESC]133;D;<ec>;<i>;<cwd>BEL`` 执行结束(PROMPT_COMMAND / prompt() 发出)
15
+ 2. 私有行报告 ``ESC]6337;<i>;<KIND>;<line>BEL``(KIND ∈ CMD/AI/EXEC):
16
+ Enter hook 对每个非空行发出。CMD=原样原生执行;AI=自然语言被拦截(清空
17
+ 缓冲、蓝色重绘,不写入 shell 历史——↑/↓ 只召回历史命令:用户命令 + AI
18
+ 工具命令);EXEC=worker 注入通道(``__ot_exec__ `` 前缀,解码后入史)。
19
+
20
+ Enter hook 分类顺序:``__ot_exec__ `` 前缀→注入执行(任何模式下都处理);
21
+ ``__ot_off=1``→纯透传(Shell 模式=普通终端);空行→原生;``?`` 前缀→AI;
22
+ ``!`` 前缀→剥掉后强制执行(CMD);首词能被 command -v / Get-Command 解析→
23
+ CMD;否则→AI。
24
+
25
+ StreamRouter 把 PTY 字节流按标记切成两类数据:live(INPUT 阶段:提示符+输入
26
+ 回显)与 exec(EXEC 阶段:命令输出),worker 除需吞除/记账的字节外原样转发
27
+ 给前端主 xterm(唯一显示管线);跨 chunk 的半截转义序列会被挂起等待后续
28
+ 字节(在字节层统一解决)。
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import base64
34
+ import codecs
35
+ import re
36
+ import shlex
37
+
38
+ EXEC_PREFIX = "__ot_exec__ "
39
+ PROBE_TAG = "__OTPROBE__"
40
+ #: 行报告 KIND
41
+ KIND_CMD = "CMD"
42
+ KIND_AI = "AI"
43
+ KIND_EXEC = "EXEC"
44
+
45
+ _ANSI_RE = re.compile(
46
+ r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC … BEL / ST
47
+ r"|\x1b\[[0-9;:?]*[ -/]*[@-~]" # CSI
48
+ r"|\x1b[@-Z\\-_]" # 其余双字节转义
49
+ )
50
+
51
+
52
+ def strip_ansi(text: str) -> str:
53
+ """去掉 ANSI/OSC 转义序列,得到纯文本(提示符捕获、DOM 展示用)。"""
54
+ return _ANSI_RE.sub("", text)
55
+
56
+
57
+ def _to_int(s: str, default):
58
+ try:
59
+ return int(s)
60
+ except (TypeError, ValueError):
61
+ return default
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # 注入脚本(bash / zsh / PowerShell 5.1+)
66
+ # ---------------------------------------------------------------------------
67
+ # 交付方式:脚本 base64 成单行,posix 用 eval "$(echo …|base64 -d)"、
68
+ # PS 用 iex(FromBase64String(…))——避免引号/$/换行在 readline 层的任何转义问题。
69
+
70
+ _BASH_SCRIPT = r'''
71
+ __ot_i=@@INST@@
72
+ __ot_off=0
73
+ # 单管线(§5.6):历史自然累积进唯一 xterm 的 scrollback,不再每命令清屏
74
+ __ot_clear=0
75
+ __ot_last_ec=0
76
+ # 宏式 Enter 的收尾状态(见 __ot_prompt_cmd 与文件末 bind):__ot_clr 由本轮
77
+ # 提交回调置位、__ot_p0 是上一轮提示符可见串。重注入是 eval 新脚本,普通赋值
78
+ # 会把「先置旗、后 eval」的这轮收尾擦行清掉——注入链每片净多占一行(抑制窗内
79
+ # 前端看不见,PTY 与前端就此错行),故只在未定义时兜底赋空。
80
+ : "${__ot_clr:=}"
81
+ : "${__ot_p0:=}"
82
+ : "${__ot_w0:=}"
83
+ export PAGER=cat GIT_PAGER=cat
84
+ # su - 等 login shell 会继承 readline 裸模式留下的 termios(-echo -icanon -isig:
85
+ # 按键不可见、Ctrl+C 失效,真机「su - 后黑屏」根因)——bash 不在 bind -x 回调
86
+ # 前后恢复 termios,而命令又在回调内 eval 自执行,su/ssh 等子 shell 原样继承裸
87
+ # 模式且自身行编辑救不回来。两段对策:注入时检出回显丢失先 stty sane 自愈当前
88
+ # tty 并快照正常态;回调内每次 eval 子命令前按快照恢复(子 shell 从正常 tty 起步)。
89
+ case " $(stty -a 2>/dev/null) " in *" -echo "*) stty sane 2>/dev/null ;; esac
90
+ __ot_tty_save=$(stty -g 2>/dev/null)
91
+ # 半行保存/恢复(真机「任务期打字只剩尾巴」根因):agent 命令注入前的 \x15
92
+ # 原生 unix-line-discard 会把用户正在敲的半行杀进 kill ring 且不恢复——任务
93
+ # 跑着时打的字被后续注入的每个 \x15 无声吞掉,只剩最后一次注入之后敲的尾巴。
94
+ # 重绑 ^U/^Y 为自有原语:^U 把半行存 __ot_saved 再清行(不经 kill ring),
95
+ # ^Y 仅在 __ot_saved 非空时把存档接回行首、光标落在拼接点。空行零副作用
96
+ # (原生 \x15\x19 对在空行会把 kill ring 旧内容复活——bash PTY 实测得
97
+ # "helloecho MARK")。注入方(InteractiveRunner/_shell_pad)保证每 \x15 配
98
+ # 一 \x19;hook 不在位(su - 重置)时 worker 不发 \x19,退回原生丢弃语义。
99
+ __ot_saved=
100
+ __ot_kill_line() {
101
+ __ot_saved="$READLINE_LINE"
102
+ READLINE_LINE=""; READLINE_POINT=0
103
+ }
104
+ __ot_yank_line() {
105
+ if [ -n "$__ot_saved" ]; then
106
+ READLINE_LINE="$__ot_saved$READLINE_LINE"
107
+ READLINE_POINT=${#__ot_saved}
108
+ __ot_saved=
109
+ fi
110
+ }
111
+ bind -x '"\C-u": __ot_kill_line'
112
+ bind -x '"\C-y": __ot_yank_line'
113
+ __ot_prompt_cmd() {
114
+ local ec=$?
115
+ printf '\033]133;D;%s;%s;%s\007' "$ec" "$__ot_i" "$PWD"
116
+ # 宏式 Enter 收尾(见文件末 bind):hook 回调期间 readline() 不返回、PS1 不重
117
+ # 展开,回调一返回 readline 必先拿「上次展开的陈旧 rl_prompt」重画一次提示符
118
+ # (真机 2026-10-09 字节级实测:cd /etc 后先出 ~ 再出 /etc 两截),宏尾
119
+ # accept-line 的 CR+LF 又把光标推到其下一行,bash 才重展开 PS1 画新提示符。
120
+ # 故按上一轮提示符可见宽度算折行数 k,上移 k 行逐行擦净,新提示符原位落下
121
+ # (不留空行)。只在经 hook 的周期擦(__ot_clr 由提交回调置位):裸周期没有
122
+ # 陈旧重绘,且保留标准 bash 语义。擦的恒是纯提示符行——输出不以换行收尾的
123
+ # 情形由 __ot_run 末尾的补白把光标推到新行,真实输出不受牵连。
124
+ if [ -n "${__ot_clr:-}" ]; then
125
+ __ot_clr=
126
+ # 擦行必须紧贴陈旧重绘发出,中间不能有 fork:core 的重注入抑制窗在
127
+ # _ev_prompt(陈旧重绘的 A 标记)之后只多留 80ms,若擦行被一次 wc/id fork
128
+ # 拖到窗外,就成「陈旧行被吞、擦行却放行」——前端白擦掉一行真实输出。
129
+ # 故宽度在下方建 __ot_p0 时算好缓存进 __ot_w0(提示符串没变则连算都不算,
130
+ # 同目录连发命令零 fork),这里只剩算术 + printf 内建。
131
+ local cols=${COLUMNS:-80} k i
132
+ k=$(( (${__ot_w0:-0} + cols - 1) / cols ))
133
+ (( k < 1 )) && k=1
134
+ printf '\033[%dA\r' "$k"
135
+ for ((i=1; i<k; i++)); do printf '\033[2K\033[1B'; done
136
+ printf '\033[2K'
137
+ (( k > 1 )) && printf '\033[%dA\r' "$((k-1))"
138
+ fi
139
+ if [ "$__ot_off" != 1 ] && [ "$__ot_clear" = 1 ]; then printf '\033[H\033[2J'; fi
140
+ if [ -n "$__ot_pc_orig" ]; then eval "$__ot_pc_orig"; fi
141
+ # 记下 bash 即将展开并缓存进 rl_prompt 的提示符可见串(与 PS1 的
142
+ # [\u@\H \w]\$ 同形):下一轮陈旧重绘擦行按它算折行数。放在
143
+ # __ot_pc_orig 之后,用户自己的 PROMPT_COMMAND 改了 PWD 也记得准。
144
+ local d="${PWD/#$HOME/\~}" c='#' p0 ch by
145
+ [ "$EUID" != 0 ] && c='$'
146
+ p0="[${USER:-$(id -un)}@$HOSTNAME $d]$c "
147
+ if [ "$p0" != "${__ot_p0:-}" ]; then
148
+ ch=${#p0}
149
+ by=$(printf %s "$p0" | wc -c | tr -d ' ')
150
+ # 显示宽 = 字符数 + UTF-8 额外字节/2(与 __ot_repaint 同式,CJK 双宽恰准)
151
+ __ot_w0=$(( ch + (by - ch) / 2 ))
152
+ __ot_p0="$p0"
153
+ fi
154
+ return 0
155
+ }
156
+ if [ -z "$__ot_pc_mine" ]; then __ot_pc_orig="$PROMPT_COMMAND"; __ot_pc_mine=1; fi
157
+ PROMPT_COMMAND=__ot_prompt_cmd
158
+ if [ -z "$__ot_ps1_orig" ]; then __ot_ps1_orig="$PS1"; fi
159
+ # PS1 不另行配色(观感对齐 main 分支:提示符用终端默认前景白,输入文本青色);
160
+ # 原生 PS1 存 __ot_ps1_orig 备用
161
+ PS1='\[\033]133;A;'"$__ot_i"'\007\][\u@\H \w]\$ \[\033]133;B;'"$__ot_i"'\007\]'
162
+ # 提交后重绘(§5.6):命令在 bind -x 回调里自执行(宏尾 accept 的是空行),
163
+ # 进回调前已把 \C-m 的换行画完——光标在回显行的下一行行首(真机 bash 5.1 对照
164
+ # 实验:回调内 \r\033[2K 只擦到空行,白回显行恒漏)。故先 CUU 上一行回到回显行、
165
+ # 清行、提示符(默认色)+命令重画、\n 收束本行(输出紧随其后)。
166
+ # 颜色 $2 可选:默认 51 青(工具命令/用户命令),AI 自然语言行传 33 蓝
167
+ # (Workbench 观感:用户输入蓝、命令青)。
168
+ # bash readline 不支持输入中变色,仅提交后变色。
169
+ __ot_repaint() {
170
+ local p="${PWD/#$HOME/\~}" c='#'
171
+ [ "$EUID" != 0 ] && c='$'
172
+ # 回显行可能折行(长命令):按 提示符+文本 显示宽算视觉行数 K,循环 CUU+EL
173
+ # K 次把回显的每个折行段全擦掉——只擦一行会漏首折行段(真机「__ot_exec__
174
+ # 长命令白漏半行」根因)。宽算法:字符数 + UTF-8 额外字节/2(CJK/emoji 双宽
175
+ # 恰准);向下取整宁漏勿过——多擦会毁上一行真实输出。
176
+ local prompt="[${USER:-$(id -un)}@$HOSTNAME $p]$c "
177
+ local s="$prompt$1" ch by w k i
178
+ # $3=same:同行擦除。空行提交时 readline 不为 \C-m 画换行(无回显字符可收束),
179
+ # 光标仍停在提示符行——上移擦会吃掉上一行(真机:连按 Enter 逐行吃掉连接
180
+ # 横幅、提示符不前进="Enter 不换行"根因)。
181
+ # 非空行在 bash 5.x 上同理:readline 进 bind -x 回调前自己发 \r\033[K 把回显行
182
+ # 擦净、光标停在回显行行首(真机 2026-10-08 字节级实测 CentOS bash 5.1.8:
183
+ # keys=b'\r' → raw b'\r\x1b[K',且早于 6337 上报标记)。此时再 CUU 就上到回显
184
+ # 行的上一行、连真实输出一起擦掉——每命令吃一行(真机:sudo su - 的 Last
185
+ # login 行、上一条命令的青色行逐条消失,屏幕逐命令上塌)。故 5.x 走「k-1 次
186
+ # 上移擦折行段 + 同行擦」;bash 4 及更早按旧实测(\C-m 已画换行、光标在回显
187
+ # 行下一行行首)保留 k 次上移擦——4.x 未在真机复核,宁保守不动。
188
+ if [ "${3:-up}" = same ]; then
189
+ printf '\r\033[2K'
190
+ else
191
+ ch=${#s}
192
+ by=$(printf %s "$s" | wc -c | tr -d ' ')
193
+ w=$(( ch + (by - ch) / 2 ))
194
+ k=$(( (w + ${COLUMNS:-80} - 1) / ${COLUMNS:-80} ))
195
+ (( k < 1 )) && k=1
196
+ if (( ${BASH_VERSINFO[0]:-5} >= 5 )); then
197
+ for ((i=1; i<k; i++)); do printf '\033[1A\033[2K'; done
198
+ printf '\r\033[2K'
199
+ else
200
+ for ((i=0; i<k; i++)); do printf '\033[1A\033[2K'; done
201
+ fi
202
+ fi
203
+ printf '%s\033[38;5;%sm%s\033[0m\n' "$prompt" "${2:-51}" "$1"
204
+ }
205
+ # Enter 由双段宏收尾(见文件末 bind):宏尾的 accept-line 只收空行,所以
206
+ # CMD/EXEC/off 路径仍由 hook 自执行:报告 → C → eval → D → 清屏;hook 返回后
207
+ # readline 先用陈旧 rl_prompt 重画一次提示符(A…B),accept 空行后 bash 重展开
208
+ # PS1 再画一次——前一次由 __ot_prompt_cmd 擦净,完成一个视觉上的"命令周期"。
209
+ # 历史引用(!! 等)在记入 history 前用
210
+ # history -p 展开,保持原生语义。fzf 的 bind -x TUI 是此模式可行性的先例。
211
+ __ot_run() {
212
+ local ec
213
+ printf '\033]133;C;%s\007' "$__ot_i"
214
+ (exit ${__ot_last_ec:-0}) 2>/dev/null
215
+ # 子命令(su/ssh/vim…)从正常 termios 起步:本回调处于 readline 裸模式,
216
+ # bash 不代为恢复,不还原则子 shell 继承 -echo 裸态(按键不可见)
217
+ [ -n "$__ot_tty_save" ] && stty "$__ot_tty_save" 2>/dev/null
218
+ eval "$1"
219
+ ec=$?
220
+ __ot_last_ec=$ec
221
+ # 还回 readline 裸模式(见 __ot_submit 快照注释):全屏应用退出后亦以此为准
222
+ [ -n "$__ot_rl_tty" ] && stty "$__ot_rl_tty" 2>/dev/null
223
+ printf '\033]133;D;%s;%s;%s\007' "$ec" "$__ot_i" "$PWD"
224
+ if [ "$__ot_off" != 1 ] && [ "$__ot_clear" = 1 ]; then printf '\033[H\033[2J'; fi
225
+ # 半行补白(zsh PROMPT_SP 同法):输出不以换行收尾时光标停在行中,回调返回后
226
+ # readline 的陈旧重绘会把提示符接在输出尾巴上画(真机 2026-10-09:printf abc
227
+ # → "abc[root@… /etc]# "),__ot_prompt_cmd 的收尾擦行就得连真实输出一起擦。
228
+ # 补 COLUMNS 个空格再 CR:光标本在行首 → 整行 blanks 后回行首(无副作用);
229
+ # 光标在行中 → 空格填满本行并自动折到下一行行首,陈旧重绘恒落在纯提示符行。
230
+ printf '%*s\r' "${COLUMNS:-80}" ''
231
+ return 0
232
+ }
233
+ # EXEC 注入体还原可读命令后 入史+青色重绘 再执行:注入行经过 tty 回显,明文
234
+ # 时是 "__ot_exec__ <命令>"、非打印字符时是 base64 包装——直接留在屏上就是
235
+ # 内部语法残留。这里把包装解码回原命令,单行体写进 shell 历史(↑/↓ 召回的
236
+ # 是历史命令:用户命令 + AI 工具命令),回显行重绘为 提示符+青色命令(§5.6),
237
+ # 再交 __ot_run 执行原注入体。
238
+ __ot_exec_run() {
239
+ local d="$1" nl=$'\n'
240
+ case "$1" in
241
+ 'eval "$(echo '*)
242
+ d="${1#*echo }"; d="${d%% | base64*}"
243
+ d="$(printf %s "$d" | base64 -d 2>/dev/null)" || d="$1" ;;
244
+ esac
245
+ [ -z "$d" ] && d="$1"
246
+ # 多行命令跳过:readline 历史条目按行召回,含 \n 的条目召回即残行
247
+ case "$d" in
248
+ *"$nl"*) ;;
249
+ *) history -s -- "$d" 2>/dev/null ;;
250
+ esac
251
+ __ot_repaint "$d"
252
+ __ot_run "$1"
253
+ }
254
+ # 首词可解析性判定(回显 CMD/AI):先剥反斜杠转义;$VAR 与 $VAR/剩余 形式先
255
+ # 替换变量值再查——type -t 按字面查 "$EDITOR"、"$JAVA_HOME/bin/java" 恒落空,
256
+ # 会把真命令误送 AI(真机复现)。变量未设值时展开为空 → 判 AI 交模型兜底。
257
+ __ot_resolve() {
258
+ local w="$1" vn val
259
+ case "$w" in "\\"*) w="${w#\\}" ;; esac
260
+ case "$w" in
261
+ '$'*)
262
+ vn="${w#\$}"; vn="${vn%%[!A-Za-z0-9_]*}"; val=""
263
+ [ -n "$vn" ] && val="${!vn-}"
264
+ w="${val}${w#\$"$vn"}"
265
+ [ -z "$w" ] && { echo AI; return; } ;;
266
+ esac
267
+ case "$(type -t "$w" 2>/dev/null)" in
268
+ alias|builtin|file|function|keyword) echo CMD ;;
269
+ *) echo AI ;;
270
+ esac
271
+ }
272
+ # 高危英文自然语言:首词是「一跑就错/阻塞/静默成功」的裸 builtin(read/wait
273
+ # 阻塞 stdin、clear/exit 退出码 0 连救援卡都不弹),且行内含英文虚词 → 判自然
274
+ # 语言。真命令(read -r line、kill -9 123、set -o vi)不含虚词,不受影响。
275
+ __ot_nl_en() {
276
+ case "$1" in
277
+ read|wait|clear|exit|logout|help|test|time|export|source|kill|jobs|history|set|let|dirs|pushd|popd) ;;
278
+ *) return 1 ;;
279
+ esac
280
+ set -f
281
+ local w
282
+ for w in $2; do
283
+ case "$w" in
284
+ the|a|an|and|or|but|for|to|from|with|into|about|after|before|then|than|me|my|your|our|their|this|that|these|those|please|is|are|was|were|be|will|would|should|could|again|up)
285
+ set +f; return 0 ;;
286
+ esac
287
+ done
288
+ set +f
289
+ return 1
290
+ }
291
+ __ot_submit() {
292
+ local line="$READLINE_LINE" kind t first exp rest r0 r1
293
+ # 本轮经 hook:宏尾 accept-line 会让 bash 重画提示符,PROMPT_COMMAND 据此擦掉
294
+ # readline 陈旧重绘留下的那一截(见 __ot_prompt_cmd 收尾注释)
295
+ __ot_clr=1
296
+ # readline 裸模式快照:__ot_run 在 eval 前按 __ot_tty_save 恢复正常态(子命令
297
+ # 所需),eval 后必须按本快照还回 readline 裸模式——readline 自认终端仍预置、
298
+ # 不会重新 prep,不还则下个提示符跑在 canonical 态:Tab 补全/↑ 历史/Ctrl+R
299
+ # 全被字面回显失效(真机:首条命令后 ↑ 打出 ^[[A、Tab 无补全)
300
+ __ot_rl_tty=$(stty -g 2>/dev/null)
301
+ case "$line" in
302
+ # __ot_pad N:清掉回显行后打印 N 个空行(卡片占位行由 shell 打印,
303
+ # 远端无 ConPTY 但同理:占位行必须存在于 shell 的输出流里)
304
+ '__ot_pad '*)
305
+ t="${line:9}"
306
+ case "$t" in ''|*[!0-9]*) t=1 ;; esac
307
+ (( t > 50 )) && t=50
308
+ READLINE_LINE=""; READLINE_POINT=0
309
+ # 不擦行:bash 5.x 的 readline 进 bind -x 回调前已自行发 \r\x1b[K 把
310
+ # 「提示符+__ot_pad N」回显行整个擦掉,光标停在回显行行首(真机
311
+ # CentOS bash 5.1.8 字节级实测:Enter 后先到 \r[K 再进回调)。此处的
312
+ # 光标几何随 bash 版本变——旧实测「已画换行、光标在回显行下一行」与
313
+ # 5.x 相反,相对 CUU 在 5.x 上必然上移一行把上一行内容吃掉(真机:
314
+ # AI 蓝色提交行、命令输出行被 pad 擦掉)。故只补 N 个换行:光标行的
315
+ # 空行 + N 个换行恰得 N 行占位,回显行已由 readline 自清。
316
+ printf '\n%.0s' $(seq 1 "$t")
317
+ return 0 ;;
318
+ '__ot_exec__ '*)
319
+ t="${line:12}"
320
+ # 注入行原子性:体恒为单引号包裹(裸壳下整行只剩一条不存在的命令,
321
+ # 分号段/命令替换一律不得执行)。这里解一层引号还原原命令再执行。
322
+ # 含单引号的命令走 base64 包装分支,不进到这里,故不须处理引号转义
323
+ case "$t" in "'"*"'" ) t="${t#?}"; t="${t%?}" ;; esac
324
+ printf '\033]6337;%s;%s;%s\007' "$__ot_i" EXEC "$t"
325
+ READLINE_LINE=""; READLINE_POINT=0
326
+ __ot_exec_run "$t"
327
+ return 0 ;;
328
+ esac
329
+ t="${line#"${line%%[![:space:]]*}"}"
330
+ # 空行也走重绘:只 printf '\r\n' 的话,bind -x 返回后 readline 的重绘会
331
+ # 擦掉先前画好的提示符——连按 Enter 只剩最后一个提示符、上方一片空行
332
+ if [ -z "$t" ]; then
333
+ # 宏尾 accept 的必须是空行:纯空白行留着会被 bash 当命令收下(ignorespace
334
+ # 不保证处处开着)并进历史
335
+ READLINE_LINE=""; READLINE_POINT=0
336
+ # 真空行 readline 不为 \C-m 画换行(光标停在提示符行)→ 同行擦;纯空白行
337
+ # 有回显字符、readline 画了换行 → 上移擦(残留空格不可见,无害)
338
+ if [ -z "$line" ]; then __ot_repaint "" 51 same; else __ot_repaint ""; fi
339
+ return 0
340
+ fi
341
+ if [ "$__ot_off" = 1 ]; then
342
+ exp=$(builtin history -p -- "$line" 2>/dev/null) || exp=$line
343
+ [ -n "$exp" ] || exp=$line
344
+ history -s -- "$line" 2>/dev/null
345
+ READLINE_LINE=""; READLINE_POINT=0
346
+ printf '\r\n'
347
+ (exit ${__ot_last_ec:-0}) 2>/dev/null
348
+ [ -n "$__ot_tty_save" ] && stty "$__ot_tty_save" 2>/dev/null
349
+ eval "$exp"
350
+ __ot_last_ec=$?
351
+ [ -n "$__ot_rl_tty" ] && stty "$__ot_rl_tty" 2>/dev/null
352
+ return 0
353
+ fi
354
+ case "$t" in
355
+ '?'*) kind=AI ;;
356
+ '!'*) t="${t:1}"; kind=CMD ;;
357
+ '('*) kind=CMD ;; # 子 shell 开头必是 shell 语法(首词被 ( 截空须直判)
358
+ '$('*) kind=CMD ;; # 命令替换开头($(which py) x.py):$ 不在操作符集里,落默认分支首词截成 $ 会误 AI
359
+ '`'*) kind=CMD ;; # 反引号替换开头同理
360
+ '{'*) kind=CMD ;; # 花括号组 { ls; }:首词 { 查不到会被误 AI
361
+ '#'*) kind=CMD ;; # 注释行交 shell 按注释 eval(无副作用),不为备注起 AI 任务
362
+ *'|'*|*'&'*|*';'*|*'>'*|*'<'*|*'`'*)
363
+ # 含操作符:首个操作符前的首词须可解析为命令(或赋值)才算命令,
364
+ # 否则是自然语言("把日志保存>log.txt"、"注意a|b的区别"不再整行执行)
365
+ first="${t%%[[:space:]]*}"; first="${first%%[;|&()<>]*}"
366
+ case "$first" in
367
+ '') kind=CMD ;; # 重定向开头(>log / >>a cmd):剥掉 <> 后首词空,合法 shell 语法
368
+ [A-Za-z_]*=*) kind=CMD ;;
369
+ *)
370
+ kind=$(__ot_resolve "$first")
371
+ if [ "$kind" = CMD ] && __ot_nl_en "$first" "$t"; then kind=AI; fi ;;
372
+ esac ;;
373
+ *) first="${t%%[[:space:]]*}"; first="${first%%[;|&()<>]*}"
374
+ # 赋值/环境前缀只在首词是合法标识符时才算命令(FOO=bar / arr[0]=x):
375
+ # 自然语言里带 "="("把ll=ls设为别名")首词非标识符,落回 type -t
376
+ # 判成 AI——原 "*=*" 会把整行当命令执行报 "command not found"
377
+ case "$first" in
378
+ [A-Za-z_]*=*)
379
+ # NAME=value 后还有词 = 环境前缀,仅当后续首词可解析为命令;
380
+ # 否则是自然语言(裸 "ll=ls -al的别名设置为永久" 由此兜住)。
381
+ # 连续多变量前缀(A=1 B=2 cmd)须逐个剥——旧版只看 rest 首词,
382
+ # r1 取到第二个赋值查不到,真命令被误 AI(真机复现)
383
+ rest="${t#"$first"}"
384
+ rest="${rest#"${rest%%[![:space:]]*}"}"
385
+ while :; do
386
+ case "$rest" in
387
+ [A-Za-z_]*=*)
388
+ r0="${rest%%[[:space:]]*}"
389
+ rest="${rest#"$r0"}"
390
+ rest="${rest#"${rest%%[![:space:]]*}"}" ;;
391
+ *) break ;;
392
+ esac
393
+ done
394
+ if [ -z "$rest" ]; then kind=CMD
395
+ else
396
+ r1="${rest%%[[:space:]]*}"; r1="${r1%%[;|&()<>]*}"
397
+ kind=$(__ot_resolve "$r1")
398
+ fi ;;
399
+ *)
400
+ # type -t 覆盖 keyword/builtin/alias/function/file——command -v 找不到
401
+ # exit/if/for 等关键字,会把它们误判成自然语言吞掉
402
+ kind=$(__ot_resolve "$first")
403
+ if [ "$kind" = CMD ] && __ot_nl_en "$first" "$t"; then kind=AI; fi ;;
404
+ esac ;;
405
+ esac
406
+ printf '\033]6337;%s;%s;%s\007' "$__ot_i" "$kind" "$t"
407
+ if [ "$kind" = AI ]; then
408
+ # 自然语言不入 shell 历史:↑/↓ 翻看的是历史命令(用户命令走 CMD 路径
409
+ # 入史、AI 工具命令走 __ot_exec_run 入史),不是历史输入的自然语言
410
+ READLINE_LINE=""; READLINE_POINT=0
411
+ # 蓝色重绘(§5.6):AI 行默认色回显清掉,提示符+原文按主题蓝重画
412
+ # (Workbench 观感:用户自然语言蓝,工具/用户命令青)
413
+ __ot_repaint "$t" 33
414
+ return 0
415
+ fi
416
+ exp=$(builtin history -p -- "$t" 2>/dev/null) || exp=$t
417
+ [ -n "$exp" ] || exp=$t
418
+ READLINE_LINE=""; READLINE_POINT=0
419
+ # 注入管线内部行(__ot_inj 分片装配 / eval 解包)不青色重绘、不入史:
420
+ # 内部语法不该出现在屏幕上,也不该被 ↑ 召回(每次重注入 ~8 条 base64
421
+ # 分片进史,翻历史全是垃圾)
422
+ case "$t" in
423
+ __ot_inj=*|_ot_inj=*|__ot_hc=*|'eval "$(echo "$__ot_inj"'*) ;;
424
+ *) history -s -- "$line" 2>/dev/null; __ot_repaint "$exp" ;;
425
+ esac
426
+ __ot_run "$exp"
427
+ return 0
428
+ }
429
+ # Enter 双段宏(真机 2026-10-09 字节级验证,CentOS bash 5.1.8):\C-o 先跑
430
+ # hook(分类/重绘/执行;readline 进 bind -x 回调前自发的 CR+EL 预擦回显行照旧
431
+ # 发生,重绘几何不变),\e[45~ 再 accept 一条空行让 readline() 真的返回——
432
+ # bash 主循环这才重跑 PROMPT_COMMAND 并重展开 PS1。只 bind -x 吞 Enter 时
433
+ # readline() 永不返回、rl_prompt 停在首次展开值:换目录后空闲提示符目录冻结
434
+ # (cd /etc 后仍显示 ~),且用户自己的 PROMPT_COMMAND、bracketed paste 开关等
435
+ # 原生提示符周期动作全部不跑。空行不入史也不执行(bash 对空命令两者都跳;
436
+ # 实测 history 号连续无空洞);PROMPT_COMMAND 补发的 D 与 __ot_run 那份同 inst
437
+ # 同 ec,core 按孤儿 D 忽略(栈已空)。\C-o 是单字节键(0x0F),bash 4.2 的
438
+ # bind -x 在宏展开时对多字节 CSI 序列(如 \e[44~)的 keymap 查找失败,
439
+ # 单字节键无此问题。宏体不含 \C-m,无递归。\C-j 同绑(发 LF 的终端走同一条
440
+ # 路径)。zsh 侧 widget 本就 zle .accept-line,无此结构问题。
441
+ # bind 命令由 injection_lines 在 eval 之后独立注入(不进 base64 块)——
442
+ # bind -x 经 eval 注入时 keymap 关联丢失(真机:bash_execute_unix_command
443
+ # cannot find keymap for command,按键触发时才报,注册时返回 0 无法预检),
444
+ # 在正常 shell 上下文执行则无此问题。
445
+ # 子 shell 继承(bug 修复:su 不带 - 切 root 后集成与主题色丢失):非 login
446
+ # 子 shell 继承导出的变量/函数/PROMPT_COMMAND/PS1;su - 等 login shell 会被
447
+ # root 的 profile 重置,无法覆盖
448
+ export __ot_i __ot_off __ot_clear __ot_last_ec __ot_pc_mine __ot_pc_orig __ot_ps1_orig
449
+ export __ot_tty_save
450
+ export PROMPT_COMMAND PS1
451
+ export -f __ot_prompt_cmd __ot_run __ot_submit __ot_repaint __ot_exec_run
452
+ # --- 历史保洁(用户报障:↑ 召回的全是 __ot_inj base64 分片/探测行)---
453
+ # ① 内存列表:扫 history 取垃圾行号 → 降序逐条 history -d(升序删会错位;
454
+ # 多行命令的续行无编号,非纯数字防御跳过——PTY 实测:HISTFILE 载入的
455
+ # 旧垃圾与连接期探测行/静默行全清、正常条目零损伤)。
456
+ # ② HISTFILE 落盘文件过滤重写(histappend 用户下次会话不再载入旧垃圾;
457
+ # 非 histappend 用户退出时 bash 本就以内存列表重写文件,同样干净)。
458
+ __ot_jk='_ot_inj|__ot_exec__|__ot_pad |__ot_hc=|__OT|\.ot_hist_'
459
+ for __ot_n in $(history | grep -E "$__ot_jk" 2>/dev/null | awk '{print $1}' | sort -rn); do
460
+ case "$__ot_n" in ''|*[!0-9]*) continue ;; esac
461
+ history -d "$__ot_n" 2>/dev/null
462
+ done
463
+ unset __ot_n
464
+ if [ -n "$HISTFILE" ] && [ -r "$HISTFILE" ] \
465
+ && grep -qE "$__ot_jk" "$HISTFILE" 2>/dev/null; then
466
+ __ot_cf="${TMPDIR:-/tmp}/.ot_hc2.$$"
467
+ grep -vE "$__ot_jk" "$HISTFILE" > "$__ot_cf" 2>/dev/null \
468
+ && cat "$__ot_cf" > "$HISTFILE" 2>/dev/null
469
+ rm -f "$__ot_cf"; unset __ot_cf
470
+ fi
471
+ unset __ot_jk
472
+ # 连接期静默窗口收口挪到注入链末行(injection_lines 的 bind 行尾部):
473
+ # 本脚本经 eval 执行时 bind 行还没发,此处收口会让 bind 行落进历史
474
+ #(↑ 召回一条 ~200B 的 bind 垃圾,与用户报障过的分片污染同款)。
475
+ # 快照缺失(静默行没跑过)时末行同样不动用户 HISTCONTROL。
476
+ # 尾部吸收行:分片二次追加的拼接点必须落在本行参数上,
477
+ # 否则上一行 export -f 名单会被拼接改名成不存在的假函数名
478
+ : __ot_script_end
479
+ '''.strip()
480
+
481
+ _ZSH_SCRIPT = r'''
482
+ __ot_i=@@INST@@
483
+ __ot_off=0
484
+ # 单管线(§5.6):历史自然累积进唯一 xterm 的 scrollback,不再每命令清屏
485
+ __ot_clear=0
486
+ export PAGER=cat GIT_PAGER=cat
487
+ __ot_precmd() {
488
+ local ec=$?
489
+ printf '\033]133;D;%s;%s;%s\007' "$ec" "$__ot_i" "$PWD"
490
+ if (( __ot_off != 1 && __ot_clear == 1 )); then printf '\033[H\033[2J'; fi
491
+ return 0
492
+ }
493
+ case " ${precmd_functions[*]} " in
494
+ *" __ot_precmd "*) ;;
495
+ *) precmd_functions=(__ot_precmd $precmd_functions) ;;
496
+ esac
497
+ # 重注入期内部行的入史抑制还原点:__ot_submit 抑制内部行入史时全局开
498
+ # hist_ignore_space(widget 内 accept-line 的入史动作发生在 widget 返回之后
499
+ # 且绕过 zshaddhistory,localoptions 届时已还原——PTY 实验证实,只有全局
500
+ # 选项活到入史时刻才有效),命令起跑后由本函数按 line A 快照还原
501
+ __ot_sup=
502
+ __ot_preexec() {
503
+ if [[ -n "$__ot_sup" ]]; then
504
+ unset __ot_sup
505
+ [[ "${__ot_his:-off}" == on ]] || unsetopt hist_ignore_space 2>/dev/null
506
+ fi
507
+ }
508
+ case " ${preexec_functions[*]} " in
509
+ *" __ot_preexec "*) ;;
510
+ *) preexec_functions=(__ot_preexec $preexec_functions) ;;
511
+ esac
512
+ if [[ -z "$__ot_ps1_orig" ]]; then __ot_ps1_orig="$PS1"; fi
513
+ # PS1 不另行配色(观感对齐 main 分支:提示符用终端默认前景白);原生 PS1
514
+ # 存 __ot_ps1_orig 备用。
515
+ # zsh 的提示符展开不处理反斜杠转义(bash 的 PS1 会,zsh 不会):
516
+ # 单引号里的 \033 会按字面输出,OSC 133 标记永远不出现。必须用 $'...'
517
+ # ANSI-C 引用让 ESC/BEL 在赋值期就变成真实字节。
518
+ PS1=$'%{\e]133;A;'"$__ot_i"$'\a%}[%n@%M %~]# %{\e]133;B;'"$__ot_i"$'\a%}'
519
+ # 首词可解析性判定(回显 CMD/AI):先剥反斜杠转义;$VAR 与 $VAR/剩余 形式先
520
+ # 替换变量值再查——whence 按字面查 "$EDITOR"、"$JAVA_HOME/bin/java" 恒落空,
521
+ # 会把真命令误送 AI(真机复现)。变量未设值时展开为空 → 判 AI 交模型兜底。
522
+ __ot_resolve() {
523
+ local w="$1" vn val
524
+ case "$w" in "\\"*) w="${w#\\}" ;; esac
525
+ case "$w" in
526
+ '$'*)
527
+ vn="${w#\$}"; vn="${vn%%[!A-Za-z0-9_]*}"; val=""
528
+ [[ -n "$vn" ]] && val="${(P)vn}"
529
+ w="${val}${w#\$"$vn"}"
530
+ [[ -z "$w" ]] && { echo AI; return; } ;;
531
+ esac
532
+ if whence -w -- "$w" >/dev/null 2>&1; then echo CMD; else echo AI; fi
533
+ }
534
+ # 高危英文自然语言(语义同 bash 侧 __ot_nl_en 注释):裸 builtin 首词 + 英文虚词
535
+ # → 判自然语言;read -r line / kill -9 123 这类真命令不含虚词,不受影响。
536
+ __ot_nl_en() {
537
+ case "$1" in
538
+ read|wait|clear|exit|logout|help|test|time|export|source|kill|jobs|history|set|let|dirs|pushd|popd) ;;
539
+ *) return 1 ;;
540
+ esac
541
+ setopt localoptions noglob
542
+ local w
543
+ for w in ${=2}; do
544
+ case "$w" in
545
+ the|a|an|and|or|but|for|to|from|with|into|about|after|before|then|than|me|my|your|our|their|this|that|these|those|please|is|are|was|were|be|will|would|should|could|again|up)
546
+ return 0 ;;
547
+ esac
548
+ done
549
+ return 1
550
+ }
551
+ __ot_submit() {
552
+ # extendedglob:strip 表达式里的 # 量词需要;localoptions 只在本函数内生效
553
+ setopt localoptions extendedglob
554
+ local line="$BUFFER" kind t first show rest r0 r1
555
+ # POSTEDIT 是持久参数:上一次 accept-line 设的青色重绘会在本次 accept-line
556
+ # 时原样输出(pad/空行/off 早退路径每提交一次就泄一行旧回显,卡片盖住垃圾行)。
557
+ # 先统一清掉,正常路径末尾再按本次 $show 重新赋值。
558
+ POSTEDIT=""
559
+ case "$line" in
560
+ # __ot_pad N:打印 N 个空行后清行提交(占位行由 shell 打印)
561
+ '__ot_pad '*)
562
+ t="${line:9}"; [[ -z "$t" || "$t" == *[!0-9]* ]] && t=1
563
+ (( t > 50 )) && t=50
564
+ BUFFER=""
565
+ # 抹掉「提示符+__ot_pad N」回显行再打空行(bash 侧清 READLINE_LINE 同理):
566
+ # 不清则每注入多残留一行,占位行数漂移、卡片盖不住 pad 出现裸空白
567
+ printf '\r\033[2K'
568
+ repeat "$t" print ""
569
+ zle .accept-line
570
+ return ;;
571
+ '__ot_exec__ '*)
572
+ t="${line:12}"; kind=EXEC
573
+ # 注入行原子性(同 bash 侧注释):解一层单引号还原原命令
574
+ case "$t" in "'"*"'" ) t="${t#?}"; t="${t%?}" ;; esac
575
+ # 显示用还原:base64 包装解码回原命令(青色回显不出现包装语法)
576
+ show="$t"
577
+ if [[ "$t" == 'eval "$(echo '* ]]; then
578
+ local b="${t#*echo }"
579
+ b="${b%% | base64*}"
580
+ show="$(printf %s "$b" | base64 -d 2>/dev/null)" || show="$t"
581
+ [[ -z "$show" ]] && show="$t"
582
+ fi
583
+ if [[ "$show" != "$t" && "$show" != *$'\n'* && "$show" != *$'\r'* ]]; then
584
+ # 包装体解码后是单行(多为中文等非 ASCII 命令):BUFFER 直接换成原
585
+ # 命令,与明文注入走同一条原生路径——accept-line 原生入史记下的就是
586
+ # 可读原文(↑ 召回工具命令而非 base64 包装语法)。注意:不能用
587
+ # 「前置空格+histignorespace」抑制原生入史——widget 内 zle
588
+ # .accept-line 的入史动作发生在 widget 返回之后,localoptions 届时
589
+ # 已还原,抑制无效(PTY 字节级实验证实);多行体仍走包装体执行
590
+ BUFFER="$show"; CURSOR=${#show}
591
+ else
592
+ BUFFER="$t"; CURSOR=${#t} # 明文 / 多行包装体:原样执行
593
+ fi ;;
594
+ *)
595
+ if (( __ot_off == 1 )); then zle .accept-line; return; fi
596
+ # 去掉行首空白(## [[:space:]]#)。原写法 ${line#"${line%%[![:space:]]#}"}
597
+ # 有两处错:无 extendedglob 时 # 是字面量、t 恒空,整个 hook 静默失效;
598
+ # 有 extendedglob 也会把无前导空白的行的第一个词吞掉("echo xy"→"xy")
599
+ t="${line##[[:space:]]#}"
600
+ if [[ -z "$t" ]]; then zle .accept-line; return; fi
601
+ show="$t"
602
+ case "$t" in
603
+ '?'*) kind=AI ;;
604
+ '!'*) t="${t:1}"; kind=CMD; show="$t"; BUFFER="$t"; CURSOR=${#t} ;;
605
+ '('*) kind=CMD ;; # 子 shell 开头必是 shell 语法(首词被 ( 截空须直判)
606
+ '$('*) kind=CMD ;; # 命令替换开头($(which py) x.py):$ 不在操作符集里,落默认分支首词截成 $ 会误 AI
607
+ '`'*) kind=CMD ;; # 反引号替换开头同理
608
+ '{'*) kind=CMD ;; # 花括号组 { ls; }:首词 { 查不到会被误 AI
609
+ '#'*) kind=CMD ;; # 注释行交 shell 按注释 eval(无副作用),不为备注起 AI 任务
610
+ *'|'*|*'&'*|*';'*|*'>'*|*'<'*|*'`'*)
611
+ # 含操作符:首个操作符前的首词须可解析为命令(或赋值)才算命令,
612
+ # 否则是自然语言("把日志保存>log.txt"、"注意a|b的区别"不再整行执行)
613
+ first="${t%%[[:space:]]*}"; first="${first%%[;|&()<>]*}"
614
+ if [[ -z "$first" ]]; then kind=CMD # 重定向开头(>log):剥掉 <> 后首词空,合法 shell 语法
615
+ elif [[ "$first" == [A-Za-z_]*=* ]]; then kind=CMD
616
+ else
617
+ kind="$(__ot_resolve "$first")"
618
+ if [[ "$kind" == CMD ]] && __ot_nl_en "$first" "$t"; then kind=AI; fi
619
+ fi ;;
620
+ *) first="${t%%[[:space:]]*}"; first="${first%%[;|&()<>]*}"
621
+ # 赋值/环境前缀只在首词是合法标识符时才算命令(FOO=bar / arr[0]=x):
622
+ # 自然语言里带 "="("把ll=ls设为别名")首词非标识符,落回 whence
623
+ # 判成 AI——原 "*=*" 会把整行当命令执行报 "command not found"
624
+ if [[ "$first" == [A-Za-z_]*=* ]]; then
625
+ # NAME=value 后还有词 = 环境前缀,仅当后续首词可解析为命令;
626
+ # 否则是自然语言(裸 "ll=ls -al的别名设置为永久" 由此兜住)。
627
+ # 连续多变量前缀(A=1 B=2 cmd)须逐个剥——旧版只看 rest 首词,
628
+ # r1 取到第二个赋值查不到,真命令被误 AI(真机复现)
629
+ rest="${t#"$first"}"; rest="${rest##[[:space:]]#}"
630
+ while [[ "$rest" == [A-Za-z_]*=* ]]; do
631
+ r0="${rest%%[[:space:]]*}"
632
+ rest="${rest#"$r0"}"; rest="${rest##[[:space:]]#}"
633
+ done
634
+ if [[ -z "$rest" ]]; then kind=CMD
635
+ else
636
+ r1="${rest%%[[:space:]]*}"; r1="${r1%%[;|&()<>]*}"
637
+ kind="$(__ot_resolve "$r1")"
638
+ fi
639
+ # whence -w 覆盖 reserved word/builtin/alias/function/path,且必须按
640
+ # 退出码判:zsh 5.9 对不存在的名字输出非空的"名字: none",旧的
641
+ # [[ -n "$(...)" ]] 恒真——zsh 侧自然语言曾被整判成 CMD(同 bash
642
+ # 的 *=* 事故)。command -v 找不到 exit/if/for 等关键字,会误判
643
+ else
644
+ kind="$(__ot_resolve "$first")"
645
+ if [[ "$kind" == CMD ]] && __ot_nl_en "$first" "$t"; then kind=AI; fi
646
+ fi ;;
647
+ esac ;;
648
+ esac
649
+ printf '\033]6337;%s;%s;%s\007' "$__ot_i" "$kind" "$t"
650
+ # 注入管线内部行(__ot_inj 分片装配 / eval 解包)不做青色重绘:它们是内部
651
+ # 语法,重绘会把 base64 分片当命令回显画进主屏(且重绘字节落在 C…D exec
652
+ # 相位,绕过 worker 的 _suppress_live 吞除,每任务泄 ~8 行垃圾)
653
+ case "$t" in
654
+ '__ot_inj='*|'_ot_inj='*|'__ot_hc='*|'eval "$(echo "$__ot_inj"'*) ;;
655
+ *)
656
+ local p="${PWD/#$HOME/\~}" c='#' col=51
657
+ [[ $EUID != 0 ]] && c='%'
658
+ # 用户自然语言(AI)蓝色、命令青色(Workbench 观感:输入蓝/命令青)
659
+ [[ "$kind" == AI ]] && col=33
660
+ POSTEDIT=$'\e[F\e[2K['"$USER@$HOST $p"$']'"$c"$' \e[38;5;'"$col"$'m'"$show"$'\e[0m\n' ;;
661
+ esac
662
+ if [[ "$kind" == AI ]]; then
663
+ # 自然语言不入 shell 历史:↑/↓ 翻看的是历史命令(用户命令走 accept-line
664
+ # 原生入史、AI 工具命令走 __ot_exec__ 分支),不是历史输入的自然语言
665
+ BUFFER=""
666
+ zle .accept-line
667
+ else
668
+ printf '\033]133;C;%s\007' "$__ot_i"
669
+ # 重注入期内部行(__ot_inj 分片 / 探测行 / 静默行)抑制原生入史:
670
+ # 全局开 hist_ignore_space + 确保前导空格,入史时刻(widget 返回后)
671
+ # 选项仍在位 → 跳过;命令起跑后 __ot_preexec 按快照还原(见脚本头)
672
+ case "$t" in
673
+ '__ot_inj='*|'_ot_inj='*|'__ot_hc='*|'eval "$(echo "$__ot_inj"'*)
674
+ setopt hist_ignore_space 2>/dev/null
675
+ [[ "$BUFFER" == ' '* ]] || BUFFER=" $BUFFER"
676
+ __ot_sup=1 ;;
677
+ esac
678
+ zle .accept-line
679
+ fi
680
+ }
681
+ # 半行保存/恢复(同 bash 侧 __ot_kill_line/__ot_yank_line):注入 \x15 杀行
682
+ # 前把半行存 __ot_saved(不经 kill ring),注入收尾的 \x19 仅在有存档时接回
683
+ # 行首、光标落拼接点——空行零副作用。任务期用户打字与注入交错时打字内容
684
+ # 原样接回(真机「任务跑着打字只剩尾巴」根因)。worker 保证每 \x15 配一 \x19。
685
+ __ot_saved=
686
+ __ot_kill_line() { __ot_saved="$BUFFER"; BUFFER=""; CURSOR=0; }
687
+ __ot_yank_line() {
688
+ if [[ -n "$__ot_saved" ]]; then
689
+ BUFFER="$__ot_saved$BUFFER"; CURSOR=${#__ot_saved}; __ot_saved=
690
+ fi
691
+ }
692
+ zle -N __ot_kill_line
693
+ zle -N __ot_yank_line
694
+ bindkey '^U' __ot_kill_line
695
+ bindkey '^Y' __ot_yank_line
696
+ zle -N __ot_submit
697
+ bindkey '^M' __ot_submit
698
+ # PTY 开 ICRNL:发送端 \r 会被内核翻成 \n(^J)才到 zle,必须同时绑 ^J
699
+ # (与 bash 脚本绑 \C-m + \C-j 同理),否则 Enter 走默认 accept-line、hook 不上报
700
+ bindkey '^J' __ot_submit
701
+ # --- 历史保洁(用户报障:↑ 召回的全是 __ot_inj base64 分片/探测行)---
702
+ # 两路清理:① 内存列表——zsh 无逐条删除 API,整体重建:fc -W 快照 →
703
+ # 过滤垃圾行 → HISTSIZE=0 清空 → fc -R 读回(fc -R 是追加语义,PTY 实验
704
+ # 证实;DB 灌入的命令一并保留)。不用 fc -p 推栈:栈上旧表的未落盘条目
705
+ # (静默行)会在退出追加保存时被写回历史文件(PTY 实测),且 fc -p 会
706
+ # 重置 HISTFILE/HISTSIZE/SAVEHIST。连接期的探测行/静默行恰在内存而未
707
+ # 落盘,①顺手清掉。
708
+ # ② HISTFILE 落盘文件过滤重写(旧版本垃圾不再被下次会话载入)。
709
+ # 两路都仅在确有垃圾时动手;每次重注入重复执行是幂等的廉价扫描。
710
+ __ot_jk='_ot_inj|__ot_exec__|__ot_pad |__ot_hc=|__OT|\.ot_hist_'
711
+ __ot_hf="$HISTFILE"; __ot_hs="$HISTSIZE"
712
+ __ot_mh="${TMPDIR:-/tmp}/.ot_hc1.$$"; __ot_cf="${TMPDIR:-/tmp}/.ot_hc2.$$"
713
+ fc -W "$__ot_mh" 2>/dev/null
714
+ if [[ -s "$__ot_mh" && -n "$__ot_hs" && "$__ot_hs" != *[!0-9]* ]] \
715
+ && grep -qE "$__ot_jk" "$__ot_mh" 2>/dev/null; then
716
+ grep -vE "$__ot_jk" "$__ot_mh" > "$__ot_cf" 2>/dev/null
717
+ HISTSIZE=0; HISTSIZE="$__ot_hs"
718
+ fc -R "$__ot_cf" 2>/dev/null
719
+ if [[ -n "$__ot_hf" && -r "$__ot_hf" ]] \
720
+ && grep -qE "$__ot_jk" "$__ot_hf" 2>/dev/null; then
721
+ grep -vE "$__ot_jk" "$__ot_hf" > "$__ot_cf" 2>/dev/null \
722
+ && cat "$__ot_cf" > "$__ot_hf" 2>/dev/null
723
+ fi
724
+ fi
725
+ rm -f "$__ot_mh" "$__ot_cf" 2>/dev/null
726
+ unset __ot_mh __ot_cf __ot_jk __ot_hf __ot_hs
727
+ # 连接期静默窗口收口:按 line A 快照还原(用户原本开着 hist_ignore_space
728
+ # 则保持开;快照缺失=静默行没跑过,不动用户选项)
729
+ [[ -n "${__ot_his+x}" && "$__ot_his" != on ]] && unsetopt hist_ignore_space 2>/dev/null
730
+ # 尾部吸收行(同 bash):分片拼接尾巴不得改写上面的绑定行
731
+ : __ot_script_end
732
+ '''.strip()
733
+
734
+ # PowerShell 5.1 + PSReadLine 2.0.0:无 SetBufferState,AI 路径用 OSC 上报 +
735
+ # __ot_clearline(清缓冲、不入史——↑/↓ 只召回命令),注入命令经
736
+ # $global:__ot_pending + prompt() 内执行达成。prompt() 顶部发 D(上一条
737
+ # AcceptLine 命令的退出码)、pending 执行内联发 C…D(不能等下一个
738
+ # prompt——那会把用户输入期挂进 EXEC 相位)。
739
+ _PS_SCRIPT = r'''
740
+ $global:__ot_i = @@INST@@
741
+ $global:__ot_off = 0
742
+ # 单管线(§5.6):历史自然累积进唯一 xterm 的 scrollback,不再每命令清屏
743
+ $global:__ot_clear = 0
744
+ $global:__ot_skip_d = $false
745
+ $global:__ot_pad_rows = 0
746
+ try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}
747
+ # 输入即时青色(§5.6):PSReadLine 按 token 着色——main 分支的输入区是
748
+ # 统一青色文本,所以把各 token 类一并染青(仅 Comment 留灰),无提交后重绘
749
+ try {
750
+ Set-PSReadLineOption -Colors @{
751
+ Default = '#39c5cf'; Command = '#39c5cf'; Parameter = '#39c5cf'
752
+ String = '#39c5cf'; Number = '#39c5cf'; Variable = '#39c5cf'
753
+ Operator = '#39c5cf'; Keyword = '#39c5cf'; Type = '#39c5cf'
754
+ Comment = '#9aa0a6'
755
+ }
756
+ } catch {}
757
+ function global:__ot_osc([string]$body) {
758
+ [Console]::Out.Write(([char]27).ToString() + ']' + $body + ([char]7).ToString())
759
+ }
760
+ # 清空当前输入行并触发新提示符周期:优先 RevertLine(无 ^C 视觉),失败或
761
+ # 缓冲未清空时回退 CancelLine(会显示 ^C,但保证缓冲清空)。
762
+ function global:__ot_clearline {
763
+ $l = 'x'
764
+ try {
765
+ [Microsoft.PowerShell.PSConsoleReadLine]::RevertLine($null, $null)
766
+ $c = 0; $l = $null
767
+ [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$l, [ref]$c)
768
+ } catch { $l = 'x' }
769
+ if ($null -ne $l -and $l -eq '') {
770
+ [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
771
+ } else {
772
+ [Microsoft.PowerShell.PSConsoleReadLine]::CancelLine()
773
+ }
774
+ }
775
+ if (-not $global:__ot_orig_prompt) {
776
+ $global:__ot_orig_prompt = (Get-Item Function:\prompt -ErrorAction SilentlyContinue).ScriptBlock
777
+ if (-not $global:__ot_orig_prompt) { $global:__ot_orig_prompt = { "PS $((Get-Location).Path)> " } }
778
+ }
779
+ # 提示符不另行配色(观感对齐 main 分支:提示符默认前景白,输入青色)
780
+ function global:prompt {
781
+ # D 标记:handler 内联执行过(EXEC/!)后由 __ot_skip_d 抑制孤儿 D;
782
+ # 原生 AcceptLine 路径(CMD)在这里发上一条命令的 D。
783
+ if ($global:__ot_skip_d) {
784
+ $global:__ot_skip_d = $false
785
+ } else {
786
+ $ec = 0
787
+ if ($null -ne $global:LASTEXITCODE) { $ec = $global:LASTEXITCODE }
788
+ elseif (-not $?) { $ec = 1 }
789
+ __ot_osc("133;D;$ec;$($global:__ot_i);$((Get-Location).Path)")
790
+ }
791
+ $global:LASTEXITCODE = $null
792
+ if ($global:__ot_off -ne 1 -and $global:__ot_clear -eq 1) {
793
+ [Console]::Out.Write(([char]27).ToString() + '[H' + ([char]27).ToString() + '[2J')
794
+ }
795
+ $e = ([char]27).ToString()
796
+ # pad 占位空行:Enter handler 存的行数在这里并入提示符串(见 __ot_pad 分支)
797
+ $padLines = ''
798
+ if ($global:__ot_pad_rows -gt 0) {
799
+ $padLines = ("`n" * $global:__ot_pad_rows)
800
+ $global:__ot_pad_rows = 0
801
+ }
802
+ return ($padLines + $e + "]133;A;$($global:__ot_i)" + ([char]7).ToString() +
803
+ 'PS ' + $((Get-Location).Path) + '> ' +
804
+ $e + "]133;B;$($global:__ot_i)" + ([char]7).ToString())
805
+ }
806
+ # EXEC/! 路径的内联执行:C → iex|Out-Host → D。必须在 Enter handler 里跑而
807
+ # 不是 prompt() 里——prompt() 是函数作用域,`$x=1`/`__ot_off=1` 这类赋值会随
808
+ # 函数返回丢失;handler ScriptBlock 捕获的是全局会话状态。输出必须 Out-Host
809
+ # 直写主机,否则会被收集进 prompt 返回值、在 D 标记之后才显示。
810
+ function global:__ot_exec_inline([string]$cmd) {
811
+ __ot_osc("133;C;$($global:__ot_i)")
812
+ $global:LASTEXITCODE = $null
813
+ $ok = $true
814
+ try { Invoke-Expression $cmd | Out-Host } catch { Write-Host $_.Exception.Message; $ok = $false }
815
+ $ec = 0
816
+ if ($null -ne $global:LASTEXITCODE) { $ec = $global:LASTEXITCODE } elseif (-not $ok) { $ec = 1 }
817
+ __ot_osc("133;D;$ec;$($global:__ot_i);$((Get-Location).Path)")
818
+ $global:__ot_skip_d = $true
819
+ $global:LASTEXITCODE = $null
820
+ }
821
+ # 高危英文自然语言(语义同 bash/zsh 侧 __ot_nl_en 注释):首词是「一跑就错/
822
+ # 阻塞/静默成功」的裸命令且行内含英文虚词 → 自然语言。真命令不含虚词,不受影响。
823
+ function global:Test-OtNlEn([string]$first, [string]$line) {
824
+ $risk = @('read','wait','clear','exit','logout','help','test','time',
825
+ 'export','source','kill','jobs','history','set','let','dirs')
826
+ if ($risk -notcontains $first) { return $false }
827
+ $stop = @('the','a','an','and','or','but','for','to','from','with','into',
828
+ 'about','after','before','then','than','me','my','your','our',
829
+ 'their','this','that','these','those','please','is','are','was',
830
+ 'were','be','will','would','should','could','again','up')
831
+ foreach ($w in ($line -split '\s+')) {
832
+ if ($stop -contains $w.ToLower()) { return $true }
833
+ }
834
+ return $false
835
+ }
836
+ Set-PSReadLineKeyHandler -Key Enter -ScriptBlock {
837
+ $line = $null; $cur = 0
838
+ [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cur)
839
+ if ($null -eq $line) { $line = '' }
840
+ # __ot_pad N:清掉自身回显行后打印 N 个空行(卡片占位行由 shell 打印,
841
+ # ConPTY 模型一致)。空行不能在 handler 里 [Console]::Write——PSReadLine
842
+ # 随后的提示符重绘会整块覆盖非它渲染的内容;改成把空行并入 prompt 返回
843
+ # 串(__ot_pad_rows),由 PSReadLine 自己渲染进输出流。
844
+ if ($line.StartsWith('__ot_pad ')) {
845
+ $t = 1
846
+ if (-not [int]::TryParse($line.Substring(9).Trim(), [ref]$t)) { $t = 1 }
847
+ if ($t -lt 1) { $t = 1 } elseif ($t -gt 50) { $t = 50 }
848
+ $global:__ot_pad_rows = $t
849
+ __ot_clearline
850
+ return
851
+ }
852
+ if ($line.StartsWith('__ot_exec__ ')) {
853
+ $inj = $line.Substring(12)
854
+ # 注入行原子性(同 bash/zsh 侧注释):解一层单引号还原原命令。
855
+ # 含单引号的命令走 iex base64 包装分支,不进到这里
856
+ if ($inj.Length -ge 2 -and $inj.StartsWith("'") -and $inj.EndsWith("'")) {
857
+ $inj = $inj.Substring(1, $inj.Length - 2)
858
+ }
859
+ __ot_osc("6337;$($global:__ot_i);EXEC;$inj")
860
+ # AI 工具命令入史:↑/↓ 召回历史命令。base64 包装体解码回可读原文再记;
861
+ # 多行/解码失败跳过(单行召回装不下换行、包装语法进史是垃圾)
862
+ $h = $null
863
+ if ($inj.StartsWith('iex (')) {
864
+ try {
865
+ $q = $inj.Substring($inj.IndexOf("'") + 1)
866
+ $h = [Text.Encoding]::UTF8.GetString(
867
+ [Convert]::FromBase64String($q.Substring(0, $q.IndexOf("'"))))
868
+ } catch { $h = $null }
869
+ } else { $h = $inj }
870
+ if ($h -and -not $h.Contains("`n") -and -not $h.Contains("`r")) {
871
+ [Microsoft.PowerShell.PSConsoleReadLine]::AddToHistory($h)
872
+ }
873
+ __ot_exec_inline $inj
874
+ __ot_clearline
875
+ return
876
+ }
877
+ if ($global:__ot_off -eq 1) { [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine(); return }
878
+ $t = $line.TrimStart()
879
+ if ($t -eq '') { [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine(); return }
880
+ if ($t.StartsWith('?')) {
881
+ # 自然语言不入史(全部 AI 分支同):↑/↓ 召回的是历史命令,不是历史
882
+ # 输入的自然语言
883
+ __ot_osc("6337;$($global:__ot_i);AI;$line")
884
+ __ot_clearline
885
+ return
886
+ }
887
+ if ($t.StartsWith('!')) {
888
+ $t = $t.Substring(1).TrimStart()
889
+ __ot_osc("6337;$($global:__ot_i);CMD;$t")
890
+ [Microsoft.PowerShell.PSConsoleReadLine]::AddToHistory($line)
891
+ __ot_exec_inline $t
892
+ __ot_clearline
893
+ return
894
+ }
895
+ if ($t.StartsWith('/')) {
896
+ # 斜杠命令(/clear 等)交给 worker 拦截:按 AI 上报并清缓冲。
897
+ # 必须放在路径规则($first -match '[\\/]')之前——否则被误判 CMD 原生执行报错
898
+ __ot_osc("6337;$($global:__ot_i);AI;$line")
899
+ __ot_clearline
900
+ return
901
+ }
902
+ $first = ($t -split '[\s;|&()]+')[0]
903
+ # 关键字(exit/if/for…)必须走原生执行:Get-Command 查不到关键字,误判 AI
904
+ # 会把 exit 这类行直接吞掉
905
+ $kw = @('exit','quit','if','else','elseif','foreach','for','while','do',
906
+ 'switch','try','catch','finally','function','return','break',
907
+ 'continue','throw','param','begin','process','end','in')
908
+ if ($kw -contains $first) {
909
+ __ot_osc("6337;$($global:__ot_i);CMD;$t")
910
+ __ot_osc("133;C;$($global:__ot_i)")
911
+ [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
912
+ return
913
+ }
914
+ $resolves = ($first -ne '') -and
915
+ [bool](Get-Command $first -ErrorAction SilentlyContinue)
916
+ if ($resolves -and (Test-OtNlEn $first $t)) {
917
+ # 高危英文自然语言(read the log… / clear up the mess…):首词可解析但
918
+ # 整句是英文话——送 AI,避免 read/wait 阻塞 stdin、clear/exit 静默成功
919
+ __ot_osc("6337;$($global:__ot_i);AI;$line")
920
+ __ot_clearline
921
+ return
922
+ }
923
+ if ($resolves) {
924
+ __ot_osc("6337;$($global:__ot_i);CMD;$t")
925
+ __ot_osc("133;C;$($global:__ot_i)")
926
+ [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
927
+ return
928
+ }
929
+ # 首词不可解析时的原生执行白名单:shell 语法开头($var=、(expr)、{组、
930
+ # `替换)、路径(.\x、/x、C:\x)、剥括号后首词空((get-date).Day、>log)。
931
+ # 旧规则「整行任意位置含 =|&;><`{}() 即 CMD」不看首词,英文自然语言
932
+ # (what does a=b mean、rename "a(1).txt")会被整行原生执行报错
933
+ if ($first -eq '' -or $t -match '^\s*[\$({`~]' -or $first -match '[\\/]') {
934
+ __ot_osc("6337;$($global:__ot_i);CMD;$t")
935
+ __ot_osc("133;C;$($global:__ot_i)")
936
+ [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
937
+ return
938
+ }
939
+ __ot_osc("6337;$($global:__ot_i);AI;$line")
940
+ __ot_clearline
941
+ }
942
+ # Windows EditMode 默认 Ctrl+U 未绑定(字面入 buffer)、Ctrl+Y=Redo。
943
+ # InteractiveRunner 的 Ctrl+U/Ctrl+Y kill-ring 括号依赖这两个显式绑定。
944
+ Set-PSReadLineKeyHandler -Chord 'Ctrl+u' -Function BackwardKillLine
945
+ Set-PSReadLineKeyHandler -Chord 'Ctrl+y' -Function Yank
946
+ '''.strip()
947
+
948
+ _SCRIPTS = {"bash": _BASH_SCRIPT, "zsh": _ZSH_SCRIPT, "powershell": _PS_SCRIPT}
949
+
950
+
951
+ def build_script(shell: str, instance: int) -> str:
952
+ """生成指定 shell / 实例号的集成脚本(多行文本)。"""
953
+ return _SCRIPTS[shell].replace("@@INST@@", str(instance))
954
+
955
+
956
+ def injection_lines(shell: str, instance: int,
957
+ b64flag: str = "-d", chunk: int = 700) -> list[bytes]:
958
+ """注入行拆成分片列表(每片一条完整 shell 行,以 \\r 结尾)。
959
+
960
+ 单行注入动辄 2~4KB,超过 tty canonical 输入缓冲(macOS 本地 ~1024、
961
+ Linux ~4096)会被静默丢弃,集成注入随缘失败。拆成多条短行:前几行
962
+ 分段赋值拼接 base64,末行 eval 还原执行。每片 < chunk 字节,远小于
963
+ 最小的 canonical 缓冲;调用方在片间稍作停顿,等 shell 消费完再发下片。
964
+ b64 字母表不含引号,单引号包裹安全。
965
+ """
966
+ b64 = base64.b64encode(build_script(shell, instance).encode()).decode()
967
+ if shell == "powershell":
968
+ cmd = ("iex ([Text.Encoding]::UTF8.GetString("
969
+ f"[Convert]::FromBase64String('{b64}')))")
970
+ return [cmd.encode() + b"\r"]
971
+ parts = [b64[i:i + chunk] for i in range(0, len(b64), chunk)]
972
+ lines = [f"__ot_inj='{parts[0]}'"]
973
+ for p in parts[1:]:
974
+ lines.append(f"__ot_inj=\"$__ot_inj\"'{p}'")
975
+ lines.append(f'eval "$(echo "$__ot_inj" | base64 {b64flag})"')
976
+ if shell == "bash":
977
+ # bind 命令在 eval 之后独立注入:bind -x 经 eval 注入时 keymap
978
+ # 关联丢失(真机:bash_execute_unix_command cannot find keymap
979
+ # for command),在正常 shell 上下文执行则无此问题。__ot_submit
980
+ # 已由 eval 定义,此处直接绑定即可。
981
+ # 中间键用 \C-o(单字节 0x0F)而非 \e[44~(多字节 CSI 序列)——
982
+ # bash 4.2 的 bind -x 在宏展开时对多字节序列的 keymap 查找失败,
983
+ # 单字节键无此问题(真机 RHEL 7.9 bash 4.2.46 验证)。
984
+ # 静默窗口收口跟在 bind 后(不再放在 eval 脚本尾):收口太早会让
985
+ # 本行落进历史(前导空格只在 ignorespace 仍开时拦得住),↑ 召回
986
+ # 一条 bind 垃圾。本行发完时 \C-m 才换绑,整行走的仍是原生
987
+ # accept-line;快照缺失(静默行没跑过)不动用户 HISTCONTROL。
988
+ lines.append(
989
+ "bind -x '\"\\C-o\": __ot_submit'"
990
+ " 2>/dev/null; bind '\"\\e[45~\": accept-line'"
991
+ " 2>/dev/null; bind '\"\\C-m\": \"\\C-o\\e[45~\"'"
992
+ " 2>/dev/null; bind '\"\\C-j\": \"\\C-o\\e[45~\"' 2>/dev/null"
993
+ "; [ -n \"${__ot_hc+x}\" ] && HISTCONTROL=\"$__ot_hc\"")
994
+ # 前导空格:配合 history_quiet_line 的静默窗口(bash ignorespace / zsh
995
+ # hist_ignore_space)让分片原生跳过入史——用户报障「↑ 召回的全是
996
+ # __ot_inj base64 分片」的根修。hook 在位时首词剥离不受影响,
997
+ # is_internal_line 本就 lstrip;PowerShell 无此机制,维持原样。
998
+ return [b" " + l.encode() + b"\r" for l in lines]
999
+
1000
+
1001
+ def history_quiet_line() -> bytes:
1002
+ """历史静默行(连接/重注入的第一条注入行,\\r 结尾,仅 POSIX shell)。
1003
+
1004
+ 快照用户当前的「前导空格跳过入史」开关(bash HISTCONTROL / zsh
1005
+ hist_ignore_space)并临时打开:此后所有注入行(历史注入 / 探测行 /
1006
+ 分片装配 / eval 解包)都带前导空格,原生入史被整体跳过——用户报障
1007
+ 「按 ↑ 召回的全是 __ot_inj base64 分片」的根修。集成脚本尾部按快照
1008
+ 还原,并清掉旧版本已落盘/已载入的垃圾行(见 _BASH/_ZSH_SCRIPT 尾部
1009
+ 历史保洁注释)。本行自身以 ``__ot_hc=`` 开头:hook 在位时按内部行
1010
+ 静默处理,且被保洁逻辑清除。setopt 在非 zsh 里的报错已 2>/dev/null;
1011
+ PowerShell 不适用(调用方不发)。
1012
+ """
1013
+ return (b'__ot_hc="$HISTCONTROL"; __ot_his="$options[hist_ignore_space]";'
1014
+ b' HISTCONTROL=ignorespace;'
1015
+ b' setopt hist_ignore_space 2>/dev/null\r')
1016
+
1017
+
1018
+ def injection_line(shell: str, instance: int, b64flag: str = "-d") -> bytes:
1019
+ """首次注入行(无 hook 的裸 shell 直接执行,以 \\r 结尾)。
1020
+
1021
+ 单行版本:仅适用于 canonical 缓冲足够大的会话(远端 SSH);本地
1022
+ macOS 会话请用 injection_lines 分片发送。
1023
+ """
1024
+ return b"".join(injection_lines(shell, instance, b64flag))
1025
+
1026
+
1027
+ def history_inject_line(shell: str, commands: list[str],
1028
+ b64flag: str = "-d") -> bytes:
1029
+ """历史记忆注入行:把 DB 里的近期命令灌进远端 shell 历史(单行、\\r 结尾)。
1030
+
1031
+ 在集成脚本注入前(裸 shell 阶段)发送:bash 用 ``history -r``、zsh 用
1032
+ ``fc -R`` 读入临时文件,使 ``history`` 命令与 ↑ 键原生可见。POSIX 行
1033
+ 带前导空格:在 history_quiet_line 打开的静默窗口内原生入史被跳过
1034
+ (旧版 bash 用 ``history -d "$HISTCMD"`` 自删,PTY 实测无效且有误删
1035
+ 相邻条目风险,已由静默窗口 + 脚本尾部保洁取代)。
1036
+ PowerShell 尽力而为:追加进 PSReadLine 历史文件(下一会话生效)。
1037
+ """
1038
+ # 连续重复去重(同一条命令反复执行是常态)+ 丢弃空行
1039
+ seen: list[str] = []
1040
+ for c in commands:
1041
+ c = c.strip()
1042
+ if c and (not seen or seen[-1] != c):
1043
+ seen.append(c)
1044
+ if not seen:
1045
+ return b""
1046
+ b64 = base64.b64encode("\n".join(seen).encode("utf-8")).decode()
1047
+ if shell == "powershell":
1048
+ cmd = (
1049
+ "$p=(Get-PSReadLineOption).HistorySavePath;"
1050
+ "$t=[Text.Encoding]::UTF8.GetString("
1051
+ f"[Convert]::FromBase64String('{b64}'));"
1052
+ "[IO.File]::AppendAllText($p, $t + \"`n\")"
1053
+ )
1054
+ elif shell == "zsh":
1055
+ # zsh 无便捷的「删当前历史项」内建;靠静默窗口前导空格跳过入史
1056
+ cmd = (f'echo {b64} | base64 {b64flag} > "$HOME/.ot_hist_$$" && '
1057
+ f'fc -R "$HOME/.ot_hist_$$" && rm -f "$HOME/.ot_hist_$$"')
1058
+ else: # bash
1059
+ cmd = (f'echo {b64} | base64 {b64flag} > "$HOME/.ot_hist_$$" && '
1060
+ f'history -r "$HOME/.ot_hist_$$" && rm -f "$HOME/.ot_hist_$$"')
1061
+ if shell == "powershell":
1062
+ return cmd.encode() + b"\r"
1063
+ return b" " + cmd.encode() + b"\r"
1064
+
1065
+
1066
+ _INTERNAL_LINE_PREFIXES = ("_ot_inj", "__ot_inj", "__ot_exec__", "__ot_pad",
1067
+ "__ot_hc=")
1068
+ _INTERNAL_LINE_EVAL = 'eval "$(echo "$__ot_inj"'
1069
+
1070
+
1071
+ def is_internal_line(line: str) -> bool:
1072
+ """注入管线内部行(分片装配 / 回显包装)判定。
1073
+
1074
+ 这些行不入历史库:每次任务 _ensure_integrated 重发 ~8 条 ~700B 分片,
1075
+ 记进 history 后回灌单行迅速超过 tty canonical 缓冲(macOS ~1KB),
1076
+ 截断让 shell 卡在续行态、写通道 EAGAIN 死锁(黑屏)。
1077
+ """
1078
+ s = (line or "").lstrip()
1079
+ return s.startswith(_INTERNAL_LINE_PREFIXES) or s.startswith(_INTERNAL_LINE_EVAL)
1080
+
1081
+
1082
+ #: 可明文注入的命令体字符集:单引号包裹后整行恒为「一条命令 + 一个字面
1083
+ #: 参数」,裸壳(``__ot_exec__`` 不在位)解析时不得产生任何副作用。
1084
+ _QUOTABLE_RE = re.compile(r"^[\x20-\x26\x28-\x7e]+$")
1085
+
1086
+
1087
+ def agent_exec_line(shell: str, command: str, b64flag: str = "-d") -> bytes:
1088
+ """AI 工具命令的注入行:``__ot_exec__ `` 前缀 + **单引号包裹**的命令本体。
1089
+
1090
+ 原子性(真机 TMOUT 登出后部分执行的根因):注入行会被落回的裸壳按普通
1091
+ 命令行解析——裸 ``__ot_exec__ <命令>`` 遇到 ``;``/``&&``/``|`` 时整行被
1092
+ 拆段,第一段报 command not found,**其余段照常执行**(本次是只读命令
1093
+ 运气好,破坏性命令会实际生效)。故本体一律单引号包成一个字面参数:
1094
+ 裸壳下只剩一条不存在的命令,任何一段都不得执行;hook 在位时由
1095
+ ``__ot_exec__`` 分支解一层引号还原原命令执行(见 build_script)。
1096
+ 命令含单引号/换行/非 ASCII 时回落 base64 包装(同样恒为单条命令)。
1097
+
1098
+ 不吞回显——Windows ConPTY 按自己的缓冲区模型发绝对光标定位重绘,任何被
1099
+ worker 吞掉的字节都会让 xterm 与 ConPTY 光标失步(实测提示符叠印/内容
1100
+ 覆盖的根因);青色命令回显由 hook 的 ``__ot_repaint``/POSTEDIT 重绘,两种
1101
+ 包装形态观感一致(§5.6)。
1102
+ """
1103
+ if "\n" not in command and "\r" not in command and command \
1104
+ and _QUOTABLE_RE.match(command):
1105
+ return (EXEC_PREFIX + "'" + command + "'").encode() + b"\r"
1106
+ b64 = base64.b64encode(command.encode("utf-8")).decode()
1107
+ if shell == "powershell":
1108
+ body = ("iex ([Text.Encoding]::UTF8.GetString("
1109
+ f"[Convert]::FromBase64String('{b64}')))")
1110
+ else:
1111
+ body = f'eval "$(echo {b64} | base64 {b64flag})"'
1112
+ return (EXEC_PREFIX + body).encode() + b"\r"
1113
+
1114
+
1115
+ def toggle_line(shell: str, agent_on: bool) -> bytes:
1116
+ """Agent/Shell 模式切换:hook 纯透传开关(__ot_exec__ 通道,静默执行)。
1117
+
1118
+ PowerShell 的赋值必须显式 ``$global:``——iex 在函数/handler 作用域里跑,
1119
+ 裸赋值会随作用域销毁(bash 函数内裸赋值默认写全局变量,无此问题)。
1120
+ """
1121
+ val = 0 if agent_on else 1
1122
+ name = "$global:__ot_off" if shell == "powershell" else "__ot_off"
1123
+ return f"{EXEC_PREFIX}{name}={val}".encode() + b"\r"
1124
+
1125
+
1126
+ def probe_command(os_family: str) -> str:
1127
+ """能力探测命令(旧哨兵 run 通道执行):shell 种类 + base64 解码参数。"""
1128
+ if os_family == "windows":
1129
+ return (f"if (Get-Module PSReadLine) {{ Write-Output "
1130
+ f"'{PROBE_TAG}powershell|ok' }} else {{ Write-Output "
1131
+ f"'{PROBE_TAG}none|none' }}")
1132
+ return (
1133
+ 'v=""; [ -n "$BASH_VERSION" ] && v="bash$BASH_VERSION"; '
1134
+ '[ -n "$ZSH_VERSION" ] && v="zsh$ZSH_VERSION"; d="none"; '
1135
+ '[ "$(echo aGVsbG8= | base64 -d 2>/dev/null)" = hello ] && d="-d"; '
1136
+ '[ "$d" = none ] && [ "$(echo aGVsbG8= | base64 -D 2>/dev/null)" = hello ] '
1137
+ '&& d="-D"; '
1138
+ f'echo "{PROBE_TAG}${{v:-none}}|$d"'
1139
+ )
1140
+
1141
+
1142
+ def parse_probe(output: str) -> tuple[str | None, str]:
1143
+ """解析探测输出 → (shell 或 None, base64 解码参数)。None = 走旧批处理模型。
1144
+
1145
+ 版本段用「到 | 为止」的宽松捕获:$BASH_VERSION 真实值形如
1146
+ "5.1.8(1)-release"(带括号/连字符),字符类匹配会在 ( 处截断导致
1147
+ 整体不匹配 → bash 主机永远静默回退批处理模型。
1148
+
1149
+ 逐个遍历全部标记匹配、取第一个可解析的:嵌套 shell(su - / ssh
1150
+ 跳板机)场景探测行整行回显也混进收集缓冲,回显里字面的
1151
+ echo "__OTPROBE__${v:-none}|$d" 模板先于真实输出出现——只取首个
1152
+ 匹配会把模板当结果解析失败,且此后永远解析不出(嵌套换壳注入
1153
+ 永远失败)。模板含 ${ / 引号,ver 段不可能以 bash/zsh/powershell
1154
+ 开头,不会被误认为真实结果。"""
1155
+ for m in re.finditer(
1156
+ re.escape(PROBE_TAG) + r"([^\r\n|]*)\|([^\r\n]*)", output or ""):
1157
+ ver, flag = m.group(1), m.group(2).strip()
1158
+ if ver.startswith("bash"):
1159
+ major = _to_int(re.match(r"bash(\d+)", ver).group(1), 0)
1160
+ if major and major >= 4:
1161
+ return "bash", (flag or "-d")
1162
+ continue # bash 3.2 等旧版:不可集成,看后续匹配(不会有)
1163
+ if ver.startswith("zsh"):
1164
+ return "zsh", (flag or "-d")
1165
+ if ver == "powershell":
1166
+ return "powershell", "-d"
1167
+ return None, "-d"
1168
+
1169
+
1170
+ # ---------------------------------------------------------------------------
1171
+ # 增量流解析器
1172
+ # ---------------------------------------------------------------------------
1173
+
1174
+ class StreamRouter:
1175
+ """按 OSC 标记切分 PTY 字节流的增量解析器。
1176
+
1177
+ 相位:SWALLOW(注入回显/横幅,丢弃数据但解析标记)→ INPUT(live)⇄
1178
+ EXEC(exec)。feed() 返回事件列表:
1179
+
1180
+ - ``("live", text)`` / ``("exec", text)``:已解码文本(相邻同通道合并)
1181
+ - ``("prompt_start", inst)``:A 标记
1182
+ - ``("prompt", plain)``:B 标记,plain 为去 ANSI 的提示符纯文本
1183
+ - ``("exec_start", inst)``:C 标记
1184
+ - ``("exec_end", inst, exit_code|None, cwd|None)``:D 标记
1185
+ - ``("report", inst, kind, line)``:6337 行报告(CMD/AI/EXEC)
1186
+ """
1187
+
1188
+ SWALLOW = "swallow"
1189
+ INPUT = "input"
1190
+ EXEC = "exec"
1191
+
1192
+ #: 未终结 OSC 的最大挂起长度,超过按普通数据放行(防恶意/损坏流卡死)
1193
+ MAX_OSC = 65536
1194
+
1195
+ def __init__(self) -> None:
1196
+ self.reset()
1197
+
1198
+ def reset(self) -> None:
1199
+ self._buf = b""
1200
+ self.phase = self.SWALLOW
1201
+ self.inst = 0
1202
+ self._dec = codecs.getincrementaldecoder("utf-8")("replace")
1203
+ self._in_prompt = False
1204
+ self._prompt_raw = bytearray()
1205
+
1206
+ def feed(self, data: bytes) -> list[tuple]:
1207
+ events: list[tuple] = []
1208
+ self._buf += data
1209
+ while self._buf:
1210
+ i = self._buf.find(b"\x1b")
1211
+ if i < 0:
1212
+ self._emit(self._buf, events)
1213
+ self._buf = b""
1214
+ break
1215
+ if i > 0:
1216
+ self._emit(self._buf[:i], events)
1217
+ self._buf = self._buf[i:]
1218
+ continue
1219
+ # buf 以 ESC 开头
1220
+ if len(self._buf) < 2:
1221
+ break # 序列类型未知,等下一块
1222
+ if self._buf[1:2] != b"]":
1223
+ # 非 OSC 转义(CSI 等):ESC 原样放行,后续字节按数据处理
1224
+ self._emit(b"\x1b", events)
1225
+ self._buf = self._buf[1:]
1226
+ continue
1227
+ found = self._osc_end()
1228
+ if found is None:
1229
+ if len(self._buf) > self.MAX_OSC:
1230
+ self._emit(self._buf, events)
1231
+ self._buf = b""
1232
+ break # OSC 未终结,等下一块
1233
+ body, consumed = found
1234
+ self._buf = self._buf[consumed:]
1235
+ self._osc(body, events)
1236
+ return events
1237
+
1238
+ def _osc_end(self) -> tuple[bytes, int] | None:
1239
+ """在 _buf(以 ESC ] 开头)里找 BEL 或 ESC \\ 终结符。"""
1240
+ b = self._buf
1241
+ j = 2
1242
+ while j < len(b):
1243
+ if b[j] == 0x07:
1244
+ return b[2:j], j + 1
1245
+ if b[j] == 0x1B and b[j + 1:j + 2] == b"\\":
1246
+ return b[2:j], j + 2
1247
+ j += 1
1248
+ return None
1249
+
1250
+ def _emit(self, payload: bytes, events: list[tuple]) -> None:
1251
+ if not payload:
1252
+ return
1253
+ if self._in_prompt:
1254
+ self._prompt_raw.extend(payload)
1255
+ if self.phase == self.SWALLOW:
1256
+ return
1257
+ text = self._dec.decode(payload)
1258
+ if not text:
1259
+ return
1260
+ kind = "exec" if self.phase == self.EXEC else "live"
1261
+ if events and events[-1][0] == kind and len(events[-1]) == 2:
1262
+ events[-1] = (kind, events[-1][1] + text)
1263
+ else:
1264
+ events.append((kind, text))
1265
+
1266
+ def _osc(self, body: bytes, events: list[tuple]) -> None:
1267
+ s = body.decode("utf-8", "replace")
1268
+ parts = s.split(";")
1269
+ head = parts[0]
1270
+ if head == "133" and len(parts) >= 2:
1271
+ code = parts[1]
1272
+ if code == "A":
1273
+ if len(parts) > 2:
1274
+ self.inst = _to_int(parts[2], self.inst)
1275
+ self.phase = self.INPUT
1276
+ self._in_prompt = True
1277
+ self._prompt_raw = bytearray()
1278
+ events.append(("prompt_start", self.inst))
1279
+ elif code == "B":
1280
+ self._in_prompt = False
1281
+ plain = strip_ansi(
1282
+ self._prompt_raw.decode("utf-8", "replace")).rstrip()
1283
+ self._prompt_raw = bytearray()
1284
+ events.append(("prompt", plain))
1285
+ elif code == "C":
1286
+ if len(parts) > 2:
1287
+ self.inst = _to_int(parts[2], self.inst)
1288
+ self.phase = self.EXEC
1289
+ events.append(("exec_start", self.inst))
1290
+ elif code == "D":
1291
+ ec = _to_int(parts[2], None) if len(parts) > 2 else None
1292
+ inst = _to_int(parts[3], self.inst) if len(parts) > 3 else self.inst
1293
+ cwd = ";".join(parts[4:]) if len(parts) > 4 else ""
1294
+ self.phase = self.INPUT
1295
+ events.append(("exec_end", inst, ec, cwd or None))
1296
+ elif head == "6337" and len(parts) >= 4:
1297
+ inst = _to_int(parts[1], self.inst)
1298
+ events.append(("report", inst, parts[2], ";".join(parts[3:])))