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.
Files changed (26) hide show
  1. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/PKG-INFO +19 -4
  2. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/README.md +18 -3
  3. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/pyproject.toml +1 -1
  4. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/__init__.py +22 -0
  5. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/config.py +1 -1
  6. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/native_client.py +24 -6
  7. nonebot_plugin_codex-0.1.5/src/nonebot_plugin_codex/protocol_io.py +131 -0
  8. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/service.py +258 -7
  9. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram.py +132 -0
  10. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram_commands.py +10 -0
  11. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_config.py +6 -0
  12. nonebot_plugin_codex-0.1.5/tests/test_native_client.py +401 -0
  13. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_release_notes.py +2 -1
  14. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_service.py +155 -0
  15. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_commands.py +7 -2
  16. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_telegram_handlers.py +186 -0
  17. nonebot_plugin_codex-0.1.3/tests/test_native_client.py +0 -128
  18. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/LICENSE +0 -0
  19. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/runtime.py +0 -0
  20. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/src/nonebot_plugin_codex/telegram_rendering.py +0 -0
  21. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/__init__.py +0 -0
  22. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/conftest.py +0 -0
  23. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_plugin_entry.py +0 -0
  24. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_plugin_meta.py +0 -0
  25. {nonebot_plugin_codex-0.1.3 → nonebot_plugin_codex-0.1.5}/tests/test_runtime.py +0 -0
  26. {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
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
- # 读取 Codex stdout / stderr 的缓冲区大小
180
- codex_stream_read_limit = 1048576
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
- - 其余项分别控制停止超时、进度保留条数、诊断输出条数、Telegram 分片长度和流读取上限。
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
- # 读取 Codex stdout / stderr 的缓冲区大小
167
- codex_stream_read_limit = 1048576
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
- - 其余项分别控制停止超时、进度保留条数、诊断输出条数、Telegram 分片长度和流读取上限。
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
  - 历史会话恢复时会尝试切回原始工作目录;如果原目录不存在,会保留当前目录并给出提示。
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-codex"
3
- version = "0.1.3"
3
+ version = "0.1.5"
4
4
  description = "Telegram bridge plugin for driving Codex from NoneBot"
5
5
  authors = [
6
6
  { name = "ttiee", email = "469784630@qq.com" },
@@ -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)
@@ -12,4 +12,4 @@ class Config(BaseModel):
12
12
  codex_progress_history: int = 6
13
13
  codex_diagnostic_history: int = 20
14
14
  codex_chunk_size: int = 3500
15
- codex_stream_read_limit: int = 1024 * 1024
15
+ codex_stream_read_limit: int = 8 * 1024 * 1024
@@ -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.STDOUT,
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 getattr(self._process, "stdout", None) is None:
412
+ if self._process is None or self._reader is None:
402
413
  raise RuntimeError("Codex app-server 尚未启动。")
403
- raw_line = await self._process.stdout.readline()
404
- if not raw_line:
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