nonebot-plugin-codex 0.1.0__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.
@@ -0,0 +1,2338 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ import asyncio
6
+ import inspect
7
+ import secrets
8
+ try:
9
+ import tomllib
10
+ except ModuleNotFoundError: # pragma: no cover - Python < 3.11
11
+ import tomli as tomllib
12
+ from pathlib import Path
13
+ from datetime import datetime, timezone
14
+ from typing import Any
15
+ from collections.abc import Callable, Awaitable
16
+ from dataclasses import field, asdict, dataclass
17
+
18
+ from nonebot.adapters.telegram.model import InlineKeyboardButton, InlineKeyboardMarkup
19
+
20
+ from .native_client import NativeCodexClient
21
+
22
+ ProgressCallback = Callable[[str], Awaitable[None]]
23
+ StreamTextCallback = Callable[[str], Awaitable[None]]
24
+ ProcessLauncher = Callable[..., Awaitable[Any]]
25
+ WhichResolver = Callable[[str], str | None]
26
+
27
+ VISIBLE_MODEL = "list"
28
+ SUPPORTED_EFFORT_COMMANDS = {"high", "xhigh"}
29
+ SUPPORTED_PERMISSION_MODES = {"safe", "danger"}
30
+ BROWSER_CALLBACK_PREFIX = "cdb"
31
+ BROWSER_PAGE_SIZE = 8
32
+ BROWSER_FILE_SUMMARY_LIMIT = 10
33
+ BROWSER_STALE_MESSAGE = "目录面板已失效,请重新执行 /cd"
34
+ HISTORY_CALLBACK_PREFIX = "chs"
35
+ HISTORY_PAGE_SIZE = 6
36
+ HISTORY_STALE_MESSAGE = "历史会话面板已失效,请重新执行 /sessions"
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class CodexBridgeSettings:
41
+ binary: str = "codex"
42
+ workdir: str = field(default_factory=lambda: str(Path.home()))
43
+ kill_timeout: float = 5.0
44
+ progress_history: int = 6
45
+ diagnostic_history: int = 20
46
+ chunk_size: int = 3500
47
+ stream_read_limit: int = 1024 * 1024
48
+ models_cache_path: Path = field(
49
+ default_factory=lambda: Path.home() / ".codex" / "models_cache.json"
50
+ )
51
+ codex_config_path: Path = field(
52
+ default_factory=lambda: Path.home() / ".codex" / "config.toml"
53
+ )
54
+ preferences_path: Path = field(
55
+ default_factory=lambda: Path("data") / "codex_bridge" / "preferences.json"
56
+ )
57
+ session_index_path: Path = field(
58
+ default_factory=lambda: Path.home() / ".codex" / "session_index.jsonl"
59
+ )
60
+ sessions_dir: Path = field(
61
+ default_factory=lambda: Path.home() / ".codex" / "sessions"
62
+ )
63
+ archived_sessions_dir: Path = field(
64
+ default_factory=lambda: Path.home() / ".codex" / "archived_sessions"
65
+ )
66
+
67
+
68
+ @dataclass(slots=True)
69
+ class ModelInfo:
70
+ slug: str
71
+ display_name: str
72
+ visibility: str
73
+ priority: int
74
+ default_reasoning_level: str
75
+ supported_reasoning_levels: list[str]
76
+
77
+
78
+ @dataclass(slots=True)
79
+ class ChatPreferences:
80
+ model: str
81
+ reasoning_effort: str
82
+ permission_mode: str = "safe"
83
+ workdir: str = field(default_factory=lambda: str(Path.home()))
84
+ default_mode: str = "resume"
85
+
86
+
87
+ @dataclass(slots=True)
88
+ class ChatSession:
89
+ active: bool = False
90
+ active_mode: str = "resume"
91
+ native_thread_id: str | None = None
92
+ exec_thread_id: str | None = None
93
+ thread_id: str | None = None
94
+ strict_resume: bool = False
95
+ running: bool = False
96
+ process: Any = None
97
+ native_runner: Any = None
98
+ runner_task: asyncio.Task[Any] | None = None
99
+ progress_message_id: int | None = None
100
+ stream_message_id: int | None = None
101
+ last_agent_message: str = ""
102
+ last_stream_text: str = ""
103
+ last_stream_rendered_text: str = ""
104
+ stream_message_truncated: bool = False
105
+ progress_lines: list[str] = field(default_factory=list)
106
+ diagnostics: list[str] = field(default_factory=list)
107
+ cancel_requested: bool = False
108
+
109
+
110
+ @dataclass(slots=True)
111
+ class RunResult:
112
+ exit_code: int
113
+ final_text: str = ""
114
+ thread_id: str | None = None
115
+ notice: str = ""
116
+ diagnostics: list[str] = field(default_factory=list)
117
+ cancelled: bool = False
118
+
119
+
120
+ @dataclass(slots=True)
121
+ class DirectoryEntry:
122
+ name: str
123
+ path: str
124
+ is_dir: bool = True
125
+
126
+
127
+ @dataclass(slots=True)
128
+ class HistoricalSessionSummary:
129
+ session_id: str
130
+ thread_name: str
131
+ updated_at: str
132
+ kind: str = "exec"
133
+ cwd: str | None = None
134
+ source_kind: str | None = None
135
+ source_path: str | None = None
136
+ archived: bool = False
137
+ missing: bool = False
138
+ preview: str | None = None
139
+ last_user_text: str | None = None
140
+ last_assistant_text: str | None = None
141
+
142
+
143
+ @dataclass(slots=True)
144
+ class HistoryBrowserState:
145
+ chat_key: str
146
+ page: int
147
+ token: str
148
+ version: int
149
+ entries: list[HistoricalSessionSummary]
150
+ scope: str = "menu"
151
+ selected_session_id: str | None = None
152
+ message_id: int | None = None
153
+
154
+
155
+ @dataclass(slots=True)
156
+ class DirectoryBrowserState:
157
+ chat_key: str
158
+ current_path: str
159
+ page: int
160
+ token: str
161
+ version: int
162
+ entries: list[DirectoryEntry]
163
+ show_hidden: bool = False
164
+ files: list[str] = field(default_factory=list)
165
+ message_id: int | None = None
166
+
167
+
168
+ def build_chat_key(chat_type: str, chat_id: int) -> str:
169
+ if chat_type == "private":
170
+ return f"private_{chat_id}"
171
+ return f"group_{chat_id}"
172
+
173
+
174
+ def build_exec_argv(
175
+ binary: str,
176
+ workdir: str,
177
+ prompt: str,
178
+ *,
179
+ model: str,
180
+ reasoning_effort: str,
181
+ permission_mode: str,
182
+ thread_id: str | None = None,
183
+ ) -> list[str]:
184
+ base_args = [
185
+ binary,
186
+ "exec",
187
+ ]
188
+ if thread_id:
189
+ base_args.extend(["resume"])
190
+ base_args.extend(
191
+ [
192
+ "--json",
193
+ "--skip-git-repo-check",
194
+ ]
195
+ )
196
+ if not thread_id:
197
+ base_args.extend(["-C", workdir])
198
+ base_args.extend(
199
+ [
200
+ "-m",
201
+ model,
202
+ "-c",
203
+ f'model_reasoning_effort="{reasoning_effort}"',
204
+ ]
205
+ )
206
+ if permission_mode == "safe":
207
+ if thread_id:
208
+ base_args.append("--full-auto")
209
+ else:
210
+ base_args.extend(["--sandbox", "workspace-write"])
211
+ elif permission_mode == "danger":
212
+ base_args.append("--dangerously-bypass-approvals-and-sandbox")
213
+ else:
214
+ raise ValueError(f"Unsupported permission mode: {permission_mode}")
215
+ if thread_id:
216
+ base_args.append(thread_id)
217
+ base_args.append(prompt)
218
+ return base_args
219
+
220
+
221
+ def encode_browser_callback(
222
+ token: str,
223
+ version: int,
224
+ action: str,
225
+ index: int | None = None,
226
+ ) -> str:
227
+ suffix = "" if index is None else f":{index}"
228
+ return f"{BROWSER_CALLBACK_PREFIX}:{token}:{version}:{action}{suffix}"
229
+
230
+
231
+ def decode_browser_callback(payload: str) -> tuple[str, int, str, int | None]:
232
+ parts = payload.split(":")
233
+ if len(parts) not in {4, 5} or parts[0] != BROWSER_CALLBACK_PREFIX:
234
+ raise ValueError("无效的目录回调。")
235
+ token = parts[1]
236
+ try:
237
+ version = int(parts[2])
238
+ except ValueError as exc:
239
+ raise ValueError("无效的目录回调。") from exc
240
+ action = parts[3]
241
+ index: int | None = None
242
+ if len(parts) == 5:
243
+ try:
244
+ index = int(parts[4])
245
+ except ValueError as exc:
246
+ raise ValueError("无效的目录回调。") from exc
247
+ return token, version, action, index
248
+
249
+
250
+ def encode_history_callback(
251
+ token: str,
252
+ version: int,
253
+ action: str,
254
+ index: int | None = None,
255
+ ) -> str:
256
+ suffix = "" if index is None else f":{index}"
257
+ return f"{HISTORY_CALLBACK_PREFIX}:{token}:{version}:{action}{suffix}"
258
+
259
+
260
+ def decode_history_callback(payload: str) -> tuple[str, int, str, int | None]:
261
+ parts = payload.split(":")
262
+ if len(parts) not in {4, 5} or parts[0] != HISTORY_CALLBACK_PREFIX:
263
+ raise ValueError("无效的历史会话回调。")
264
+ token = parts[1]
265
+ try:
266
+ version = int(parts[2])
267
+ except ValueError as exc:
268
+ raise ValueError("无效的历史会话回调。") from exc
269
+ action = parts[3]
270
+ index: int | None = None
271
+ if len(parts) == 5:
272
+ try:
273
+ index = int(parts[4])
274
+ except ValueError as exc:
275
+ raise ValueError("无效的历史会话回调。") from exc
276
+ return token, version, action, index
277
+
278
+
279
+ def parse_event_line(line: str) -> dict[str, Any] | None:
280
+ try:
281
+ payload = json.loads(line)
282
+ except json.JSONDecodeError:
283
+ return None
284
+ if isinstance(payload, dict) and isinstance(payload.get("type"), str):
285
+ return payload
286
+ return None
287
+
288
+
289
+ def should_forward_follow_up(session: ChatSession | None, text: str) -> bool:
290
+ if session is None or not session.active or session.running:
291
+ return False
292
+ plain = text.strip()
293
+ return bool(plain and not plain.startswith("/"))
294
+
295
+
296
+ def chunk_text(text: str, limit: int) -> list[str]:
297
+ if not text:
298
+ return []
299
+ chunks: list[str] = []
300
+ remaining = text
301
+ while remaining:
302
+ if len(remaining) <= limit:
303
+ chunks.append(remaining)
304
+ break
305
+ split_at = remaining.rfind("\n", 0, limit)
306
+ if split_at <= 0:
307
+ split_at = limit
308
+ chunks.append(remaining[:split_at].rstrip())
309
+ remaining = remaining[split_at:].lstrip()
310
+ return [chunk for chunk in chunks if chunk]
311
+
312
+
313
+ def format_result_text(result: RunResult) -> str:
314
+ parts: list[str] = []
315
+ if result.notice:
316
+ parts.append(result.notice)
317
+ if result.cancelled:
318
+ parts.append("Codex 已中断。")
319
+ elif result.final_text:
320
+ parts.append(result.final_text)
321
+ elif result.exit_code == 0:
322
+ parts.append("Codex 已完成,但没有返回可展示的最终文本。")
323
+ else:
324
+ parts.append("Codex 执行失败。")
325
+ if result.diagnostics:
326
+ parts.append("\n".join(result.diagnostics[-5:]))
327
+ return "\n\n".join(parts)
328
+
329
+
330
+ def format_preferences_summary(preferences: ChatPreferences) -> str:
331
+ return (
332
+ f"模型: {preferences.model} | 推理: {preferences.reasoning_effort} | "
333
+ f"权限: {preferences.permission_mode}"
334
+ )
335
+
336
+
337
+ def format_file_summary(files: list[str]) -> str:
338
+ if not files:
339
+ return "文件:无"
340
+ preview = ",".join(files[:BROWSER_FILE_SUMMARY_LIMIT])
341
+ remaining = len(files) - BROWSER_FILE_SUMMARY_LIMIT
342
+ suffix = f" 等 {len(files)} 个" if remaining > 0 else ""
343
+ return f"文件:{preview}{suffix}"
344
+
345
+
346
+ def _trim_command(command: str, limit: int = 120) -> str:
347
+ compact = " ".join(command.split())
348
+ if len(compact) <= limit:
349
+ return compact
350
+ return f"{compact[: limit - 3]}..."
351
+
352
+
353
+ def _append_progress_line(session: ChatSession, line: str, limit: int) -> None:
354
+ session.progress_lines.append(line)
355
+ if len(session.progress_lines) > limit:
356
+ del session.progress_lines[:-limit]
357
+
358
+
359
+ def _append_diagnostic(session: ChatSession, line: str, limit: int) -> None:
360
+ session.diagnostics.append(line)
361
+ if len(session.diagnostics) > limit:
362
+ del session.diagnostics[:-limit]
363
+
364
+
365
+ def _apply_event(
366
+ session: ChatSession,
367
+ event: dict[str, Any],
368
+ *,
369
+ progress_history: int,
370
+ ) -> tuple[bool, str | None]:
371
+ event_type = event["type"]
372
+ if event_type == "thread.started":
373
+ thread_id = event.get("thread_id")
374
+ if isinstance(thread_id, str) and thread_id:
375
+ session.thread_id = thread_id
376
+ return True, None
377
+ if event_type == "turn.started":
378
+ _append_progress_line(session, "开始处理请求", progress_history)
379
+ return True, None
380
+ if event_type not in {"item.started", "item.completed"}:
381
+ return False, None
382
+
383
+ item = event.get("item")
384
+ if not isinstance(item, dict):
385
+ return False, None
386
+
387
+ item_type = item.get("type")
388
+ if item_type == "command_execution":
389
+ command = _trim_command(str(item.get("command", "")))
390
+ prefix = "执行" if event_type == "item.started" else "完成"
391
+ _append_progress_line(session, f"{prefix}: {command}", progress_history)
392
+ return True, None
393
+
394
+ if item_type == "agent_message":
395
+ text = item.get("text")
396
+ if isinstance(text, str) and text.strip():
397
+ stripped = text.strip()
398
+ session.last_agent_message = stripped
399
+ if stripped != session.last_stream_text:
400
+ session.last_stream_text = stripped
401
+ return False, stripped
402
+ return False, None
403
+
404
+ return False, None
405
+
406
+
407
+ def render_progress_text(session: ChatSession, *, header: str | None = None) -> str:
408
+ parts: list[str] = []
409
+ if header:
410
+ parts.append(header)
411
+ if not session.progress_lines:
412
+ parts.append("Codex 运行中…")
413
+ else:
414
+ body = "\n".join(f"- {line}" for line in session.progress_lines)
415
+ parts.append(f"Codex 运行中…\n{body}")
416
+ return "\n".join(parts)
417
+
418
+
419
+ async def terminate_process(process: Any, timeout: float) -> None:
420
+ if process is None:
421
+ return
422
+ if getattr(process, "returncode", None) is not None:
423
+ return
424
+ process.terminate()
425
+ try:
426
+ await asyncio.wait_for(process.wait(), timeout=timeout)
427
+ except asyncio.TimeoutError:
428
+ process.kill()
429
+ await process.wait()
430
+
431
+
432
+ class CodexBridgeService:
433
+ def __init__(
434
+ self,
435
+ settings: CodexBridgeSettings,
436
+ *,
437
+ launcher: ProcessLauncher | None = None,
438
+ native_client: NativeCodexClient | None = None,
439
+ which_resolver: WhichResolver = shutil.which,
440
+ ) -> None:
441
+ self.settings = settings
442
+ self.launcher = launcher or asyncio.create_subprocess_exec
443
+ self.native_client = native_client
444
+ self.which_resolver = which_resolver
445
+ self.sessions: dict[str, ChatSession] = {}
446
+ self.preference_overrides = self._load_preferences()
447
+ self.directory_browsers: dict[str, DirectoryBrowserState] = {}
448
+ self.history_browsers: dict[str, HistoryBrowserState] = {}
449
+ self._native_history_entries: list[HistoricalSessionSummary] = []
450
+ self._native_history_loaded = False
451
+
452
+ def _spawn_native_client(self) -> Any:
453
+ if self.native_client is None:
454
+ return None
455
+ if isinstance(self.native_client, NativeCodexClient):
456
+ return self.native_client.clone()
457
+ return self.native_client
458
+
459
+ async def _close_native_runner(self, runner: Any) -> None:
460
+ if runner is None:
461
+ return
462
+ close = getattr(runner, "close", None)
463
+ if close is None:
464
+ return
465
+ result = close()
466
+ if inspect.isawaitable(result):
467
+ await result
468
+
469
+ def _load_history_index(self) -> tuple[dict[str, tuple[str, str]], bool]:
470
+ path = self.settings.session_index_path
471
+ try:
472
+ raw_lines = path.read_text(encoding="utf-8").splitlines()
473
+ except FileNotFoundError:
474
+ return {}, False
475
+ except OSError as exc:
476
+ raise ValueError("无法读取 Codex 历史会话索引。") from exc
477
+
478
+ indexed: dict[str, tuple[str, str]] = {}
479
+ for line in raw_lines:
480
+ try:
481
+ payload = json.loads(line)
482
+ except json.JSONDecodeError:
483
+ continue
484
+ if not isinstance(payload, dict):
485
+ continue
486
+ session_id = payload.get("id")
487
+ thread_name = payload.get("thread_name")
488
+ updated_at = payload.get("updated_at")
489
+ if not all(
490
+ isinstance(value, str) and value
491
+ for value in (session_id, thread_name, updated_at)
492
+ ):
493
+ continue
494
+ indexed[session_id] = (thread_name, updated_at)
495
+ return indexed, True
496
+
497
+ def _normalize_history_title(self, text: str) -> str | None:
498
+ plain = " ".join(text.split())
499
+ if not plain:
500
+ return None
501
+ if plain.startswith("# AGENTS.md instructions"):
502
+ return None
503
+ if plain.startswith("<environment_context>"):
504
+ return None
505
+ if self._is_noise_history_text(plain):
506
+ return None
507
+ if len(plain) <= 120:
508
+ return plain
509
+ return f"{plain[:117]}..."
510
+
511
+ def _normalize_history_preview(self, text: str) -> str | None:
512
+ plain = " ".join(text.split())
513
+ if not plain or self._is_noise_history_text(plain):
514
+ return None
515
+ if len(plain) <= 240:
516
+ return plain
517
+ return f"{plain[:237]}..."
518
+
519
+ def _parse_history_time(self, value: str) -> datetime | None:
520
+ plain = value.strip()
521
+ if not plain:
522
+ return None
523
+
524
+ try:
525
+ timestamp = float(plain)
526
+ except ValueError:
527
+ timestamp = None
528
+ if timestamp is not None:
529
+ if abs(timestamp) >= 1_000_000_000_000:
530
+ timestamp /= 1000
531
+ try:
532
+ return datetime.fromtimestamp(timestamp, tz=timezone.utc)
533
+ except (OverflowError, OSError, ValueError):
534
+ return None
535
+
536
+ normalized = plain
537
+ if normalized.endswith("Z"):
538
+ normalized = f"{normalized[:-1]}+00:00"
539
+ try:
540
+ parsed = datetime.fromisoformat(normalized)
541
+ except ValueError:
542
+ return None
543
+ if parsed.tzinfo is None:
544
+ return parsed.replace(tzinfo=timezone.utc)
545
+ return parsed.astimezone(timezone.utc)
546
+
547
+ def _format_history_relative_time(self, value: str) -> str:
548
+ parsed = self._parse_history_time(value)
549
+ if parsed is None:
550
+ return value
551
+
552
+ elapsed_seconds = max(
553
+ 0,
554
+ int((datetime.now(timezone.utc) - parsed).total_seconds()),
555
+ )
556
+ if elapsed_seconds < 60:
557
+ return "刚刚"
558
+
559
+ minutes = elapsed_seconds // 60
560
+ if minutes < 60:
561
+ return f"{minutes} 分钟前"
562
+
563
+ hours = elapsed_seconds // 3600
564
+ if hours < 24:
565
+ return f"{hours} 小时前"
566
+
567
+ days = elapsed_seconds // 86400
568
+ if days < 7:
569
+ return f"{days} 天前"
570
+
571
+ weeks = days // 7
572
+ if days < 30:
573
+ return f"{weeks} 周前"
574
+
575
+ months = days // 30
576
+ if days < 365:
577
+ return f"{months} 个月前"
578
+
579
+ years = days // 365
580
+ return f"{years} 年前"
581
+
582
+ def _format_history_local_time(self, value: str) -> str:
583
+ parsed = self._parse_history_time(value)
584
+ if parsed is None:
585
+ return value
586
+ return parsed.astimezone().strftime("%Y-%m-%d %H:%M:%S")
587
+
588
+ def _is_noise_history_text(self, text: str) -> bool:
589
+ lowered = text.strip().lower()
590
+ if not lowered:
591
+ return True
592
+ if lowered.startswith("# agents.md instructions"):
593
+ return True
594
+ if lowered.startswith("<environment_context>"):
595
+ return True
596
+ if "you are a helpful assistant" in lowered and (
597
+ "generate a concise ui title" in lowered
598
+ or "you will be presented with a user prompt" in lowered
599
+ or "generate a clear, informative task title" in lowered
600
+ ):
601
+ return True
602
+ return False
603
+
604
+ def _extract_history_title(self, payload: dict[str, Any]) -> str | None:
605
+ payload_type = payload.get("type")
606
+ if payload_type == "event_msg":
607
+ event = payload.get("payload")
608
+ if not isinstance(event, dict) or event.get("type") != "user_message":
609
+ return None
610
+ message = event.get("message")
611
+ if isinstance(message, str):
612
+ return self._normalize_history_title(message)
613
+ return None
614
+
615
+ if payload_type != "response_item":
616
+ return None
617
+ item = payload.get("payload")
618
+ if not isinstance(item, dict):
619
+ return None
620
+ if item.get("type") != "message" or item.get("role") != "user":
621
+ return None
622
+ content = item.get("content")
623
+ if not isinstance(content, list):
624
+ return None
625
+ for part in content:
626
+ if not isinstance(part, dict) or part.get("type") != "input_text":
627
+ continue
628
+ text = part.get("text")
629
+ if isinstance(text, str):
630
+ title = self._normalize_history_title(text)
631
+ if title:
632
+ return title
633
+ return None
634
+
635
+ def _extract_history_message(
636
+ self,
637
+ payload: dict[str, Any],
638
+ ) -> tuple[str, str] | None:
639
+ payload_type = payload.get("type")
640
+ if payload_type == "event_msg":
641
+ event = payload.get("payload")
642
+ if not isinstance(event, dict) or event.get("type") != "user_message":
643
+ return None
644
+ message = event.get("message")
645
+ if not isinstance(message, str):
646
+ return None
647
+ normalized = self._normalize_history_preview(message)
648
+ if normalized is None:
649
+ return None
650
+ return "user", normalized
651
+
652
+ if payload_type != "response_item":
653
+ return None
654
+ item = payload.get("payload")
655
+ if not isinstance(item, dict):
656
+ return None
657
+ if item.get("type") != "message":
658
+ return None
659
+ role = item.get("role")
660
+ if role not in {"user", "assistant"}:
661
+ return None
662
+ content = item.get("content")
663
+ if not isinstance(content, list):
664
+ return None
665
+ supported_types = {"input_text"} if role == "user" else {"output_text"}
666
+ texts: list[str] = []
667
+ for part in content:
668
+ if not isinstance(part, dict) or part.get("type") not in supported_types:
669
+ continue
670
+ text = part.get("text")
671
+ if isinstance(text, str):
672
+ normalized = self._normalize_history_preview(text)
673
+ if normalized:
674
+ texts.append(normalized)
675
+ if not texts:
676
+ return None
677
+ return role, " ".join(texts)
678
+
679
+ def _parse_history_session_file(
680
+ self,
681
+ path: Path,
682
+ *,
683
+ archived: bool,
684
+ indexed: tuple[str, str] | None,
685
+ ) -> HistoricalSessionSummary | None:
686
+ session_id: str | None = None
687
+ cwd: str | None = None
688
+ source_kind: str | None = None
689
+ discovered_title: str | None = None
690
+ discovered_updated_at: str | None = None
691
+ last_user_text: str | None = None
692
+ last_assistant_text: str | None = None
693
+
694
+ try:
695
+ with path.open("r", encoding="utf-8") as handle:
696
+ for line in handle:
697
+ try:
698
+ payload = json.loads(line)
699
+ except json.JSONDecodeError:
700
+ continue
701
+ if not isinstance(payload, dict):
702
+ continue
703
+ timestamp = payload.get("timestamp")
704
+ if isinstance(timestamp, str) and timestamp:
705
+ discovered_updated_at = timestamp
706
+ if discovered_title is None:
707
+ discovered_title = self._extract_history_title(payload)
708
+ extracted_message = self._extract_history_message(payload)
709
+ if extracted_message is not None:
710
+ role, text = extracted_message
711
+ if role == "user":
712
+ last_user_text = text
713
+ elif role == "assistant":
714
+ last_assistant_text = text
715
+ if payload.get("type") != "session_meta":
716
+ continue
717
+ meta = payload.get("payload")
718
+ if not isinstance(meta, dict):
719
+ continue
720
+ meta_session_id = meta.get("id")
721
+ if isinstance(meta_session_id, str) and meta_session_id:
722
+ session_id = meta_session_id
723
+ meta_cwd = meta.get("cwd")
724
+ if isinstance(meta_cwd, str) and meta_cwd:
725
+ cwd = meta_cwd
726
+ meta_source = meta.get("source")
727
+ if isinstance(meta_source, str) and meta_source:
728
+ source_kind = meta_source
729
+ meta_updated_at = meta.get("timestamp")
730
+ if isinstance(meta_updated_at, str) and meta_updated_at:
731
+ discovered_updated_at = meta_updated_at
732
+ except OSError:
733
+ if indexed is None:
734
+ return None
735
+ return HistoricalSessionSummary(
736
+ session_id=session_id or path.stem,
737
+ thread_name=indexed[0],
738
+ updated_at=indexed[1],
739
+ kind="exec",
740
+ source_path=str(path),
741
+ archived=archived,
742
+ missing=True,
743
+ preview=indexed[0],
744
+ )
745
+
746
+ if not session_id:
747
+ return None
748
+
749
+ if indexed is not None:
750
+ thread_name, updated_at = indexed
751
+ else:
752
+ thread_name = discovered_title or session_id
753
+ updated_at = discovered_updated_at or ""
754
+
755
+ preview = last_assistant_text or last_user_text or thread_name
756
+ return HistoricalSessionSummary(
757
+ session_id=session_id,
758
+ thread_name=thread_name,
759
+ updated_at=updated_at,
760
+ kind="exec",
761
+ cwd=cwd,
762
+ source_kind=source_kind or "exec",
763
+ source_path=str(path),
764
+ archived=archived,
765
+ missing=False,
766
+ preview=preview,
767
+ last_user_text=last_user_text,
768
+ last_assistant_text=last_assistant_text,
769
+ )
770
+
771
+ def _collect_history_log_summaries(self) -> dict[str, HistoricalSessionSummary]:
772
+ collected: dict[str, HistoricalSessionSummary] = {}
773
+ for path, archived in self._iter_history_files():
774
+ summary = self._parse_history_session_file(
775
+ path,
776
+ archived=archived,
777
+ indexed=None,
778
+ )
779
+ if summary is None:
780
+ continue
781
+ existing = collected.get(summary.session_id)
782
+ if existing is None or (existing.archived and not summary.archived):
783
+ collected[summary.session_id] = summary
784
+ return collected
785
+
786
+ def _enrich_history_summary_from_log(
787
+ self,
788
+ summary: HistoricalSessionSummary,
789
+ log_summary: HistoricalSessionSummary | None,
790
+ ) -> HistoricalSessionSummary:
791
+ if log_summary is None:
792
+ return summary
793
+
794
+ if summary.cwd is None:
795
+ summary.cwd = log_summary.cwd
796
+ if summary.source_kind is None:
797
+ summary.source_kind = log_summary.source_kind
798
+ summary.source_path = log_summary.source_path
799
+ summary.archived = log_summary.archived
800
+ summary.missing = log_summary.missing
801
+ summary.last_user_text = log_summary.last_user_text
802
+ summary.last_assistant_text = log_summary.last_assistant_text
803
+ if log_summary.last_assistant_text or log_summary.last_user_text:
804
+ summary.preview = (
805
+ log_summary.last_assistant_text or log_summary.last_user_text
806
+ )
807
+ elif summary.preview is None and log_summary.preview is not None:
808
+ summary.preview = log_summary.preview
809
+ return summary
810
+
811
+ def _iter_history_files(self) -> list[tuple[Path, bool]]:
812
+ files: list[tuple[Path, bool]] = []
813
+ if self.settings.sessions_dir.exists():
814
+ files.extend(
815
+ (path, False) for path in self.settings.sessions_dir.rglob("*.jsonl")
816
+ )
817
+ if self.settings.archived_sessions_dir.exists():
818
+ files.extend(
819
+ (path, True)
820
+ for path in self.settings.archived_sessions_dir.rglob("*.jsonl")
821
+ )
822
+ return sorted(files, key=lambda item: str(item[0]))
823
+
824
+ def _collect_exec_history_sessions(
825
+ self,
826
+ index_entries: dict[str, tuple[str, str]],
827
+ ) -> list[HistoricalSessionSummary]:
828
+ collected = self._collect_history_log_summaries()
829
+ for summary in collected.values():
830
+ indexed = index_entries.get(summary.session_id)
831
+ if indexed is not None:
832
+ summary.thread_name = indexed[0]
833
+ summary.updated_at = indexed[1]
834
+ if summary.last_user_text is None and summary.last_assistant_text is None:
835
+ summary.preview = summary.thread_name
836
+
837
+ for session_id, (thread_name, updated_at) in index_entries.items():
838
+ if session_id in collected:
839
+ continue
840
+ collected[session_id] = HistoricalSessionSummary(
841
+ session_id=session_id,
842
+ thread_name=thread_name,
843
+ updated_at=updated_at,
844
+ kind="exec",
845
+ source_kind="exec",
846
+ missing=True,
847
+ preview=thread_name,
848
+ )
849
+
850
+ return sorted(
851
+ collected.values(),
852
+ key=lambda session: session.updated_at,
853
+ reverse=True,
854
+ )
855
+
856
+ async def _load_native_history_sessions(self) -> list[HistoricalSessionSummary]:
857
+ if self.native_client is None:
858
+ return []
859
+ history_logs = self._collect_history_log_summaries()
860
+ client = self._spawn_native_client()
861
+ try:
862
+ threads = await client.list_threads()
863
+ except Exception:
864
+ return []
865
+ finally:
866
+ await self._close_native_runner(client)
867
+ entries = []
868
+ for thread in threads:
869
+ entry = HistoricalSessionSummary(
870
+ session_id=thread.thread_id,
871
+ thread_name=thread.thread_name,
872
+ updated_at=thread.updated_at,
873
+ kind="native",
874
+ cwd=thread.cwd,
875
+ source_kind=thread.source_kind,
876
+ preview=thread.preview,
877
+ )
878
+ entries.append(
879
+ self._enrich_history_summary_from_log(
880
+ entry,
881
+ history_logs.get(thread.thread_id),
882
+ )
883
+ )
884
+ return sorted(entries, key=lambda session: session.updated_at, reverse=True)
885
+
886
+ def _get_native_history_sessions(self) -> list[HistoricalSessionSummary]:
887
+ if self.native_client is None:
888
+ return []
889
+ if not self._native_history_loaded:
890
+ try:
891
+ asyncio.get_running_loop()
892
+ except RuntimeError:
893
+ self._native_history_entries = asyncio.run(
894
+ self._load_native_history_sessions()
895
+ )
896
+ self._native_history_loaded = True
897
+ else:
898
+ return list(self._native_history_entries)
899
+ return list(self._native_history_entries)
900
+
901
+ async def refresh_history_sessions(self) -> list[HistoricalSessionSummary]:
902
+ self._native_history_entries = await self._load_native_history_sessions()
903
+ self._native_history_loaded = True
904
+ return self.list_history_sessions()
905
+
906
+ def list_history_sessions(self) -> list[HistoricalSessionSummary]:
907
+ index_entries, has_index = self._load_history_index()
908
+ native_entries = self._get_native_history_sessions()
909
+ native_ids = {entry.session_id for entry in native_entries}
910
+ exec_entries = [
911
+ entry
912
+ for entry in self._collect_exec_history_sessions(index_entries)
913
+ if entry.session_id not in native_ids
914
+ ]
915
+ if native_entries or exec_entries:
916
+ return native_entries + exec_entries
917
+ if not has_index:
918
+ raise ValueError("未找到 Codex 历史会话索引。")
919
+ raise ValueError("未找到 Codex 历史会话。")
920
+
921
+ def get_history_session(self, session_id: str) -> HistoricalSessionSummary:
922
+ for session in self.list_history_sessions():
923
+ if session.session_id == session_id:
924
+ return session
925
+ raise ValueError("未找到指定历史会话。")
926
+
927
+ def _history_total_pages(self, entries: list[HistoricalSessionSummary]) -> int:
928
+ return max(1, (len(entries) + HISTORY_PAGE_SIZE - 1) // HISTORY_PAGE_SIZE)
929
+
930
+ def _clamp_history_page(
931
+ self, entries: list[HistoricalSessionSummary], page: int
932
+ ) -> int:
933
+ total_pages = self._history_total_pages(entries)
934
+ return max(0, min(page, total_pages - 1))
935
+
936
+ def _history_entries_for_scope(self, scope: str) -> list[HistoricalSessionSummary]:
937
+ entries = self.list_history_sessions()
938
+ if scope == "menu":
939
+ return entries
940
+ if scope == "resume":
941
+ return [entry for entry in entries if entry.kind == "native"]
942
+ if scope == "exec":
943
+ return [entry for entry in entries if entry.kind == "exec"]
944
+ raise ValueError("未知历史会话模式。")
945
+
946
+ def _replace_history_browser_state(
947
+ self,
948
+ chat_key: str,
949
+ *,
950
+ page: int,
951
+ scope: str = "menu",
952
+ selected_session_id: str | None = None,
953
+ previous: HistoryBrowserState | None = None,
954
+ ) -> HistoryBrowserState:
955
+ entries = self._history_entries_for_scope(scope)
956
+ state = HistoryBrowserState(
957
+ chat_key=chat_key,
958
+ page=self._clamp_history_page(entries, page),
959
+ token=previous.token if previous else self._make_browser_token(),
960
+ version=(previous.version + 1) if previous else 1,
961
+ entries=entries,
962
+ scope=scope,
963
+ selected_session_id=selected_session_id,
964
+ message_id=previous.message_id if previous else None,
965
+ )
966
+ self.history_browsers[chat_key] = state
967
+ return state
968
+
969
+ def open_history_browser(self, chat_key: str) -> HistoryBrowserState:
970
+ return self._replace_history_browser_state(chat_key, page=0, scope="menu")
971
+
972
+ def get_history_browser(
973
+ self,
974
+ chat_key: str,
975
+ token: str | None = None,
976
+ version: int | None = None,
977
+ ) -> HistoryBrowserState:
978
+ state = self.history_browsers.get(chat_key)
979
+ if state is None:
980
+ raise ValueError(HISTORY_STALE_MESSAGE)
981
+ if token is not None and state.token != token:
982
+ raise ValueError(HISTORY_STALE_MESSAGE)
983
+ if version is not None and state.version != version:
984
+ raise ValueError(HISTORY_STALE_MESSAGE)
985
+ return state
986
+
987
+ def remember_history_browser_message(
988
+ self,
989
+ chat_key: str,
990
+ token: str,
991
+ message_id: int | None,
992
+ ) -> None:
993
+ if message_id is None:
994
+ return
995
+ browser = self.get_history_browser(chat_key, token=token)
996
+ browser.message_id = message_id
997
+
998
+ def close_history_browser(self, chat_key: str, token: str, version: int) -> None:
999
+ self.get_history_browser(chat_key, token=token, version=version)
1000
+ self.history_browsers.pop(chat_key, None)
1001
+
1002
+ def navigate_history_browser(
1003
+ self,
1004
+ chat_key: str,
1005
+ token: str,
1006
+ version: int,
1007
+ action: str,
1008
+ index: int | None = None,
1009
+ ) -> HistoryBrowserState:
1010
+ browser = self.get_history_browser(chat_key, token=token, version=version)
1011
+ if action == "scope_resume":
1012
+ return self._replace_history_browser_state(
1013
+ chat_key,
1014
+ page=0,
1015
+ scope="resume",
1016
+ previous=browser,
1017
+ )
1018
+ if action == "scope_exec":
1019
+ return self._replace_history_browser_state(
1020
+ chat_key,
1021
+ page=0,
1022
+ scope="exec",
1023
+ previous=browser,
1024
+ )
1025
+ if action == "menu":
1026
+ return self._replace_history_browser_state(
1027
+ chat_key,
1028
+ page=0,
1029
+ scope="menu",
1030
+ previous=browser,
1031
+ )
1032
+ if action == "open":
1033
+ if index is None or not 0 <= index < len(browser.entries):
1034
+ raise ValueError("历史会话不存在。")
1035
+ return self._replace_history_browser_state(
1036
+ chat_key,
1037
+ page=browser.page,
1038
+ scope=browser.scope,
1039
+ selected_session_id=browser.entries[index].session_id,
1040
+ previous=browser,
1041
+ )
1042
+ if action == "back":
1043
+ return self._replace_history_browser_state(
1044
+ chat_key,
1045
+ page=browser.page,
1046
+ scope=browser.scope,
1047
+ previous=browser,
1048
+ )
1049
+ if action == "refresh":
1050
+ return self._replace_history_browser_state(
1051
+ chat_key,
1052
+ page=browser.page,
1053
+ scope=browser.scope,
1054
+ selected_session_id=browser.selected_session_id,
1055
+ previous=browser,
1056
+ )
1057
+ if action == "prev":
1058
+ return self._replace_history_browser_state(
1059
+ chat_key,
1060
+ page=browser.page - 1,
1061
+ scope=browser.scope,
1062
+ previous=browser,
1063
+ )
1064
+ if action == "next":
1065
+ return self._replace_history_browser_state(
1066
+ chat_key,
1067
+ page=browser.page + 1,
1068
+ scope=browser.scope,
1069
+ previous=browser,
1070
+ )
1071
+ raise ValueError("未知历史会话操作。")
1072
+
1073
+ def render_history_browser(self, chat_key: str) -> tuple[str, InlineKeyboardMarkup]:
1074
+ browser = self.get_history_browser(chat_key)
1075
+ preferences = self.get_preferences(chat_key)
1076
+ session = self.sessions.get(chat_key)
1077
+ current_mode = session.active_mode if session else preferences.default_mode
1078
+ if session is None:
1079
+ current_thread = "未绑定"
1080
+ elif current_mode == "exec":
1081
+ current_thread = self._current_exec_thread_id(session) or "未绑定"
1082
+ elif self.native_client is not None:
1083
+ current_thread = session.native_thread_id or "未绑定"
1084
+ else:
1085
+ current_thread = session.thread_id or "未绑定"
1086
+
1087
+ if browser.scope == "menu":
1088
+ resume_count = sum(1 for entry in browser.entries if entry.kind == "native")
1089
+ exec_count = sum(1 for entry in browser.entries if entry.kind == "exec")
1090
+ lines = [
1091
+ "Codex 历史会话",
1092
+ f"当前模式:{current_mode}",
1093
+ f"当前绑定:{current_thread}",
1094
+ f"当前工作目录:{preferences.workdir}",
1095
+ f"resume:{resume_count}",
1096
+ f"exec:{exec_count}",
1097
+ ]
1098
+ keyboard = [
1099
+ [
1100
+ InlineKeyboardButton(
1101
+ text=f"resume ({resume_count})",
1102
+ callback_data=encode_history_callback(
1103
+ browser.token,
1104
+ browser.version,
1105
+ "scope_resume",
1106
+ ),
1107
+ )
1108
+ ],
1109
+ [
1110
+ InlineKeyboardButton(
1111
+ text=f"exec ({exec_count})",
1112
+ callback_data=encode_history_callback(
1113
+ browser.token,
1114
+ browser.version,
1115
+ "scope_exec",
1116
+ ),
1117
+ )
1118
+ ],
1119
+ [
1120
+ InlineKeyboardButton(
1121
+ text="关闭",
1122
+ callback_data=encode_history_callback(
1123
+ browser.token,
1124
+ browser.version,
1125
+ "close",
1126
+ ),
1127
+ )
1128
+ ],
1129
+ ]
1130
+ return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
1131
+
1132
+ if browser.selected_session_id is not None:
1133
+ selected = next(
1134
+ (
1135
+ entry
1136
+ for entry in browser.entries
1137
+ if entry.session_id == browser.selected_session_id
1138
+ ),
1139
+ None,
1140
+ )
1141
+ if selected is None:
1142
+ raise ValueError("未找到指定历史会话。")
1143
+ lines = [
1144
+ "Codex 历史会话",
1145
+ f"类型:{selected.kind}",
1146
+ f"标题:{selected.thread_name}",
1147
+ f"更新时间:{self._format_history_local_time(selected.updated_at)}",
1148
+ f"原始工作目录:{selected.cwd or '未知'}",
1149
+ f"归档:{'是' if selected.archived else '否'}",
1150
+ f"上次对话概览:{selected.preview or selected.thread_name}",
1151
+ ]
1152
+ if selected.last_user_text:
1153
+ lines.append(f"上次用户输入:{selected.last_user_text}")
1154
+ if selected.last_assistant_text:
1155
+ lines.append(f"上次助手回复:{selected.last_assistant_text}")
1156
+ if selected.missing:
1157
+ lines.append("源会话文件缺失,无法继续该对话。")
1158
+ can_continue = not (
1159
+ selected.kind == "exec"
1160
+ and (selected.missing or selected.source_path is None)
1161
+ )
1162
+ keyboard: list[list[InlineKeyboardButton]] = []
1163
+ if can_continue:
1164
+ keyboard.append(
1165
+ [
1166
+ InlineKeyboardButton(
1167
+ text="续聊",
1168
+ callback_data=encode_history_callback(
1169
+ browser.token,
1170
+ browser.version,
1171
+ "apply",
1172
+ ),
1173
+ )
1174
+ ]
1175
+ )
1176
+ keyboard.append(
1177
+ [
1178
+ InlineKeyboardButton(
1179
+ text="返回列表",
1180
+ callback_data=encode_history_callback(
1181
+ browser.token,
1182
+ browser.version,
1183
+ "back",
1184
+ ),
1185
+ ),
1186
+ InlineKeyboardButton(
1187
+ text="返回模式选择",
1188
+ callback_data=encode_history_callback(
1189
+ browser.token,
1190
+ browser.version,
1191
+ "menu",
1192
+ ),
1193
+ ),
1194
+ InlineKeyboardButton(
1195
+ text="关闭",
1196
+ callback_data=encode_history_callback(
1197
+ browser.token,
1198
+ browser.version,
1199
+ "close",
1200
+ ),
1201
+ ),
1202
+ ]
1203
+ )
1204
+ return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
1205
+
1206
+ total_pages = self._history_total_pages(browser.entries)
1207
+ start = browser.page * HISTORY_PAGE_SIZE
1208
+ end = start + HISTORY_PAGE_SIZE
1209
+ current_entries = browser.entries[start:end]
1210
+ lines = [
1211
+ "Codex 历史会话",
1212
+ f"当前浏览模式:{browser.scope}",
1213
+ f"当前模式:{current_mode}",
1214
+ f"当前绑定:{current_thread}",
1215
+ f"当前工作目录:{preferences.workdir}",
1216
+ f"总数:{len(browser.entries)}",
1217
+ f"第 {browser.page + 1}/{total_pages} 页",
1218
+ ]
1219
+ keyboard: list[list[InlineKeyboardButton]] = []
1220
+ for offset, entry in enumerate(current_entries):
1221
+ keyboard.append(
1222
+ [
1223
+ InlineKeyboardButton(
1224
+ text=(
1225
+ f"{entry.thread_name} | "
1226
+ f"{self._format_history_relative_time(entry.updated_at)}"
1227
+ ),
1228
+ callback_data=encode_history_callback(
1229
+ browser.token,
1230
+ browser.version,
1231
+ "open",
1232
+ start + offset,
1233
+ ),
1234
+ )
1235
+ ]
1236
+ )
1237
+
1238
+ nav_buttons: list[InlineKeyboardButton] = []
1239
+ if browser.page > 0:
1240
+ nav_buttons.append(
1241
+ InlineKeyboardButton(
1242
+ text="上一页",
1243
+ callback_data=encode_history_callback(
1244
+ browser.token,
1245
+ browser.version,
1246
+ "prev",
1247
+ ),
1248
+ )
1249
+ )
1250
+ if browser.page + 1 < total_pages:
1251
+ nav_buttons.append(
1252
+ InlineKeyboardButton(
1253
+ text="下一页",
1254
+ callback_data=encode_history_callback(
1255
+ browser.token,
1256
+ browser.version,
1257
+ "next",
1258
+ ),
1259
+ )
1260
+ )
1261
+ if nav_buttons:
1262
+ keyboard.append(nav_buttons)
1263
+
1264
+ keyboard.append(
1265
+ [
1266
+ InlineKeyboardButton(
1267
+ text="返回模式选择",
1268
+ callback_data=encode_history_callback(
1269
+ browser.token,
1270
+ browser.version,
1271
+ "menu",
1272
+ ),
1273
+ ),
1274
+ InlineKeyboardButton(
1275
+ text="刷新",
1276
+ callback_data=encode_history_callback(
1277
+ browser.token,
1278
+ browser.version,
1279
+ "refresh",
1280
+ ),
1281
+ ),
1282
+ InlineKeyboardButton(
1283
+ text="关闭",
1284
+ callback_data=encode_history_callback(
1285
+ browser.token,
1286
+ browser.version,
1287
+ "close",
1288
+ ),
1289
+ ),
1290
+ ]
1291
+ )
1292
+ return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
1293
+
1294
+ async def apply_history_session(self, chat_key: str, token: str, version: int) -> str:
1295
+ self._ensure_not_running(chat_key)
1296
+ browser = self.get_history_browser(chat_key, token=token, version=version)
1297
+ if browser.selected_session_id is None:
1298
+ raise ValueError("请先选择一个历史会话。")
1299
+
1300
+ selected = next(
1301
+ (
1302
+ entry
1303
+ for entry in browser.entries
1304
+ if entry.session_id == browser.selected_session_id
1305
+ ),
1306
+ None,
1307
+ )
1308
+ if selected is None:
1309
+ raise ValueError("未找到指定历史会话。")
1310
+ if selected.kind == "exec" and (selected.missing or selected.source_path is None):
1311
+ raise ValueError("源会话文件不存在,无法继续。")
1312
+
1313
+ session = self.activate_chat(chat_key)
1314
+ if selected.kind == "native":
1315
+ session.active_mode = "resume"
1316
+ self._set_native_thread_id(session, selected.session_id)
1317
+ else:
1318
+ session.active_mode = "exec"
1319
+ self._set_exec_thread_id(session, selected.session_id)
1320
+ self._sync_legacy_thread_id(session)
1321
+ session.strict_resume = True
1322
+
1323
+ current = self.get_preferences(chat_key)
1324
+ notice_lines = [
1325
+ f"已切换到历史会话({selected.kind}):{selected.thread_name}",
1326
+ f"当前模式:{'resume' if selected.kind == 'native' else 'exec'}",
1327
+ ]
1328
+ if selected.cwd:
1329
+ target = Path(selected.cwd).expanduser()
1330
+ if target.exists() and target.is_dir():
1331
+ self.preference_overrides[chat_key] = ChatPreferences(
1332
+ model=current.model,
1333
+ reasoning_effort=current.reasoning_effort,
1334
+ permission_mode=current.permission_mode,
1335
+ workdir=str(target.resolve()),
1336
+ default_mode=current.default_mode,
1337
+ )
1338
+ self._persist_preferences()
1339
+ else:
1340
+ notice_lines.append("原工作目录不存在,已保留当前工作目录。")
1341
+ notice_lines.append(f"当前工作目录:{self.get_preferences(chat_key).workdir}")
1342
+ notice_lines.append("下一条普通消息会继续该会话。")
1343
+ return "\n".join(notice_lines)
1344
+
1345
+ def load_models(self) -> dict[str, ModelInfo]:
1346
+ try:
1347
+ payload = json.loads(
1348
+ self.settings.models_cache_path.read_text(encoding="utf-8")
1349
+ )
1350
+ except FileNotFoundError as exc:
1351
+ raise FileNotFoundError("未找到 Codex 模型缓存文件。") from exc
1352
+ except json.JSONDecodeError as exc:
1353
+ raise ValueError("Codex 模型缓存文件损坏,无法解析。") from exc
1354
+
1355
+ models = payload.get("models")
1356
+ if not isinstance(models, list):
1357
+ raise ValueError("Codex 模型缓存文件格式不正确。")
1358
+
1359
+ parsed: dict[str, ModelInfo] = {}
1360
+ for item in models:
1361
+ if not isinstance(item, dict):
1362
+ continue
1363
+ slug = item.get("slug")
1364
+ if not isinstance(slug, str) or not slug:
1365
+ continue
1366
+ supported = [
1367
+ level.get("effort")
1368
+ for level in item.get("supported_reasoning_levels", [])
1369
+ if isinstance(level, dict) and isinstance(level.get("effort"), str)
1370
+ ]
1371
+ parsed[slug] = ModelInfo(
1372
+ slug=slug,
1373
+ display_name=str(item.get("display_name") or slug),
1374
+ visibility=str(item.get("visibility") or ""),
1375
+ priority=int(item.get("priority") or 0),
1376
+ default_reasoning_level=str(
1377
+ item.get("default_reasoning_level") or "medium"
1378
+ ),
1379
+ supported_reasoning_levels=supported,
1380
+ )
1381
+ if not parsed:
1382
+ raise ValueError("Codex 模型缓存中没有可用模型。")
1383
+ return parsed
1384
+
1385
+ def list_models(self) -> list[ModelInfo]:
1386
+ visible = [
1387
+ model
1388
+ for model in self.load_models().values()
1389
+ if model.visibility == VISIBLE_MODEL
1390
+ ]
1391
+ return sorted(visible, key=lambda model: (model.priority, model.slug))
1392
+
1393
+ def _load_preferences(self) -> dict[str, ChatPreferences]:
1394
+ path = self.settings.preferences_path
1395
+ if not path.exists():
1396
+ return {}
1397
+ try:
1398
+ raw = json.loads(path.read_text(encoding="utf-8"))
1399
+ except (OSError, json.JSONDecodeError):
1400
+ return {}
1401
+ if not isinstance(raw, dict):
1402
+ return {}
1403
+ loaded: dict[str, ChatPreferences] = {}
1404
+ for chat_key, value in raw.items():
1405
+ if not isinstance(chat_key, str) or not isinstance(value, dict):
1406
+ continue
1407
+ model = value.get("model")
1408
+ reasoning_effort = value.get("reasoning_effort")
1409
+ permission_mode = value.get("permission_mode")
1410
+ workdir = value.get("workdir")
1411
+ default_mode = value.get("default_mode")
1412
+ if not all(
1413
+ isinstance(field, str)
1414
+ for field in (model, reasoning_effort, permission_mode)
1415
+ ):
1416
+ continue
1417
+ loaded[chat_key] = ChatPreferences(
1418
+ model=model,
1419
+ reasoning_effort=reasoning_effort,
1420
+ permission_mode=permission_mode,
1421
+ workdir=(
1422
+ workdir if isinstance(workdir, str) and workdir else str(Path.home())
1423
+ ),
1424
+ default_mode=(
1425
+ default_mode
1426
+ if isinstance(default_mode, str)
1427
+ and default_mode in {"resume", "exec"}
1428
+ else "resume"
1429
+ ),
1430
+ )
1431
+ return loaded
1432
+
1433
+ def _persist_preferences(self) -> None:
1434
+ self.settings.preferences_path.parent.mkdir(parents=True, exist_ok=True)
1435
+ serialized = {
1436
+ chat_key: asdict(preferences)
1437
+ for chat_key, preferences in self.preference_overrides.items()
1438
+ }
1439
+ self.settings.preferences_path.write_text(
1440
+ json.dumps(serialized, ensure_ascii=False, indent=2),
1441
+ encoding="utf-8",
1442
+ )
1443
+
1444
+ def _load_codex_defaults(self) -> tuple[str | None, str | None]:
1445
+ path = self.settings.codex_config_path
1446
+ if not path.exists():
1447
+ return None, None
1448
+ try:
1449
+ config = tomllib.loads(path.read_text(encoding="utf-8"))
1450
+ except (OSError, tomllib.TOMLDecodeError):
1451
+ return None, None
1452
+ model = config.get("model")
1453
+ effort = config.get("model_reasoning_effort")
1454
+ return (
1455
+ model if isinstance(model, str) else None,
1456
+ effort if isinstance(effort, str) else None,
1457
+ )
1458
+
1459
+ def _pick_default_model(self, models: dict[str, ModelInfo]) -> ModelInfo:
1460
+ configured_model, _ = self._load_codex_defaults()
1461
+ if configured_model and configured_model in models:
1462
+ return models[configured_model]
1463
+ visible = [
1464
+ model for model in models.values() if model.visibility == VISIBLE_MODEL
1465
+ ]
1466
+ ranked = sorted(
1467
+ visible or list(models.values()),
1468
+ key=lambda model: (model.priority, model.slug),
1469
+ )
1470
+ return ranked[0]
1471
+
1472
+ def _normalize_effort(self, model: ModelInfo, effort: str | None) -> str:
1473
+ supported = set(model.supported_reasoning_levels)
1474
+ if effort and effort in supported:
1475
+ return effort
1476
+ if "high" in supported:
1477
+ return "high"
1478
+ if model.default_reasoning_level in supported:
1479
+ return model.default_reasoning_level
1480
+ if model.supported_reasoning_levels:
1481
+ return model.supported_reasoning_levels[0]
1482
+ return model.default_reasoning_level
1483
+
1484
+ def _default_preferences(self) -> ChatPreferences:
1485
+ models = self.load_models()
1486
+ model = self._pick_default_model(models)
1487
+ _, configured_effort = self._load_codex_defaults()
1488
+ effort = self._normalize_effort(model, configured_effort)
1489
+ return ChatPreferences(
1490
+ model=model.slug,
1491
+ reasoning_effort=effort,
1492
+ permission_mode="safe",
1493
+ workdir=str(Path.home()),
1494
+ default_mode="resume",
1495
+ )
1496
+
1497
+ def get_session(self, chat_key: str) -> ChatSession:
1498
+ return self.sessions.setdefault(chat_key, ChatSession())
1499
+
1500
+ def get_preferences(self, chat_key: str) -> ChatPreferences:
1501
+ preferences = self.preference_overrides.get(chat_key)
1502
+ if preferences is None:
1503
+ preferences = self._default_preferences()
1504
+ self.preference_overrides[chat_key] = preferences
1505
+ self._persist_preferences()
1506
+ return preferences
1507
+
1508
+ def describe_preferences(self, chat_key: str) -> str:
1509
+ return format_preferences_summary(self.get_preferences(chat_key))
1510
+
1511
+ def describe_workdir(self, chat_key: str) -> str:
1512
+ preferences = self.get_preferences(chat_key)
1513
+ session = self.sessions.get(chat_key)
1514
+ next_step = "继续当前会话" if session and session.thread_id else "新开会话"
1515
+ return (
1516
+ f"当前工作目录:{preferences.workdir}\n"
1517
+ f"当前设置:{format_preferences_summary(preferences)}\n"
1518
+ f"下一条普通消息:{next_step}"
1519
+ )
1520
+
1521
+ def _make_browser_token(self) -> str:
1522
+ return secrets.token_hex(4)
1523
+
1524
+ def activate_chat(self, chat_key: str) -> ChatSession:
1525
+ session = self.get_session(chat_key)
1526
+ if not session.active or session.active_mode not in {"resume", "exec"}:
1527
+ session.active_mode = self.get_preferences(chat_key).default_mode
1528
+ session.active = True
1529
+ self._sync_legacy_thread_id(session)
1530
+ return session
1531
+
1532
+ def _ensure_not_running(self, chat_key: str) -> None:
1533
+ session = self.sessions.get(chat_key)
1534
+ if session and session.running:
1535
+ raise RuntimeError("Codex is already running for this chat")
1536
+
1537
+ def _sync_legacy_thread_id(self, session: ChatSession) -> None:
1538
+ if session.active_mode == "exec":
1539
+ session.thread_id = session.exec_thread_id or session.thread_id
1540
+ return
1541
+ if self.native_client is not None:
1542
+ session.thread_id = session.native_thread_id
1543
+ return
1544
+ session.thread_id = session.exec_thread_id or session.thread_id
1545
+
1546
+ def _current_exec_thread_id(self, session: ChatSession) -> str | None:
1547
+ return session.exec_thread_id or session.thread_id
1548
+
1549
+ def _set_exec_thread_id(self, session: ChatSession, thread_id: str | None) -> None:
1550
+ session.exec_thread_id = thread_id
1551
+ if session.active_mode == "exec" or self.native_client is None:
1552
+ session.thread_id = thread_id
1553
+
1554
+ def _set_native_thread_id(self, session: ChatSession, thread_id: str | None) -> None:
1555
+ session.native_thread_id = thread_id
1556
+ if session.active_mode == "resume":
1557
+ session.thread_id = thread_id
1558
+
1559
+ def _clear_thread_only(self, chat_key: str) -> None:
1560
+ session = self.get_session(chat_key)
1561
+ session.native_thread_id = None
1562
+ session.thread_id = None
1563
+ session.exec_thread_id = None
1564
+ session.strict_resume = False
1565
+
1566
+ def _browser_total_pages(self, entries: list[DirectoryEntry]) -> int:
1567
+ return max(1, (len(entries) + BROWSER_PAGE_SIZE - 1) // BROWSER_PAGE_SIZE)
1568
+
1569
+ def _clamp_browser_page(self, entries: list[DirectoryEntry], page: int) -> int:
1570
+ total_pages = self._browser_total_pages(entries)
1571
+ return max(0, min(page, total_pages - 1))
1572
+
1573
+ def _resolve_directory_path(self, chat_key: str, raw_path: str) -> str:
1574
+ base = Path(self.get_preferences(chat_key).workdir)
1575
+ candidate = Path(raw_path).expanduser()
1576
+ if not candidate.is_absolute():
1577
+ candidate = base / candidate
1578
+ resolved = candidate.resolve()
1579
+ if not resolved.exists():
1580
+ raise ValueError("目录不存在。")
1581
+ if not resolved.is_dir():
1582
+ raise ValueError("目标不是目录。")
1583
+ return str(resolved)
1584
+
1585
+ def _list_directory_entries(
1586
+ self,
1587
+ path: str,
1588
+ *,
1589
+ show_hidden: bool,
1590
+ ) -> tuple[list[DirectoryEntry], list[str]]:
1591
+ directory = Path(path)
1592
+ try:
1593
+ children = list(directory.iterdir())
1594
+ except OSError as exc:
1595
+ raise ValueError("目录无法读取。") from exc
1596
+
1597
+ directories: list[DirectoryEntry] = []
1598
+ files: list[str] = []
1599
+ for child in children:
1600
+ if not show_hidden and child.name.startswith("."):
1601
+ continue
1602
+ try:
1603
+ if child.is_dir():
1604
+ directories.append(
1605
+ DirectoryEntry(name=child.name, path=str(child.resolve()))
1606
+ )
1607
+ else:
1608
+ files.append(child.name)
1609
+ except OSError:
1610
+ continue
1611
+
1612
+ directories.sort(key=lambda entry: entry.name.casefold())
1613
+ files.sort(key=str.casefold)
1614
+ return directories, files
1615
+
1616
+ def _replace_browser_state(
1617
+ self,
1618
+ chat_key: str,
1619
+ path: str,
1620
+ *,
1621
+ page: int,
1622
+ show_hidden: bool | None = None,
1623
+ previous: DirectoryBrowserState | None = None,
1624
+ ) -> DirectoryBrowserState:
1625
+ resolved = str(Path(path).expanduser().resolve())
1626
+ effective_show_hidden = (
1627
+ previous.show_hidden
1628
+ if show_hidden is None and previous is not None
1629
+ else bool(show_hidden)
1630
+ )
1631
+ entries, files = self._list_directory_entries(
1632
+ resolved,
1633
+ show_hidden=effective_show_hidden,
1634
+ )
1635
+ state = DirectoryBrowserState(
1636
+ chat_key=chat_key,
1637
+ current_path=resolved,
1638
+ page=self._clamp_browser_page(entries, page),
1639
+ token=previous.token if previous else self._make_browser_token(),
1640
+ version=(previous.version + 1) if previous else 1,
1641
+ entries=entries,
1642
+ show_hidden=effective_show_hidden,
1643
+ files=files,
1644
+ message_id=previous.message_id if previous else None,
1645
+ )
1646
+ self.directory_browsers[chat_key] = state
1647
+ return state
1648
+
1649
+ def open_directory_browser(self, chat_key: str) -> DirectoryBrowserState:
1650
+ self._ensure_not_running(chat_key)
1651
+ return self._replace_browser_state(
1652
+ chat_key,
1653
+ self.get_preferences(chat_key).workdir,
1654
+ page=0,
1655
+ )
1656
+
1657
+ def get_browser(
1658
+ self,
1659
+ chat_key: str,
1660
+ token: str | None = None,
1661
+ version: int | None = None,
1662
+ ) -> DirectoryBrowserState:
1663
+ state = self.directory_browsers.get(chat_key)
1664
+ if state is None:
1665
+ raise ValueError(BROWSER_STALE_MESSAGE)
1666
+ if token is not None and state.token != token:
1667
+ raise ValueError(BROWSER_STALE_MESSAGE)
1668
+ if version is not None and state.version != version:
1669
+ raise ValueError(BROWSER_STALE_MESSAGE)
1670
+ return state
1671
+
1672
+ def remember_browser_message(
1673
+ self, chat_key: str, token: str, message_id: int | None
1674
+ ) -> None:
1675
+ if message_id is None:
1676
+ return
1677
+ browser = self.get_browser(chat_key, token=token)
1678
+ browser.message_id = message_id
1679
+
1680
+ def close_directory_browser(self, chat_key: str, token: str, version: int) -> None:
1681
+ self.get_browser(chat_key, token=token, version=version)
1682
+ self.directory_browsers.pop(chat_key, None)
1683
+
1684
+ def navigate_directory_browser(
1685
+ self,
1686
+ chat_key: str,
1687
+ token: str,
1688
+ version: int,
1689
+ action: str,
1690
+ index: int | None = None,
1691
+ ) -> DirectoryBrowserState:
1692
+ browser = self.get_browser(chat_key, token=token, version=version)
1693
+ if action == "open":
1694
+ if index is None or not 0 <= index < len(browser.entries):
1695
+ raise ValueError("目录项不存在。")
1696
+ return self._replace_browser_state(
1697
+ chat_key,
1698
+ browser.entries[index].path,
1699
+ page=0,
1700
+ previous=browser,
1701
+ )
1702
+ if action == "up":
1703
+ return self._replace_browser_state(
1704
+ chat_key,
1705
+ str(Path(browser.current_path).parent),
1706
+ page=0,
1707
+ previous=browser,
1708
+ )
1709
+ if action == "root":
1710
+ root = Path(browser.current_path).anchor or "/"
1711
+ return self._replace_browser_state(
1712
+ chat_key,
1713
+ root,
1714
+ page=0,
1715
+ previous=browser,
1716
+ )
1717
+ if action == "home":
1718
+ return self._replace_browser_state(
1719
+ chat_key,
1720
+ str(Path.home()),
1721
+ page=0,
1722
+ previous=browser,
1723
+ )
1724
+ if action == "refresh":
1725
+ return self._replace_browser_state(
1726
+ chat_key,
1727
+ browser.current_path,
1728
+ page=browser.page,
1729
+ previous=browser,
1730
+ )
1731
+ if action == "toggle_hidden":
1732
+ return self._replace_browser_state(
1733
+ chat_key,
1734
+ browser.current_path,
1735
+ page=browser.page,
1736
+ show_hidden=not browser.show_hidden,
1737
+ previous=browser,
1738
+ )
1739
+ if action == "prev":
1740
+ return self._replace_browser_state(
1741
+ chat_key,
1742
+ browser.current_path,
1743
+ page=browser.page - 1,
1744
+ previous=browser,
1745
+ )
1746
+ if action == "next":
1747
+ return self._replace_browser_state(
1748
+ chat_key,
1749
+ browser.current_path,
1750
+ page=browser.page + 1,
1751
+ previous=browser,
1752
+ )
1753
+ raise ValueError("未知目录操作。")
1754
+
1755
+ async def apply_browser_directory(
1756
+ self, chat_key: str, token: str, version: int
1757
+ ) -> str:
1758
+ browser = self.get_browser(chat_key, token=token, version=version)
1759
+ notice = await self.update_workdir(chat_key, browser.current_path)
1760
+ self._replace_browser_state(
1761
+ chat_key,
1762
+ browser.current_path,
1763
+ page=browser.page,
1764
+ previous=browser,
1765
+ )
1766
+ return notice
1767
+
1768
+ def render_directory_browser(self, chat_key: str) -> tuple[str, InlineKeyboardMarkup]:
1769
+ browser = self.get_browser(chat_key)
1770
+ preferences = self.get_preferences(chat_key)
1771
+ total_pages = self._browser_total_pages(browser.entries)
1772
+ start = browser.page * BROWSER_PAGE_SIZE
1773
+ end = start + BROWSER_PAGE_SIZE
1774
+ current_entries = browser.entries[start:end]
1775
+
1776
+ lines = [
1777
+ "目录浏览",
1778
+ f"浏览路径:{browser.current_path}",
1779
+ f"当前工作目录:{preferences.workdir}",
1780
+ f"子目录:{len(browser.entries)}",
1781
+ format_file_summary(browser.files),
1782
+ ]
1783
+ if total_pages > 1:
1784
+ lines.append(f"第 {browser.page + 1}/{total_pages} 页")
1785
+ if not browser.entries:
1786
+ lines.append("当前目录没有子目录。")
1787
+
1788
+ keyboard: list[list[InlineKeyboardButton]] = []
1789
+ for offset, entry in enumerate(current_entries):
1790
+ keyboard.append(
1791
+ [
1792
+ InlineKeyboardButton(
1793
+ text=entry.name,
1794
+ callback_data=encode_browser_callback(
1795
+ browser.token,
1796
+ browser.version,
1797
+ "open",
1798
+ start + offset,
1799
+ ),
1800
+ )
1801
+ ]
1802
+ )
1803
+
1804
+ if total_pages > 1:
1805
+ page_buttons: list[InlineKeyboardButton] = []
1806
+ if browser.page > 0:
1807
+ page_buttons.append(
1808
+ InlineKeyboardButton(
1809
+ text="上一页",
1810
+ callback_data=encode_browser_callback(
1811
+ browser.token,
1812
+ browser.version,
1813
+ "prev",
1814
+ ),
1815
+ )
1816
+ )
1817
+ if browser.page + 1 < total_pages:
1818
+ page_buttons.append(
1819
+ InlineKeyboardButton(
1820
+ text="下一页",
1821
+ callback_data=encode_browser_callback(
1822
+ browser.token,
1823
+ browser.version,
1824
+ "next",
1825
+ ),
1826
+ )
1827
+ )
1828
+ if page_buttons:
1829
+ keyboard.append(page_buttons)
1830
+
1831
+ keyboard.append(
1832
+ [
1833
+ InlineKeyboardButton(
1834
+ text="上一级",
1835
+ callback_data=encode_browser_callback(
1836
+ browser.token, browser.version, "up"
1837
+ ),
1838
+ ),
1839
+ InlineKeyboardButton(
1840
+ text="根目录 /",
1841
+ callback_data=encode_browser_callback(
1842
+ browser.token, browser.version, "root"
1843
+ ),
1844
+ ),
1845
+ InlineKeyboardButton(
1846
+ text="Home",
1847
+ callback_data=encode_browser_callback(
1848
+ browser.token, browser.version, "home"
1849
+ ),
1850
+ ),
1851
+ ]
1852
+ )
1853
+ keyboard.append(
1854
+ [
1855
+ InlineKeyboardButton(
1856
+ text="隐藏 .开头项" if browser.show_hidden else "显示 .开头项",
1857
+ callback_data=encode_browser_callback(
1858
+ browser.token,
1859
+ browser.version,
1860
+ "toggle_hidden",
1861
+ ),
1862
+ )
1863
+ ]
1864
+ )
1865
+ keyboard.append(
1866
+ [
1867
+ InlineKeyboardButton(
1868
+ text="设为当前工作目录",
1869
+ callback_data=encode_browser_callback(
1870
+ browser.token,
1871
+ browser.version,
1872
+ "apply",
1873
+ ),
1874
+ ),
1875
+ InlineKeyboardButton(
1876
+ text="刷新",
1877
+ callback_data=encode_browser_callback(
1878
+ browser.token,
1879
+ browser.version,
1880
+ "refresh",
1881
+ ),
1882
+ ),
1883
+ ]
1884
+ )
1885
+ keyboard.append(
1886
+ [
1887
+ InlineKeyboardButton(
1888
+ text="关闭",
1889
+ callback_data=encode_browser_callback(
1890
+ browser.token, browser.version, "close"
1891
+ ),
1892
+ )
1893
+ ]
1894
+ )
1895
+ return "\n".join(lines), InlineKeyboardMarkup(inline_keyboard=keyboard)
1896
+
1897
+ async def reset_chat(self, chat_key: str, *, keep_active: bool) -> ChatSession:
1898
+ session = self.get_session(chat_key)
1899
+ session.cancel_requested = True
1900
+ runner_task = session.runner_task
1901
+ await self._close_native_runner(session.native_runner)
1902
+ await terminate_process(session.process, self.settings.kill_timeout)
1903
+ current_task = asyncio.current_task()
1904
+ if runner_task is not None and runner_task is not current_task:
1905
+ try:
1906
+ await asyncio.wait_for(
1907
+ asyncio.shield(runner_task), timeout=self.settings.kill_timeout
1908
+ )
1909
+ except (asyncio.TimeoutError, asyncio.CancelledError, Exception):
1910
+ pass
1911
+ session.active = keep_active
1912
+ preferences = self.preference_overrides.get(chat_key)
1913
+ if preferences is not None:
1914
+ session.active_mode = preferences.default_mode
1915
+ elif session.active_mode not in {"resume", "exec"}:
1916
+ session.active_mode = "resume"
1917
+ session.native_thread_id = None
1918
+ session.exec_thread_id = None
1919
+ session.thread_id = None
1920
+ session.strict_resume = False
1921
+ session.running = False
1922
+ session.process = None
1923
+ session.native_runner = None
1924
+ session.runner_task = None
1925
+ session.progress_message_id = None
1926
+ session.stream_message_id = None
1927
+ session.last_agent_message = ""
1928
+ session.last_stream_text = ""
1929
+ session.last_stream_rendered_text = ""
1930
+ session.stream_message_truncated = False
1931
+ session.progress_lines.clear()
1932
+ session.diagnostics.clear()
1933
+ session.cancel_requested = False
1934
+ return session
1935
+
1936
+ async def update_model(self, chat_key: str, slug: str) -> str:
1937
+ self._ensure_not_running(chat_key)
1938
+ models = self.load_models()
1939
+ if slug not in models:
1940
+ raise ValueError("未找到指定模型。")
1941
+
1942
+ current = self.get_preferences(chat_key)
1943
+ model = models[slug]
1944
+ next_effort = current.reasoning_effort
1945
+ notice = ""
1946
+ if next_effort not in model.supported_reasoning_levels:
1947
+ downgraded = self._normalize_effort(model, "high")
1948
+ next_effort = downgraded
1949
+ notice = f"推理强度已自动降级为 {downgraded}。"
1950
+
1951
+ self.preference_overrides[chat_key] = ChatPreferences(
1952
+ model=slug,
1953
+ reasoning_effort=next_effort,
1954
+ permission_mode=current.permission_mode,
1955
+ workdir=current.workdir,
1956
+ default_mode=current.default_mode,
1957
+ )
1958
+ self._persist_preferences()
1959
+ self._clear_thread_only(chat_key)
1960
+ if notice:
1961
+ return f"{notice}\n当前设置:{self.describe_preferences(chat_key)}"
1962
+ return f"当前设置:{self.describe_preferences(chat_key)}"
1963
+
1964
+ async def update_reasoning_effort(self, chat_key: str, effort: str) -> str:
1965
+ self._ensure_not_running(chat_key)
1966
+ if effort not in SUPPORTED_EFFORT_COMMANDS:
1967
+ raise ValueError("仅支持 high 或 xhigh。")
1968
+
1969
+ current = self.get_preferences(chat_key)
1970
+ model = self.load_models().get(current.model)
1971
+ if model is None:
1972
+ raise ValueError("当前模型不在本地缓存中。")
1973
+ if effort not in model.supported_reasoning_levels:
1974
+ supported = ", ".join(model.supported_reasoning_levels)
1975
+ raise ValueError(f"当前模型仅支持:{supported}")
1976
+
1977
+ self.preference_overrides[chat_key] = ChatPreferences(
1978
+ model=current.model,
1979
+ reasoning_effort=effort,
1980
+ permission_mode=current.permission_mode,
1981
+ workdir=current.workdir,
1982
+ default_mode=current.default_mode,
1983
+ )
1984
+ self._persist_preferences()
1985
+ self._clear_thread_only(chat_key)
1986
+ return f"当前设置:{self.describe_preferences(chat_key)}"
1987
+
1988
+ async def update_permission_mode(self, chat_key: str, permission_mode: str) -> str:
1989
+ self._ensure_not_running(chat_key)
1990
+ if permission_mode not in SUPPORTED_PERMISSION_MODES:
1991
+ raise ValueError("仅支持 safe 或 danger。")
1992
+
1993
+ current = self.get_preferences(chat_key)
1994
+ self.preference_overrides[chat_key] = ChatPreferences(
1995
+ model=current.model,
1996
+ reasoning_effort=current.reasoning_effort,
1997
+ permission_mode=permission_mode,
1998
+ workdir=current.workdir,
1999
+ default_mode=current.default_mode,
2000
+ )
2001
+ self._persist_preferences()
2002
+ self._clear_thread_only(chat_key)
2003
+ return f"当前设置:{self.describe_preferences(chat_key)}"
2004
+
2005
+ async def update_workdir(self, chat_key: str, workdir: str) -> str:
2006
+ self._ensure_not_running(chat_key)
2007
+ resolved = self._resolve_directory_path(chat_key, workdir)
2008
+ current = self.get_preferences(chat_key)
2009
+ self.preference_overrides[chat_key] = ChatPreferences(
2010
+ model=current.model,
2011
+ reasoning_effort=current.reasoning_effort,
2012
+ permission_mode=current.permission_mode,
2013
+ workdir=resolved,
2014
+ default_mode=current.default_mode,
2015
+ )
2016
+ self._persist_preferences()
2017
+ self._clear_thread_only(chat_key)
2018
+ return self.describe_workdir(chat_key)
2019
+
2020
+ async def update_default_mode(self, chat_key: str, mode: str) -> str:
2021
+ self._ensure_not_running(chat_key)
2022
+ if mode not in {"resume", "exec"}:
2023
+ raise ValueError("仅支持 resume 或 exec。")
2024
+
2025
+ current = self.get_preferences(chat_key)
2026
+ self.preference_overrides[chat_key] = ChatPreferences(
2027
+ model=current.model,
2028
+ reasoning_effort=current.reasoning_effort,
2029
+ permission_mode=current.permission_mode,
2030
+ workdir=current.workdir,
2031
+ default_mode=mode,
2032
+ )
2033
+ self._persist_preferences()
2034
+ session = self.get_session(chat_key)
2035
+ session.active_mode = mode
2036
+ self._sync_legacy_thread_id(session)
2037
+ return f"当前默认模式:{mode}"
2038
+
2039
+ def get_supported_efforts(self, model_slug: str) -> list[str]:
2040
+ model = self.load_models().get(model_slug)
2041
+ if model is None:
2042
+ raise ValueError("未找到指定模型。")
2043
+ return model.supported_reasoning_levels
2044
+
2045
+ async def run_prompt(
2046
+ self,
2047
+ chat_key: str,
2048
+ prompt: str,
2049
+ *,
2050
+ mode_override: str | None = None,
2051
+ on_progress: ProgressCallback | None = None,
2052
+ on_stream_text: StreamTextCallback | None = None,
2053
+ ) -> RunResult:
2054
+ session = self.activate_chat(chat_key)
2055
+ if session.running:
2056
+ raise RuntimeError("Codex is already running for this chat")
2057
+ if not self.which_resolver(self.settings.binary):
2058
+ raise FileNotFoundError(self.settings.binary)
2059
+
2060
+ clean_prompt = prompt.strip()
2061
+ if not clean_prompt:
2062
+ return RunResult(exit_code=0, notice="输入为空,未发送到 Codex。")
2063
+
2064
+ preferences = self.get_preferences(chat_key)
2065
+ mode = mode_override or session.active_mode or preferences.default_mode
2066
+ if mode == "resume" and self.native_client is not None:
2067
+ result = await self._run_native_prompt(
2068
+ session,
2069
+ clean_prompt,
2070
+ preferences=preferences,
2071
+ on_progress=on_progress,
2072
+ on_stream_text=on_stream_text,
2073
+ )
2074
+ return result
2075
+
2076
+ result = await self._run_exec_prompt(
2077
+ session,
2078
+ clean_prompt,
2079
+ previous_thread=self._current_exec_thread_id(session),
2080
+ preferences=preferences,
2081
+ on_progress=on_progress,
2082
+ on_stream_text=on_stream_text,
2083
+ )
2084
+ return result
2085
+
2086
+ async def _run_exec_prompt(
2087
+ self,
2088
+ session: ChatSession,
2089
+ prompt: str,
2090
+ *,
2091
+ previous_thread: str | None,
2092
+ preferences: ChatPreferences,
2093
+ on_progress: ProgressCallback | None,
2094
+ on_stream_text: StreamTextCallback | None,
2095
+ ) -> RunResult:
2096
+ result = await self._run_exec_once(
2097
+ session,
2098
+ prompt,
2099
+ preferences=preferences,
2100
+ on_progress=on_progress,
2101
+ on_stream_text=on_stream_text,
2102
+ )
2103
+ if result.cancelled:
2104
+ return result
2105
+
2106
+ if (
2107
+ previous_thread
2108
+ and result.exit_code != 0
2109
+ and not result.final_text
2110
+ and not session.strict_resume
2111
+ ):
2112
+ self._set_exec_thread_id(session, None)
2113
+ self._sync_legacy_thread_id(session)
2114
+ if on_progress is not None:
2115
+ await on_progress("原会话恢复失败,正在新开会话…")
2116
+ result = await self._run_exec_once(
2117
+ session,
2118
+ prompt,
2119
+ preferences=preferences,
2120
+ on_progress=on_progress,
2121
+ on_stream_text=on_stream_text,
2122
+ )
2123
+ result.notice = "原会话未成功恢复,已新开会话。"
2124
+ return result
2125
+
2126
+ if previous_thread and result.thread_id and result.thread_id != previous_thread:
2127
+ result.notice = "原会话未成功恢复,已自动切换到新会话。"
2128
+ return result
2129
+
2130
+ async def _run_exec_once(
2131
+ self,
2132
+ session: ChatSession,
2133
+ prompt: str,
2134
+ *,
2135
+ preferences: ChatPreferences,
2136
+ on_progress: ProgressCallback | None,
2137
+ on_stream_text: StreamTextCallback | None,
2138
+ ) -> RunResult:
2139
+ session.running = True
2140
+ session.cancel_requested = False
2141
+ session.last_agent_message = ""
2142
+ session.last_stream_text = ""
2143
+ session.last_stream_rendered_text = ""
2144
+ session.stream_message_truncated = False
2145
+ session.progress_lines.clear()
2146
+ session.diagnostics.clear()
2147
+
2148
+ exec_thread_id = self._current_exec_thread_id(session)
2149
+ starting_new_thread = exec_thread_id is None
2150
+ argv = build_exec_argv(
2151
+ self.settings.binary,
2152
+ preferences.workdir,
2153
+ prompt,
2154
+ model=preferences.model,
2155
+ reasoning_effort=preferences.reasoning_effort,
2156
+ permission_mode=preferences.permission_mode,
2157
+ thread_id=exec_thread_id,
2158
+ )
2159
+ process = await self.launcher(
2160
+ *argv,
2161
+ stdout=asyncio.subprocess.PIPE,
2162
+ stderr=asyncio.subprocess.STDOUT,
2163
+ cwd=preferences.workdir,
2164
+ limit=self.settings.stream_read_limit,
2165
+ )
2166
+ session.process = process
2167
+
2168
+ if on_progress is not None:
2169
+ await on_progress(
2170
+ render_progress_text(
2171
+ session,
2172
+ header=(
2173
+ format_preferences_summary(preferences)
2174
+ if starting_new_thread
2175
+ else None
2176
+ ),
2177
+ )
2178
+ )
2179
+
2180
+ stdout = getattr(process, "stdout", None)
2181
+ try:
2182
+ while stdout is not None:
2183
+ raw_line = await stdout.readline()
2184
+ if not raw_line:
2185
+ break
2186
+ line = raw_line.decode("utf-8", errors="replace").strip()
2187
+ if not line:
2188
+ continue
2189
+ event = parse_event_line(line)
2190
+ if event is None:
2191
+ _append_diagnostic(session, line, self.settings.diagnostic_history)
2192
+ continue
2193
+ changed, stream_text = _apply_event(
2194
+ session,
2195
+ event,
2196
+ progress_history=self.settings.progress_history,
2197
+ )
2198
+ if event.get("type") == "thread.started":
2199
+ thread_id = event.get("thread_id")
2200
+ if isinstance(thread_id, str) and thread_id:
2201
+ self._set_exec_thread_id(session, thread_id)
2202
+ self._sync_legacy_thread_id(session)
2203
+ if changed and on_progress is not None:
2204
+ await on_progress(render_progress_text(session))
2205
+ if stream_text is not None and on_stream_text is not None:
2206
+ await on_stream_text(stream_text)
2207
+
2208
+ exit_code = await process.wait()
2209
+ cancelled = session.cancel_requested
2210
+ except Exception:
2211
+ await terminate_process(process, self.settings.kill_timeout)
2212
+ raise
2213
+ finally:
2214
+ session.running = False
2215
+ session.process = None
2216
+ session.cancel_requested = False
2217
+
2218
+ return RunResult(
2219
+ exit_code=exit_code,
2220
+ final_text=session.last_agent_message,
2221
+ thread_id=self._current_exec_thread_id(session),
2222
+ diagnostics=list(session.diagnostics),
2223
+ cancelled=cancelled,
2224
+ )
2225
+
2226
+ async def _run_native_prompt(
2227
+ self,
2228
+ session: ChatSession,
2229
+ prompt: str,
2230
+ *,
2231
+ preferences: ChatPreferences,
2232
+ on_progress: ProgressCallback | None,
2233
+ on_stream_text: StreamTextCallback | None,
2234
+ ) -> RunResult:
2235
+ if self.native_client is None:
2236
+ raise RuntimeError("Native Codex client is not configured.")
2237
+
2238
+ native_runner = self._spawn_native_client()
2239
+ if native_runner is None:
2240
+ raise RuntimeError("Native Codex client is not configured.")
2241
+
2242
+ session.running = True
2243
+ session.cancel_requested = False
2244
+ session.native_runner = native_runner
2245
+ session.runner_task = asyncio.current_task()
2246
+ session.last_agent_message = ""
2247
+ session.last_stream_text = ""
2248
+ session.last_stream_rendered_text = ""
2249
+ session.stream_message_truncated = False
2250
+ session.progress_lines.clear()
2251
+ session.diagnostics.clear()
2252
+
2253
+ starting_new_thread = session.native_thread_id is None
2254
+ if on_progress is not None:
2255
+ await on_progress(
2256
+ render_progress_text(
2257
+ session,
2258
+ header=(
2259
+ format_preferences_summary(preferences)
2260
+ if starting_new_thread
2261
+ else None
2262
+ ),
2263
+ )
2264
+ )
2265
+
2266
+ async def forward_progress(line: str) -> None:
2267
+ _append_progress_line(session, line, self.settings.progress_history)
2268
+ if on_progress is not None:
2269
+ await on_progress(render_progress_text(session))
2270
+
2271
+ async def forward_stream_text(text: str) -> None:
2272
+ stripped = text.strip()
2273
+ if not stripped:
2274
+ return
2275
+ session.last_agent_message = stripped
2276
+ session.last_stream_text = stripped
2277
+ if on_stream_text is not None:
2278
+ await on_stream_text(stripped)
2279
+
2280
+ try:
2281
+ if session.native_thread_id is None:
2282
+ thread = await native_runner.start_thread(
2283
+ workdir=preferences.workdir,
2284
+ model=preferences.model,
2285
+ reasoning_effort=preferences.reasoning_effort,
2286
+ permission_mode=preferences.permission_mode,
2287
+ )
2288
+ else:
2289
+ thread = await native_runner.resume_thread(
2290
+ session.native_thread_id,
2291
+ workdir=preferences.workdir,
2292
+ model=preferences.model,
2293
+ reasoning_effort=preferences.reasoning_effort,
2294
+ permission_mode=preferences.permission_mode,
2295
+ )
2296
+ self._set_native_thread_id(session, thread.thread_id)
2297
+ native_result = await native_runner.run_turn(
2298
+ thread.thread_id,
2299
+ prompt,
2300
+ cwd=preferences.workdir,
2301
+ model=preferences.model,
2302
+ reasoning_effort=preferences.reasoning_effort,
2303
+ on_progress=forward_progress,
2304
+ on_stream_text=forward_stream_text,
2305
+ )
2306
+ final_thread_id = native_result.thread_id or thread.thread_id
2307
+ self._set_native_thread_id(session, final_thread_id)
2308
+ if native_result.final_text.strip():
2309
+ session.last_agent_message = native_result.final_text.strip()
2310
+ session.last_stream_text = native_result.final_text.strip()
2311
+ return RunResult(
2312
+ exit_code=native_result.exit_code,
2313
+ final_text=session.last_agent_message,
2314
+ thread_id=final_thread_id,
2315
+ diagnostics=list(native_result.diagnostics),
2316
+ cancelled=session.cancel_requested,
2317
+ )
2318
+ except Exception as exc:
2319
+ if session.cancel_requested:
2320
+ return RunResult(
2321
+ exit_code=1,
2322
+ thread_id=session.native_thread_id,
2323
+ diagnostics=list(session.diagnostics),
2324
+ cancelled=True,
2325
+ )
2326
+ _append_diagnostic(session, str(exc), self.settings.diagnostic_history)
2327
+ return RunResult(
2328
+ exit_code=1,
2329
+ thread_id=session.native_thread_id,
2330
+ diagnostics=list(session.diagnostics),
2331
+ )
2332
+ finally:
2333
+ await self._close_native_runner(session.native_runner)
2334
+ session.running = False
2335
+ session.process = None
2336
+ session.native_runner = None
2337
+ session.runner_task = None
2338
+ session.cancel_requested = False