nonebot-plugin-codex 0.1.3__tar.gz → 0.1.5__tar.gz
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.
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/PKG-INFO +19 -4
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/README.md +18 -3
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/pyproject.toml +1 -1
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/__init__.py +22 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/config.py +1 -1
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/native_client.py +24 -6
- nonebot_plugin_codex-0.1.5/src/nonebot_plugin_codex/protocol_io.py +131 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/service.py +258 -7
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram.py +132 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram_commands.py +10 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_config.py +6 -0
- nonebot_plugin_codex-0.1.5/tests/test_native_client.py +401 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_release_notes.py +2 -1
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_service.py +155 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_commands.py +7 -2
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_handlers.py +186 -0
- nonebot_plugin_codex-0.1.3/tests/test_native_client.py +0 -128
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/LICENSE +0 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/runtime.py +0 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram_rendering.py +0 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/__init__.py +0 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/conftest.py +0 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_plugin_entry.py +0 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_plugin_meta.py +0 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_runtime.py +0 -0
- {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_rendering.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: nonebot-plugin-codex
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Telegram bridge plugin for driving Codex from NoneBot
|
|
5
5
|
Author-Email: ttiee <469784630@qq.com>
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -40,6 +40,13 @@ _✨ 在 Telegram 里驱动 Codex CLI 的 NoneBot 插件 ✨_
|
|
|
40
40
|
|
|
41
41
|
</div>
|
|
42
42
|
|
|
43
|
+
<p align="center">
|
|
44
|
+
<img src="docs/images/readme/1.jpg" width="24%" alt="nonebot-plugin-codex screenshot 1">
|
|
45
|
+
<img src="docs/images/readme/2.jpg" width="24%" alt="nonebot-plugin-codex screenshot 2">
|
|
46
|
+
<img src="docs/images/readme/3.jpg" width="24%" alt="nonebot-plugin-codex screenshot 3">
|
|
47
|
+
<img src="docs/images/readme/4.jpg" width="24%" alt="nonebot-plugin-codex screenshot 4">
|
|
48
|
+
</p>
|
|
49
|
+
|
|
43
50
|
## 项目介绍
|
|
44
51
|
|
|
45
52
|
`nonebot-plugin-codex` 是一个面向 Telegram 场景的 NoneBot 插件,用来把本机 `codex` CLI 暴露为可对话、可续聊、可管理工作目录的聊天式开发助手。
|
|
@@ -122,6 +129,7 @@ codex_workdir = "/home/yourname"
|
|
|
122
129
|
|
|
123
130
|
```text
|
|
124
131
|
/codex
|
|
132
|
+
/panel
|
|
125
133
|
/cd /home/yourname/projects/demo
|
|
126
134
|
/mode resume
|
|
127
135
|
然后继续直接发送普通文本消息续聊
|
|
@@ -129,6 +137,8 @@ codex_workdir = "/home/yourname"
|
|
|
129
137
|
|
|
130
138
|
`/codex` 不带参数时会打开一个 Telegram 内的使用引导面板,方便你直接查看当前模式、工作目录、设置摘要,并进入目录浏览、设置面板或历史会话。
|
|
131
139
|
|
|
140
|
+
`/panel` 和 `/status` 会打开统一的“当前工作台”面板,把模式、模型、推理强度、权限、工作目录、当前会话状态和最近历史摘要放在同一屏里,并提供进入设置、目录、历史、新会话和停止会话的快捷操作。
|
|
141
|
+
|
|
132
142
|
你也可以直接把首条任务跟在 `/codex` 后面:
|
|
133
143
|
|
|
134
144
|
```text
|
|
@@ -147,6 +157,7 @@ codex_workdir = "/home/yourname"
|
|
|
147
157
|
```text
|
|
148
158
|
/help
|
|
149
159
|
/start
|
|
160
|
+
/panel
|
|
150
161
|
```
|
|
151
162
|
|
|
152
163
|
## 配置说明
|
|
@@ -176,8 +187,8 @@ codex_diagnostic_history = 20
|
|
|
176
187
|
# 单条 Telegram 消息的分片长度,过长回复会自动拆分
|
|
177
188
|
codex_chunk_size = 3500
|
|
178
189
|
|
|
179
|
-
#
|
|
180
|
-
codex_stream_read_limit =
|
|
190
|
+
# 单条 Codex 协议消息允许的最大字节数
|
|
191
|
+
codex_stream_read_limit = 8388608
|
|
181
192
|
|
|
182
193
|
```
|
|
183
194
|
|
|
@@ -185,7 +196,8 @@ codex_stream_read_limit = 1048576
|
|
|
185
196
|
|
|
186
197
|
- `codex_binary`:如果宿主机不是直接执行 `codex`,改成实际绝对路径。
|
|
187
198
|
- `codex_workdir`:默认工作目录,也是 `/cd` 相对路径解析与目录浏览器 Home 的基准。
|
|
188
|
-
-
|
|
199
|
+
- `codex_stream_read_limit`:限制单条 Codex 协议帧的最大字节数,不是 Telegram 消息分片长度。
|
|
200
|
+
- 其余项分别控制停止超时、进度保留条数、诊断输出条数和 Telegram 分片长度。
|
|
189
201
|
- 插件自己的配置数据由 `nonebot-plugin-localstore` 自动管理。
|
|
190
202
|
- 模型缓存、Codex CLI 配置和历史会话目录默认读取 `~/.codex/*`,属于插件内部实现路径。
|
|
191
203
|
|
|
@@ -196,6 +208,8 @@ codex_stream_read_limit = 1048576
|
|
|
196
208
|
| `/codex [prompt]` | 打开引导面板,或直接附带首条任务连接 Codex |
|
|
197
209
|
| `/help` | 打开使用引导面板 |
|
|
198
210
|
| `/start` | 打开使用引导面板 |
|
|
211
|
+
| `/panel` | 打开统一工作台面板 |
|
|
212
|
+
| `/status` | 打开统一工作台面板 |
|
|
199
213
|
| `/mode [resume\|exec]` | 查看或切换默认模式 |
|
|
200
214
|
| `/exec <prompt>` | 以一次性 `exec` 模式执行任务 |
|
|
201
215
|
| `/new` | 新建当前聊天会话 |
|
|
@@ -229,6 +243,7 @@ codex_stream_read_limit = 1048576
|
|
|
229
243
|
|
|
230
244
|
## 目录与历史会话
|
|
231
245
|
|
|
246
|
+
- `/panel` 或 `/status` 会打开统一工作台,一屏查看当前设置、工作目录、会话状态和最近历史,并跳转到常用控制面板。
|
|
232
247
|
- `/cd` 可打开目录浏览器,逐级进入目录、切换 Home、显示隐藏目录,并把当前浏览目录设置为工作目录。
|
|
233
248
|
- `/sessions` 会列出 native 与 exec 历史会话,便于恢复此前任务。
|
|
234
249
|
- 历史会话恢复时会尝试切回原始工作目录;如果原目录不存在,会保留当前目录并给出提示。
|
|
@@ -27,6 +27,13 @@ _✨ 在 Telegram 里驱动 Codex CLI 的 NoneBot 插件 ✨_
|
|
|
27
27
|
|
|
28
28
|
</div>
|
|
29
29
|
|
|
30
|
+
<p align="center">
|
|
31
|
+
<img src="docs/images/readme/1.jpg" width="24%" alt="nonebot-plugin-codex screenshot 1">
|
|
32
|
+
<img src="docs/images/readme/2.jpg" width="24%" alt="nonebot-plugin-codex screenshot 2">
|
|
33
|
+
<img src="docs/images/readme/3.jpg" width="24%" alt="nonebot-plugin-codex screenshot 3">
|
|
34
|
+
<img src="docs/images/readme/4.jpg" width="24%" alt="nonebot-plugin-codex screenshot 4">
|
|
35
|
+
</p>
|
|
36
|
+
|
|
30
37
|
## 项目介绍
|
|
31
38
|
|
|
32
39
|
`nonebot-plugin-codex` 是一个面向 Telegram 场景的 NoneBot 插件,用来把本机 `codex` CLI 暴露为可对话、可续聊、可管理工作目录的聊天式开发助手。
|
|
@@ -109,6 +116,7 @@ codex_workdir = "/home/yourname"
|
|
|
109
116
|
|
|
110
117
|
```text
|
|
111
118
|
/codex
|
|
119
|
+
/panel
|
|
112
120
|
/cd /home/yourname/projects/demo
|
|
113
121
|
/mode resume
|
|
114
122
|
然后继续直接发送普通文本消息续聊
|
|
@@ -116,6 +124,8 @@ codex_workdir = "/home/yourname"
|
|
|
116
124
|
|
|
117
125
|
`/codex` 不带参数时会打开一个 Telegram 内的使用引导面板,方便你直接查看当前模式、工作目录、设置摘要,并进入目录浏览、设置面板或历史会话。
|
|
118
126
|
|
|
127
|
+
`/panel` 和 `/status` 会打开统一的“当前工作台”面板,把模式、模型、推理强度、权限、工作目录、当前会话状态和最近历史摘要放在同一屏里,并提供进入设置、目录、历史、新会话和停止会话的快捷操作。
|
|
128
|
+
|
|
119
129
|
你也可以直接把首条任务跟在 `/codex` 后面:
|
|
120
130
|
|
|
121
131
|
```text
|
|
@@ -134,6 +144,7 @@ codex_workdir = "/home/yourname"
|
|
|
134
144
|
```text
|
|
135
145
|
/help
|
|
136
146
|
/start
|
|
147
|
+
/panel
|
|
137
148
|
```
|
|
138
149
|
|
|
139
150
|
## 配置说明
|
|
@@ -163,8 +174,8 @@ codex_diagnostic_history = 20
|
|
|
163
174
|
# 单条 Telegram 消息的分片长度,过长回复会自动拆分
|
|
164
175
|
codex_chunk_size = 3500
|
|
165
176
|
|
|
166
|
-
#
|
|
167
|
-
codex_stream_read_limit =
|
|
177
|
+
# 单条 Codex 协议消息允许的最大字节数
|
|
178
|
+
codex_stream_read_limit = 8388608
|
|
168
179
|
|
|
169
180
|
```
|
|
170
181
|
|
|
@@ -172,7 +183,8 @@ codex_stream_read_limit = 1048576
|
|
|
172
183
|
|
|
173
184
|
- `codex_binary`:如果宿主机不是直接执行 `codex`,改成实际绝对路径。
|
|
174
185
|
- `codex_workdir`:默认工作目录,也是 `/cd` 相对路径解析与目录浏览器 Home 的基准。
|
|
175
|
-
-
|
|
186
|
+
- `codex_stream_read_limit`:限制单条 Codex 协议帧的最大字节数,不是 Telegram 消息分片长度。
|
|
187
|
+
- 其余项分别控制停止超时、进度保留条数、诊断输出条数和 Telegram 分片长度。
|
|
176
188
|
- 插件自己的配置数据由 `nonebot-plugin-localstore` 自动管理。
|
|
177
189
|
- 模型缓存、Codex CLI 配置和历史会话目录默认读取 `~/.codex/*`,属于插件内部实现路径。
|
|
178
190
|
|
|
@@ -183,6 +195,8 @@ codex_stream_read_limit = 1048576
|
|
|
183
195
|
| `/codex [prompt]` | 打开引导面板,或直接附带首条任务连接 Codex |
|
|
184
196
|
| `/help` | 打开使用引导面板 |
|
|
185
197
|
| `/start` | 打开使用引导面板 |
|
|
198
|
+
| `/panel` | 打开统一工作台面板 |
|
|
199
|
+
| `/status` | 打开统一工作台面板 |
|
|
186
200
|
| `/mode [resume\|exec]` | 查看或切换默认模式 |
|
|
187
201
|
| `/exec <prompt>` | 以一次性 `exec` 模式执行任务 |
|
|
188
202
|
| `/new` | 新建当前聊天会话 |
|
|
@@ -216,6 +230,7 @@ codex_stream_read_limit = 1048576
|
|
|
216
230
|
|
|
217
231
|
## 目录与历史会话
|
|
218
232
|
|
|
233
|
+
- `/panel` 或 `/status` 会打开统一工作台,一屏查看当前设置、工作目录、会话状态和最近历史,并跳转到常用控制面板。
|
|
219
234
|
- `/cd` 可打开目录浏览器,逐级进入目录、切换 Home、显示隐藏目录,并把当前浏览目录设置为工作目录。
|
|
220
235
|
- `/sessions` 会列出 native 与 exec 历史会话,便于恢复此前任务。
|
|
221
236
|
- 历史会话恢复时会尝试切回原始工作目录;如果原目录不存在,会保留当前目录并给出提示。
|
{nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/__init__.py
RENAMED
|
@@ -88,6 +88,8 @@ if _runtime_ready:
|
|
|
88
88
|
codex_cmd = on_command("codex", priority=10, block=True)
|
|
89
89
|
help_cmd = on_command("help", priority=10, block=True)
|
|
90
90
|
start_cmd = on_command("start", priority=10, block=True)
|
|
91
|
+
panel_cmd = on_command("panel", priority=10, block=True)
|
|
92
|
+
status_cmd = on_command("status", priority=10, block=True)
|
|
91
93
|
mode_cmd = on_command("mode", priority=10, block=True)
|
|
92
94
|
exec_cmd = on_command("exec", priority=10, block=True)
|
|
93
95
|
new_cmd = on_command("new", priority=10, block=True)
|
|
@@ -125,6 +127,12 @@ if _runtime_ready:
|
|
|
125
127
|
block=True,
|
|
126
128
|
rule=handlers.is_onboarding_callback,
|
|
127
129
|
)
|
|
130
|
+
workspace_callback = on_type(
|
|
131
|
+
CallbackQueryEvent,
|
|
132
|
+
priority=10,
|
|
133
|
+
block=True,
|
|
134
|
+
rule=handlers.is_workspace_callback,
|
|
135
|
+
)
|
|
128
136
|
|
|
129
137
|
@codex_cmd.handle()
|
|
130
138
|
async def _handle_codex(
|
|
@@ -140,6 +148,14 @@ if _runtime_ready:
|
|
|
140
148
|
async def _handle_start(bot: Bot, event: MessageEvent) -> None:
|
|
141
149
|
await handlers.handle_start(bot, event)
|
|
142
150
|
|
|
151
|
+
@panel_cmd.handle()
|
|
152
|
+
async def _handle_panel(bot: Bot, event: MessageEvent) -> None:
|
|
153
|
+
await handlers.handle_panel(bot, event)
|
|
154
|
+
|
|
155
|
+
@status_cmd.handle()
|
|
156
|
+
async def _handle_status(bot: Bot, event: MessageEvent) -> None:
|
|
157
|
+
await handlers.handle_status(bot, event)
|
|
158
|
+
|
|
143
159
|
@mode_cmd.handle()
|
|
144
160
|
async def _handle_mode(
|
|
145
161
|
bot: Bot, event: MessageEvent, args: Message = CommandArg()
|
|
@@ -220,6 +236,12 @@ if _runtime_ready:
|
|
|
220
236
|
) -> None:
|
|
221
237
|
await handlers.handle_onboarding_callback(bot, event)
|
|
222
238
|
|
|
239
|
+
@workspace_callback.handle()
|
|
240
|
+
async def _handle_workspace_callback(
|
|
241
|
+
bot: Bot, event: CallbackQueryEvent
|
|
242
|
+
) -> None:
|
|
243
|
+
await handlers.handle_workspace_callback(bot, event)
|
|
244
|
+
|
|
223
245
|
@follow_up.handle()
|
|
224
246
|
async def _handle_follow_up(bot: Bot, event: MessageEvent) -> None:
|
|
225
247
|
await handlers.handle_follow_up(bot, event)
|
{nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/native_client.py
RENAMED
|
@@ -7,6 +7,8 @@ from dataclasses import field, dataclass
|
|
|
7
7
|
from typing import Any
|
|
8
8
|
from collections.abc import Callable, Awaitable
|
|
9
9
|
|
|
10
|
+
from .protocol_io import NdjsonProcessReader, ProtocolStreamError
|
|
11
|
+
|
|
10
12
|
Callback = Callable[[str], object]
|
|
11
13
|
ProcessLauncher = Callable[..., Awaitable[Any]]
|
|
12
14
|
|
|
@@ -93,7 +95,7 @@ class NativeCodexClient:
|
|
|
93
95
|
launcher: ProcessLauncher | None = None,
|
|
94
96
|
client_name: str = "tg_bot",
|
|
95
97
|
client_version: str = "0",
|
|
96
|
-
stream_read_limit: int = 1024 * 1024,
|
|
98
|
+
stream_read_limit: int = 8 * 1024 * 1024,
|
|
97
99
|
) -> None:
|
|
98
100
|
self.binary = binary
|
|
99
101
|
self.launcher = launcher or asyncio.create_subprocess_exec
|
|
@@ -101,6 +103,7 @@ class NativeCodexClient:
|
|
|
101
103
|
self.client_version = client_version
|
|
102
104
|
self.stream_read_limit = stream_read_limit
|
|
103
105
|
self._process: Any = None
|
|
106
|
+
self._reader: NdjsonProcessReader | None = None
|
|
104
107
|
self._initialized = False
|
|
105
108
|
self._next_request_id = 1
|
|
106
109
|
|
|
@@ -115,10 +118,14 @@ class NativeCodexClient:
|
|
|
115
118
|
|
|
116
119
|
async def close(self, timeout: float = 5.0) -> None:
|
|
117
120
|
process = self._process
|
|
121
|
+
reader = self._reader
|
|
118
122
|
self._process = None
|
|
123
|
+
self._reader = None
|
|
119
124
|
self._initialized = False
|
|
120
125
|
self._next_request_id = 1
|
|
121
126
|
await _terminate_process(process, timeout)
|
|
127
|
+
if reader is not None:
|
|
128
|
+
await reader.wait_closed()
|
|
122
129
|
|
|
123
130
|
async def start_thread(
|
|
124
131
|
self,
|
|
@@ -322,9 +329,13 @@ class NativeCodexClient:
|
|
|
322
329
|
"stdio://",
|
|
323
330
|
stdin=asyncio.subprocess.PIPE,
|
|
324
331
|
stdout=asyncio.subprocess.PIPE,
|
|
325
|
-
stderr=asyncio.subprocess.
|
|
332
|
+
stderr=asyncio.subprocess.PIPE,
|
|
326
333
|
limit=self.stream_read_limit,
|
|
327
334
|
)
|
|
335
|
+
self._reader = NdjsonProcessReader(
|
|
336
|
+
self._process,
|
|
337
|
+
frame_limit=self.stream_read_limit,
|
|
338
|
+
)
|
|
328
339
|
request_id = self._allocate_request_id()
|
|
329
340
|
await self._write_message(
|
|
330
341
|
{
|
|
@@ -398,12 +409,19 @@ class NativeCodexClient:
|
|
|
398
409
|
return result
|
|
399
410
|
|
|
400
411
|
async def _read_message(self, diagnostics: list[str]) -> dict[str, Any] | None:
|
|
401
|
-
if self._process is None or
|
|
412
|
+
if self._process is None or self._reader is None:
|
|
402
413
|
raise RuntimeError("Codex app-server 尚未启动。")
|
|
403
|
-
|
|
404
|
-
|
|
414
|
+
|
|
415
|
+
diagnostics.extend(self._reader.drain_stderr_lines())
|
|
416
|
+
try:
|
|
417
|
+
line = await self._reader.read_stdout_line()
|
|
418
|
+
except ProtocolStreamError as exc:
|
|
419
|
+
diagnostics.extend(self._reader.drain_stderr_lines())
|
|
420
|
+
raise RuntimeError(str(exc)) from exc
|
|
421
|
+
diagnostics.extend(self._reader.drain_stderr_lines())
|
|
422
|
+
|
|
423
|
+
if line is None:
|
|
405
424
|
raise RuntimeError("Codex app-server 已提前退出。")
|
|
406
|
-
line = raw_line.decode("utf-8", errors="replace").strip()
|
|
407
425
|
if not line:
|
|
408
426
|
return None
|
|
409
427
|
try:
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
READ_CHUNK_SIZE = 4096
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def oversized_frame_message(limit: int) -> str:
|
|
10
|
+
return (
|
|
11
|
+
"Codex 返回的单条协议消息超过 "
|
|
12
|
+
f"`codex_stream_read_limit`(当前 {limit} 字节)。"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def incomplete_frame_message() -> str:
|
|
17
|
+
return "Codex 返回了不完整的协议消息。"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def truncated_stderr_message(limit: int) -> str:
|
|
21
|
+
return (
|
|
22
|
+
"Codex stderr 单行输出超过 "
|
|
23
|
+
f"`codex_stream_read_limit`(当前 {limit} 字节),已截断。"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProtocolStreamError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class NdjsonProcessReader:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
process: Any,
|
|
35
|
+
*,
|
|
36
|
+
frame_limit: int,
|
|
37
|
+
read_chunk_size: int = READ_CHUNK_SIZE,
|
|
38
|
+
) -> None:
|
|
39
|
+
self._stdout = getattr(process, "stdout", None)
|
|
40
|
+
self._stderr = getattr(process, "stderr", None)
|
|
41
|
+
self._frame_limit = frame_limit
|
|
42
|
+
self._read_chunk_size = max(1, read_chunk_size)
|
|
43
|
+
self._stdout_buffer = bytearray()
|
|
44
|
+
self._stderr_buffer = bytearray()
|
|
45
|
+
self._stderr_lines: list[str] = []
|
|
46
|
+
self._stderr_skipping_line = False
|
|
47
|
+
self._stderr_task: asyncio.Task[None] | None = None
|
|
48
|
+
if self._stderr is not None:
|
|
49
|
+
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
|
50
|
+
|
|
51
|
+
async def read_stdout_line(self) -> str | None:
|
|
52
|
+
if self._stdout is None:
|
|
53
|
+
raise RuntimeError("Codex 协议 stdout 不可用。")
|
|
54
|
+
|
|
55
|
+
while True:
|
|
56
|
+
newline_index = self._stdout_buffer.find(b"\n")
|
|
57
|
+
if newline_index >= 0:
|
|
58
|
+
frame = bytes(self._stdout_buffer[: newline_index + 1])
|
|
59
|
+
del self._stdout_buffer[: newline_index + 1]
|
|
60
|
+
if newline_index > self._frame_limit:
|
|
61
|
+
raise ProtocolStreamError(oversized_frame_message(self._frame_limit))
|
|
62
|
+
return frame.decode("utf-8", errors="replace").strip()
|
|
63
|
+
|
|
64
|
+
if len(self._stdout_buffer) > self._frame_limit:
|
|
65
|
+
self._stdout_buffer.clear()
|
|
66
|
+
raise ProtocolStreamError(oversized_frame_message(self._frame_limit))
|
|
67
|
+
|
|
68
|
+
chunk = await self._stdout.read(self._read_chunk_size)
|
|
69
|
+
if not chunk:
|
|
70
|
+
if self._stdout_buffer:
|
|
71
|
+
self._stdout_buffer.clear()
|
|
72
|
+
raise ProtocolStreamError(incomplete_frame_message())
|
|
73
|
+
return None
|
|
74
|
+
self._stdout_buffer.extend(chunk)
|
|
75
|
+
|
|
76
|
+
def drain_stderr_lines(self) -> list[str]:
|
|
77
|
+
lines = list(self._stderr_lines)
|
|
78
|
+
self._stderr_lines.clear()
|
|
79
|
+
return lines
|
|
80
|
+
|
|
81
|
+
async def wait_closed(self) -> None:
|
|
82
|
+
if self._stderr_task is not None:
|
|
83
|
+
await self._stderr_task
|
|
84
|
+
|
|
85
|
+
async def _drain_stderr(self) -> None:
|
|
86
|
+
if self._stderr is None:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
while True:
|
|
90
|
+
chunk = await self._stderr.read(self._read_chunk_size)
|
|
91
|
+
if not chunk:
|
|
92
|
+
break
|
|
93
|
+
self._stderr_buffer.extend(chunk)
|
|
94
|
+
self._consume_stderr_buffer(final=False)
|
|
95
|
+
|
|
96
|
+
self._consume_stderr_buffer(final=True)
|
|
97
|
+
|
|
98
|
+
def _consume_stderr_buffer(self, *, final: bool) -> None:
|
|
99
|
+
while True:
|
|
100
|
+
if self._stderr_skipping_line:
|
|
101
|
+
newline_index = self._stderr_buffer.find(b"\n")
|
|
102
|
+
if newline_index < 0:
|
|
103
|
+
if final:
|
|
104
|
+
self._stderr_buffer.clear()
|
|
105
|
+
self._stderr_skipping_line = False
|
|
106
|
+
return
|
|
107
|
+
del self._stderr_buffer[: newline_index + 1]
|
|
108
|
+
self._stderr_skipping_line = False
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
newline_index = self._stderr_buffer.find(b"\n")
|
|
112
|
+
if newline_index >= 0:
|
|
113
|
+
frame = bytes(self._stderr_buffer[: newline_index + 1])
|
|
114
|
+
del self._stderr_buffer[: newline_index + 1]
|
|
115
|
+
line = frame.decode("utf-8", errors="replace").strip()
|
|
116
|
+
if line:
|
|
117
|
+
self._stderr_lines.append(line)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
if len(self._stderr_buffer) > self._frame_limit:
|
|
121
|
+
self._stderr_lines.append(truncated_stderr_message(self._frame_limit))
|
|
122
|
+
self._stderr_buffer.clear()
|
|
123
|
+
self._stderr_skipping_line = True
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
if final:
|
|
127
|
+
line = self._stderr_buffer.decode("utf-8", errors="replace").strip()
|
|
128
|
+
self._stderr_buffer.clear()
|
|
129
|
+
if line:
|
|
130
|
+
self._stderr_lines.append(line)
|
|
131
|
+
return
|