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,340 @@
1
+ """有界任务执行器:处理流式事件、中断暴露、resume 与轮次预算。
2
+
3
+ 智能体构建(build_agent)、中间件(DenyMiddleware/ToolErrorRetryMiddleware)、
4
+ 系统提示词(build_system_prompt)、模型工厂(build_chat_model)已拆出本文件。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable
13
+
14
+ from langchain_core.messages import AIMessage, ToolMessage
15
+ from langgraph.errors import GraphRecursionError
16
+
17
+ from .token_est import count_tokens
18
+ from ..core.messages import _dbg
19
+
20
+ # 任务卡死兜底(astream 自身无超时):静默到点即中止任务并给前端 error 收尾,
21
+ # 否则「AI 正在思考」永久不收。两档:无在途工具=模型挂起;有=工具没回。
22
+ AGENT_STALL_TIMEOUT = 120.0 # 无在途工具的静默上限(秒)
23
+ AGENT_TOOL_TIMEOUT = 1800.0 # 有在途工具的静默上限(秒)
24
+
25
+
26
+ class _Stalled(Exception):
27
+ """流静默超时:转成 TaskEvent(error) 收尾,不外抛给 core。"""
28
+
29
+
30
+ @dataclass
31
+ class TaskEvent:
32
+ kind: str
33
+ text: str = ""
34
+ command: str = ""
35
+ reasons: list[str] = field(default_factory=list)
36
+ name: str = "" # tool_start/tool_end:工具名
37
+ index: int = 0 # tool_start/tool_end:start/end 配对序号
38
+ failed: bool = False # tool_end:ToolMessage status=error
39
+
40
+
41
+ def _tool_args_summary(name: str, args: dict) -> str:
42
+ """工具卡参数摘要:逐工具取关键参数(deepagents 内置文件工具的路径
43
+ 参数名是 file_path),未命中按常见参数名兜底,再取首个非空字符串值。"""
44
+ keyed = {"read_file": "file_path", "write_file": "file_path",
45
+ "edit_file": "file_path", "ls": "path", "glob": "pattern",
46
+ "grep": "pattern", "load_skill": "file_path"}
47
+ for key in (keyed.get(name), "file_path", "path", "pattern"):
48
+ v = args.get(key) if key else None
49
+ if isinstance(v, str) and v:
50
+ return v[:120]
51
+ return next((v[:120] for v in args.values() if isinstance(v, str) and v), "")
52
+
53
+
54
+ class _ToolClaimIndex:
55
+ """(name, command) → 有序 seq 队列:审批认领表。
56
+
57
+ HITL 的 ActionRequest 不带 tool_call id,无法直接对回工具宣告;
58
+ 同串并行命令(模型一轮发两条同命令)按宣告序消歧——审批按进队序
59
+ 配对到工具卡,不许错位。"""
60
+
61
+ def __init__(self) -> None:
62
+ self._q: dict[tuple[str, str], list[int]] = {}
63
+
64
+ def register(self, name: str, command: str, seq: int) -> None:
65
+ self._q.setdefault((name, command), []).append(seq)
66
+
67
+ def claim(self, name: str, command: str) -> int | None:
68
+ q = self._q.get((name, command))
69
+ return q.pop(0) if q else None
70
+
71
+
72
+ class TaskRunner:
73
+ """一次用户任务的执行器:处理流式事件、中断暴露、resume 与轮次预算。"""
74
+
75
+ def __init__(
76
+ self, agent, thread_id: str, max_tool_turns: int = 10, *,
77
+ on_event: Callable[[TaskEvent], None] | None = None,
78
+ ) -> None:
79
+ self.agent = agent
80
+ self.cfg = {"configurable": {"thread_id": thread_id}}
81
+ self.max_tool_turns = max_tool_turns
82
+ self.on_event = on_event
83
+ self.interrupt_payload: dict | None = None
84
+ self._tool_calls = 0
85
+ # 非 execute 工具的 start/end 配对记账(前端工具调用小卡)
86
+ self._tool_seq = 0
87
+ self._pending_tools: dict[str, tuple[int, str]] = {}
88
+ self._claims = _ToolClaimIndex() # 审批认领表((name,command)→seq 序列)
89
+ self._tool_seen: set[str] = set() # 已宣告 tool_call_id(只增不减)
90
+ self.input_tokens = 0 # 本次任务的累计输入 token(usage_metadata)
91
+ self.output_tokens = 0 # 本次任务的累计输出 token
92
+ # 会话栏 token 估算(tiktoken,token_est):网关不回传 usage_metadata
93
+ # 时的兜底口径。攒本轮驱动新流入的双向文本,驱动结束一次计价。
94
+ self.est_input_tokens = 0
95
+ self.est_output_tokens = 0
96
+ self._est_in: list[str] = []
97
+ self._est_out: list[str] = []
98
+ self._last_progress = time.monotonic() # 最近一次流片段(静默判据)
99
+
100
+ def claim_seq(self, name: str, command: str) -> int | None:
101
+ """审批认领:(name, command) 按进队序弹出 tool_start 序号(无卡=None)。"""
102
+ return self._claims.claim(name, command)
103
+
104
+ def _add(self, events: list[TaskEvent], ev: TaskEvent) -> None:
105
+ """live 事件:收集进返回列表的同时实时投递给展示层。"""
106
+ events.append(ev)
107
+ if self.on_event is not None:
108
+ self.on_event(ev)
109
+
110
+ def _config(self) -> dict:
111
+ # 每个模型+工具往返约消耗 2 个 superstep,加固定余量
112
+ return self.cfg
113
+
114
+ async def run(self, text: str) -> list[TaskEvent]:
115
+ self._est_in.append(text)
116
+ return await self._drive(
117
+ {"messages": [{"role": "user", "content": text}]}
118
+ )
119
+
120
+ async def resume(self, decisions: list[dict]) -> list[TaskEvent]:
121
+ from langgraph.types import Command
122
+
123
+ return await self._drive(Command(resume={"decisions": decisions}))
124
+
125
+ async def _drive(self, payload) -> list[TaskEvent]:
126
+ events: list[TaskEvent] = []
127
+ self.interrupt_payload = None
128
+ # 偏离计划(最小):langgraph 1.2.11 中 astream(recursion_limit=...)
129
+ # 属于已废弃且被静默忽略的 kwarg(实测不会生效,图会跑到默认上限),
130
+ # 必须写进 config 顶层键才生效;实测每轮模型+工具约耗 3 个 superstep。
131
+ run_cfg = {**self.cfg, "recursion_limit": self.max_tool_turns * 2 + 4}
132
+ self._last_progress = time.monotonic()
133
+ q: asyncio.Queue = asyncio.Queue()
134
+ _dbg("drive: astream start")
135
+
136
+ async def _pump_stream() -> None:
137
+ try:
138
+ async for mode, chunk in self.agent.astream(
139
+ payload, run_cfg,
140
+ stream_mode=["updates", "messages"]):
141
+ self._last_progress = time.monotonic()
142
+ await q.put(("item", mode, chunk))
143
+ except asyncio.CancelledError:
144
+ raise
145
+ except BaseException as e: # noqa: BLE001 - 原样交回主循环再抛
146
+ q.put_nowait(("err", e, None))
147
+ else:
148
+ q.put_nowait(("end", None, None))
149
+
150
+ feeder = asyncio.create_task(_pump_stream())
151
+ try:
152
+ while True:
153
+ try:
154
+ kind, a, b = await asyncio.wait_for(q.get(), 1.0)
155
+ except asyncio.TimeoutError:
156
+ self._check_stall()
157
+ continue
158
+ if kind == "end":
159
+ break
160
+ if kind == "err":
161
+ raise a
162
+ mode, chunk = a, b
163
+ if mode == "messages":
164
+ msg, meta = chunk
165
+ if isinstance(msg, AIMessage) and msg.content and \
166
+ meta.get("langgraph_node") == "model":
167
+ # 思考块与正文分开流式:思考过程实时进前端分析卡
168
+ # (中间过程可见),正文继续走 token 通道
169
+ think, text = _split_content(msg.content)
170
+ if think:
171
+ self._est_out.append(think)
172
+ self._add(events, TaskEvent("think", think))
173
+ if text:
174
+ self._est_out.append(text)
175
+ self._add(events, TaskEvent("token", text))
176
+ # Anthropic 流式在 chunk 的 usage_metadata 里累计
177
+ # input/output token,取峰值即为该次调用的完整用量
178
+ usage = getattr(msg, "usage_metadata", None) or {}
179
+ if usage:
180
+ self.input_tokens = max(
181
+ self.input_tokens, usage.get("input_tokens") or 0)
182
+ self.output_tokens = max(
183
+ self.output_tokens, usage.get("output_tokens") or 0)
184
+ elif mode == "updates":
185
+ for node, val in (chunk or {}).items():
186
+ for m in val.get("messages", []) if isinstance(val, dict) else []:
187
+ await self._absorb(m, node, events)
188
+ except _Stalled as e:
189
+ self._flush_est()
190
+ self._add(events, TaskEvent("error", str(e)))
191
+ return events
192
+ except GraphRecursionError:
193
+ self._flush_est()
194
+ self._add(events, TaskEvent(
195
+ "limit",
196
+ f"已达到工具调用预算({self.max_tool_turns} 轮),任务停止。"
197
+ "请缩小目标或换一种问法。",
198
+ ))
199
+ return events
200
+ finally:
201
+ feeder.cancel()
202
+ self._flush_est()
203
+
204
+ _dbg("drive: astream done, querying state")
205
+ state = await self.agent.aget_state(self.cfg)
206
+ _dbg("drive: state queried")
207
+ for task in state.tasks:
208
+ if task.interrupts:
209
+ self.interrupt_payload = task.interrupts[0].value
210
+ ar = self.interrupt_payload.get("action_requests", [{}])[0]
211
+ events.append(TaskEvent(
212
+ "interrupt", command=ar.get("args", {}).get("command", ""),
213
+ ))
214
+ if not self.interrupt_payload:
215
+ final = self._final_text(state)
216
+ if final:
217
+ events.append(TaskEvent("final", final))
218
+ return events
219
+
220
+ def _check_stall(self) -> None:
221
+ """静默超时判定:到点抛 _Stalled,由 _drive 转 error 事件收尾。
222
+
223
+ 无在途工具=卡在模型调用(网关挂起/连接半死),短阈值即可判定;
224
+ 有在途工具=命令真在跑(长构建合法),给足工具阈值。"""
225
+ idle = time.monotonic() - self._last_progress
226
+ if self._pending_tools:
227
+ if idle > AGENT_TOOL_TIMEOUT:
228
+ raise _Stalled(
229
+ f"工具执行超过 {int(AGENT_TOOL_TIMEOUT)} 秒无响应,任务已中止。"
230
+ "可按 ⏹ 中断后缩小命令范围重试。")
231
+ return
232
+ if idle > AGENT_STALL_TIMEOUT:
233
+ raise _Stalled(
234
+ f"模型超过 {int(AGENT_STALL_TIMEOUT)} 秒无响应,任务已中止。"
235
+ "请稍后重试,或按 ⏹ 重新提问。")
236
+
237
+ def _flush_est(self) -> None:
238
+ """把本轮驱动攒下的双向文本一次计价(tiktoken 按整段切,边界准确)。"""
239
+ if self._est_in:
240
+ self.est_input_tokens += count_tokens("".join(self._est_in))
241
+ self._est_in = []
242
+ if self._est_out:
243
+ self.est_output_tokens += count_tokens("".join(self._est_out))
244
+ self._est_out = []
245
+
246
+ async def _absorb(self, m, node: str, events: list[TaskEvent]) -> None:
247
+ if isinstance(m, ToolMessage):
248
+ # 工具结果回灌模型上下文 = 下轮输入 token 的大头(命令输出)
249
+ self._est_in.append(m.content if isinstance(m.content, str)
250
+ else str(m.content))
251
+ # start/end 按 tool_call_id 配对:execute 也发(远程目标上模型几乎
252
+ # 只走 execute,不发卡则全程无工具卡可见),命令本体展示仍走
253
+ # ai_collapse/输出流原生路径,卡片只做状态 Trail
254
+ pending = self._pending_tools.pop(m.tool_call_id, None)
255
+ if pending is not None:
256
+ self._add(events, TaskEvent(
257
+ "tool_end", name=pending[1], index=pending[0],
258
+ failed=m.status == "error"))
259
+ if isinstance(m, AIMessage) and m.tool_calls:
260
+ for tc in m.tool_calls:
261
+ tid = tc.get("id")
262
+ # HITL after_model 在 resume 后把同一 AIMessage 回灌 updates 流
263
+ # (langchain HumanInTheLoopMiddleware.after_model 返回
264
+ # {"messages": [last_ai_msg, ...]})——按 tool_call_id 幂等:
265
+ # 每个调用全任务只宣告一次;跳过时不动 _tool_seq/_pending_tools,
266
+ # 配对保持指向首次 index,tool_end 才能换徽标回原卡
267
+ if tid and tid in self._tool_seen:
268
+ continue
269
+ if tid:
270
+ self._tool_seen.add(tid)
271
+ if tc["name"] == "execute":
272
+ self._tool_calls += 1
273
+ self._add(events, TaskEvent(
274
+ "tool_call", command=tc["args"].get("command", "")
275
+ ))
276
+ self._tool_seq += 1
277
+ self._pending_tools[tc["id"]] = (self._tool_seq, "execute")
278
+ self._claims.register("execute", tc["args"].get("command", ""),
279
+ self._tool_seq)
280
+ self._add(events, TaskEvent(
281
+ "tool_start", name="execute",
282
+ # 完整 command(不截):审批关联按串匹配,截 120 字符
283
+ # 会让长命令断链(text 仍是 120 字符展示摘要)
284
+ command=tc["args"].get("command", ""),
285
+ text=tc["args"].get("command", "")[:120],
286
+ index=self._tool_seq,
287
+ ))
288
+ else:
289
+ self._tool_seq += 1
290
+ self._pending_tools[tc["id"]] = (self._tool_seq, tc["name"])
291
+ self._claims.register(tc["name"], "", self._tool_seq)
292
+ self._add(events, TaskEvent(
293
+ "tool_start", name=tc["name"],
294
+ text=_tool_args_summary(tc["name"], tc.get("args", {})),
295
+ index=self._tool_seq,
296
+ ))
297
+ if isinstance(m, ToolMessage) and m.status == "error" and "安全策略拒绝" in m.content:
298
+ self._add(events, TaskEvent("denied", text=m.content))
299
+
300
+ def _final_text(self, state) -> str:
301
+ for m in reversed(state.values.get("messages", [])):
302
+ if isinstance(m, AIMessage) and m.content and not m.tool_calls:
303
+ return message_text(m)
304
+ return ""
305
+
306
+
307
+ def _split_content(content) -> tuple[str, str]:
308
+ """消息 content 拆成 (思考, 正文) 两段。
309
+
310
+ 网关模型的 content 有多种形态(实测 127.0.0.1:15721 网关):
311
+ - 字符串:纯正文;
312
+ - block 列表:[{'type': 'thinking', 'thinking': ...}, {'type': 'text',
313
+ 'text': ...}](ainvoke/state 消息形态);
314
+ - 裸字符串混排列表:['', {'type': 'thinking', ...}, '正文'](astream
315
+ chunk 合并形态——正文是裸 str 元素而非 text 块)。
316
+ 思考块单独返回,供前端分析卡展示模型的中间过程。
317
+ """
318
+ if isinstance(content, str):
319
+ return "", content
320
+ thinks: list[str] = []
321
+ texts: list[str] = []
322
+ for block in content or []:
323
+ if isinstance(block, dict):
324
+ if block.get("type") == "thinking":
325
+ thinks.append(block.get("thinking", ""))
326
+ elif block.get("type") == "text":
327
+ texts.append(block.get("text", ""))
328
+ elif isinstance(block, str):
329
+ texts.append(block)
330
+ return "".join(thinks), "".join(texts)
331
+
332
+
333
+ def message_text(msg: AIMessage) -> str:
334
+ """从 AIMessage 取纯文本正文(思考块不进 final 总结)。"""
335
+ return _split_content(msg.content)[1]
336
+
337
+
338
+ def thinking_text(msg: AIMessage) -> str:
339
+ """从 AIMessage 取思考过程文本(无则空串)。"""
340
+ return _split_content(msg.content)[0]
@@ -0,0 +1,164 @@
1
+ """目标系统画像:发行版/包管理器/服务管理器/可用工具。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tomllib
8
+ from dataclasses import asdict, dataclass, field
9
+ from pathlib import Path
10
+
11
+ _DEBIAN_LIKE = {"debian", "ubuntu", "raspbian", "linuxmint", "pop"}
12
+ _RHEL_LIKE = {"centos", "rhel", "fedora", "rocky", "almalinux", "ol", "amzn"}
13
+
14
+
15
+ @dataclass
16
+ class SysFacts:
17
+ os_id: str
18
+ version_id: str
19
+ pretty_name: str
20
+ uname_s: str
21
+ kernel: str
22
+ shell: str
23
+ available: list[str] = field(default_factory=list)
24
+
25
+
26
+ @dataclass
27
+ class SystemProfile:
28
+ host: str
29
+ os_family: str
30
+ distro: str
31
+ version: str
32
+ kernel: str
33
+ pkg_manager: str
34
+ service_mgr: str
35
+ shell: str
36
+ tools: dict[str, str] = field(default_factory=dict)
37
+
38
+
39
+ # 本地目标的画像:不跑探针(POSIX 探针语句在 PowerShell 下无哨兵输出,
40
+ # 会挂满超时),直接用平台常量。CLI 与 Web worker 共用。
41
+ if sys.platform == "win32":
42
+ LOCAL_PROFILE = SystemProfile(
43
+ host="local",
44
+ os_family="windows", distro="Windows", version="", kernel="",
45
+ pkg_manager="winget", service_mgr="sc", shell="powershell",
46
+ tools={},
47
+ )
48
+ else:
49
+ LOCAL_PROFILE = SystemProfile(
50
+ host="local",
51
+ os_family="darwin" if sys.platform == "darwin" else "linux",
52
+ distro="Local", version="", kernel="",
53
+ pkg_manager="brew" if sys.platform == "darwin" else "apt",
54
+ service_mgr="launchd" if sys.platform == "darwin" else "systemd",
55
+ shell=os.environ.get("SHELL", "/bin/bash"), tools={},
56
+ )
57
+
58
+
59
+ def profile_from_facts(host: str, f: SysFacts) -> SystemProfile:
60
+ avail = set(f.available)
61
+ if f.uname_s == "Darwin":
62
+ family, pkg, svc = "darwin", "brew", "launchd"
63
+ elif f.os_id in _DEBIAN_LIKE:
64
+ family, pkg, svc = "debian", "apt", "systemd"
65
+ elif f.os_id in _RHEL_LIKE:
66
+ family = "rhel"
67
+ # RHEL8+/Fedora/Rocky/Alma 默认 dnf;CentOS 7 只有 yum
68
+ pkg = "dnf" if "dnf" in avail else ("yum" if "yum" in avail else "dnf")
69
+ if f.os_id == "centos" and f.version_id.startswith("7"):
70
+ pkg = "yum"
71
+ svc = "systemd"
72
+ elif f.os_id == "alpine":
73
+ family, pkg, svc = "alpine", "apk", "openrc"
74
+ elif f.os_id in {"arch", "manjaro"}:
75
+ family, pkg, svc = "arch", "pacman", "systemd"
76
+ elif "suse" in f.os_id:
77
+ family, pkg, svc = "suse", "zypper", "systemd"
78
+ else:
79
+ family, pkg, svc = "unknown", "yum" if "yum" in avail else "apt", "systemd"
80
+
81
+ tools: dict[str, str] = {}
82
+ for t in ("python3", "python", "docker", "node", "git", "make"):
83
+ if t in avail:
84
+ tools[t] = "yes"
85
+ return SystemProfile(
86
+ host=host, os_family=family,
87
+ distro=f.pretty_name or f.os_id, version=f.version_id,
88
+ kernel=f.kernel, pkg_manager=pkg, service_mgr=svc,
89
+ shell=f.shell, tools=tools,
90
+ )
91
+
92
+
93
+ MANUAL_PRESETS: dict[str, dict] = {
94
+ "ubuntu": {"os_family": "debian", "pkg_manager": "apt", "service_mgr": "systemd"},
95
+ "debian": {"os_family": "debian", "pkg_manager": "apt", "service_mgr": "systemd"},
96
+ "centos7": {"os_family": "rhel", "pkg_manager": "yum", "service_mgr": "systemd"},
97
+ "rocky9": {"os_family": "rhel", "pkg_manager": "dnf", "service_mgr": "systemd"},
98
+ "alpine": {"os_family": "alpine", "pkg_manager": "apk", "service_mgr": "openrc"},
99
+ "arch": {"os_family": "arch", "pkg_manager": "pacman", "service_mgr": "systemd"},
100
+ "macos": {"os_family": "darwin", "pkg_manager": "brew", "service_mgr": "launchd"},
101
+ }
102
+
103
+
104
+ _PROBE_STATEMENTS = [
105
+ "[ -r /etc/os-release ] && . /etc/os-release || true",
106
+ "printf '%s\\n' \"${ID:-}\" \"${VERSION_ID:-}\" \"${PRETTY_NAME:-}\"",
107
+ "uname -s",
108
+ "uname -r",
109
+ "printf '%s\\n' \"${SHELL:-}\"",
110
+ "for c in apt-get dnf yum apk pacman zypper brew systemctl docker python3 "
111
+ "python node git make; do command -v \"$c\" >/dev/null 2>&1 && echo \"$c\"; done",
112
+ "printf '%s\\n' \"__OT_PROBE_DONE__\"",
113
+ ]
114
+
115
+
116
+ async def probe_profile(session, host: str) -> SystemProfile:
117
+ """在会话上执行探测命令并解析为 SystemProfile。"""
118
+ # 必须用 '; ' 连接:每条都是完整语句,拼成单行(不能用空格,否则上一条
119
+ # 会把下一条当成自己的参数,整句语法错误导致哨兵不打印、run 超时)。
120
+ res = await session.run("; ".join(_PROBE_STATEMENTS))
121
+ lines = [l.strip() for l in res.output.splitlines()]
122
+ if "__OT_PROBE_DONE__" in lines:
123
+ lines = lines[: lines.index("__OT_PROBE_DONE__")]
124
+ # 字段:os_id / version / pretty / uname_s / kernel / shell / tools...
125
+ def get(i: int) -> str:
126
+ return lines[i] if i < len(lines) else ""
127
+ facts = SysFacts(
128
+ os_id=get(0), version_id=get(1), pretty_name=get(2),
129
+ uname_s=get(3), kernel=get(4), shell=get(5),
130
+ available=lines[6:],
131
+ )
132
+ if facts.uname_s == "":
133
+ facts.uname_s = "Linux"
134
+ return profile_from_facts(host, facts)
135
+
136
+
137
+ def _toml_escape(v: str) -> str:
138
+ return v.replace("\\", "\\\\").replace('"', '\\"')
139
+
140
+
141
+ def save_host_cache(path: Path, host: str, p: SystemProfile) -> None:
142
+ path.parent.mkdir(parents=True, exist_ok=True)
143
+ block = ["\n[hosts.{}]".format(host.replace(".", "_")),
144
+ f'host = "{_toml_escape(host)}"']
145
+ for k, v in asdict(p).items():
146
+ # host 已显式写入;tools 不进缓存(均跳过,避免 TOML 键重复)
147
+ if k in ("tools", "host"):
148
+ continue
149
+ block.append(f'{k} = "{_toml_escape(str(v))}"')
150
+ block.append("")
151
+ with path.open("a", encoding="utf-8") as f:
152
+ f.write("\n".join(block))
153
+
154
+
155
+ def load_host_cache(path: Path) -> dict[str, SystemProfile]:
156
+ if not path.exists():
157
+ return {}
158
+ data = tomllib.loads(path.read_text(encoding="utf-8"))
159
+ out: dict[str, SystemProfile] = {}
160
+ for _table, m in data.get("hosts", {}).items():
161
+ host = m.get("host", _table)
162
+ out[host] = SystemProfile(host=host, tools={}, **{k: v for k, v in m.items()
163
+ if k != "host"})
164
+ return out
@@ -0,0 +1,49 @@
1
+ """tiktoken 本地 token 估算:状态栏「本次会话消耗」的兜底口径。
2
+
3
+ 真实 usage_metadata 只在模型网关回传时可得;本地代理/第三方聚合网关经常
4
+ 不回传,此时按 tiktoken 对「我们能看到的双向文本」(任务文本+工具输出 ↔
5
+ 思考+正文)估算。BPE 词表首次使用需联网下载,之后走 TIKTOKEN_CACHE_DIR
6
+ 本地缓存(落在应用数据目录,离线可用);tiktoken 未安装或词表不可得时
7
+ 退化为字符启发式——估算绝不能打死主流程。
8
+ """
9
+ import os
10
+
11
+ from ..config import app_dir
12
+
13
+ os.environ.setdefault("TIKTOKEN_CACHE_DIR", str(app_dir() / "tiktoken_cache"))
14
+
15
+ _enc = None
16
+ _enc_failed = False
17
+
18
+
19
+ def _encoding():
20
+ """懒加载 cl100k_base 编码器;不可用返回 None(进程内只试一次)。"""
21
+ global _enc, _enc_failed
22
+ if _enc is not None:
23
+ return _enc
24
+ if _enc_failed:
25
+ return None
26
+ try:
27
+ import tiktoken
28
+ _enc = tiktoken.get_encoding("cl100k_base")
29
+ except Exception: # noqa: BLE001 - 未安装/离线无词表:退化启发式
30
+ _enc_failed = True
31
+ _enc = None
32
+ return _enc
33
+
34
+
35
+ def count_tokens(text: str) -> int:
36
+ """文本 → token 数(估算口径)。"""
37
+ if not text:
38
+ return 0
39
+ enc = _encoding()
40
+ if enc is not None:
41
+ try:
42
+ # disallowed_special=():正文里的 "<|...|>" 形态按普通文本计价,
43
+ # 不当特殊 token 抛错
44
+ return len(enc.encode(text, disallowed_special=()))
45
+ except Exception: # noqa: BLE001 - 编码失败走启发式
46
+ pass
47
+ cjk = sum(1 for ch in text if "一" <= ch <= "鿿")
48
+ other = len(text) - cjk
49
+ return max(1, int(cjk * 1.6) + (other + 3) // 4)
openterminal/app.py ADDED
@@ -0,0 +1,52 @@
1
+ """ot / openterminal 入口。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from dotenv import load_dotenv
11
+
12
+ from .config import Config, app_dir
13
+
14
+
15
+ def main() -> None:
16
+ for p in (Path.cwd() / ".env", app_dir() / ".env"):
17
+ if p.exists():
18
+ load_dotenv(p)
19
+ cfg = Config.load()
20
+ # ot web [--host X] [--port N] [--token T] [--no-open]
21
+ if len(sys.argv) >= 2 and sys.argv[1] == "web":
22
+ from .web.server import run_web
23
+
24
+ run_web(cfg, argv=sys.argv[2:])
25
+ return
26
+ # ot exec -t <目标> -c <命令> / ot list targets —— 无头执行面(headless.py)
27
+ # 输出要保持纯净供脚本消费,不打印欢迎横幅
28
+ if len(sys.argv) >= 2 and sys.argv[1] in {"exec", "list"}:
29
+ from .frontend.headless import run_headless
30
+
31
+ raise SystemExit(run_headless(sys.argv[1:], cfg=cfg))
32
+ from .frontend.banner import print_banner
33
+
34
+ print_banner()
35
+ # ot connect <ssh配置别名> / ot ssh user@host:port
36
+ initial = None
37
+ if len(sys.argv) >= 3 and sys.argv[1] in {"connect", "ssh"}:
38
+ initial = sys.argv[2]
39
+ # 无目标参数 → 启动选择框(local / 记住的连接 / 添加新连接)
40
+ # Cli 惰性导入:cli→term_frontend→core 链很重,只有交互 CLI 分支才需要;
41
+ # `ot list`/`ot exec`/`ot web` 走上面的早返回,不该付这份导入成本。
42
+ from .cli import Cli
43
+
44
+ cli = Cli(cfg, initial_target=initial)
45
+ try:
46
+ asyncio.run(cli.loop())
47
+ except KeyboardInterrupt:
48
+ pass
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
@@ -0,0 +1,7 @@
1
+ """旧路径壳(阶段二 Task 15):实现已迁至 frontend/approval.py。"""
2
+
3
+ from .frontend.approval import * # noqa: F401,F403
4
+ from .frontend import approval as _impl # noqa: F401
5
+
6
+ def __getattr__(name):
7
+ return getattr(_impl, name)
@@ -0,0 +1,3 @@
1
+ """旧路径壳(阶段二 Task 14):实现已迁至 agent/backend.py。"""
2
+
3
+ from .agent.backend import PtyShellBackend # noqa: F401
openterminal/banner.py ADDED
@@ -0,0 +1,7 @@
1
+ """旧路径壳(阶段二 Task 15):实现已迁至 frontend/banner.py。"""
2
+
3
+ from .frontend.banner import * # noqa: F401,F403
4
+ from .frontend import banner as _impl # noqa: F401
5
+
6
+ def __getattr__(name):
7
+ return getattr(_impl, name)