remote-claude 1.0.2 → 1.0.4

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.
package/remote_claude.py CHANGED
@@ -252,6 +252,44 @@ def cmd_status(args):
252
252
  return 0
253
253
 
254
254
 
255
+ WATCHDOG_SCRIPT = USER_DATA_DIR / "watchdog.sh"
256
+ WATCHDOG_PID_FILE = USER_DATA_DIR / "watchdog.pid"
257
+
258
+
259
+ def _start_watchdog():
260
+ """启动后台 watchdog(如果尚未运行)"""
261
+ if not WATCHDOG_SCRIPT.exists():
262
+ return # 脚本不存在时静默跳过
263
+ # 检查是否已在运行
264
+ if WATCHDOG_PID_FILE.exists():
265
+ try:
266
+ pid = int(WATCHDOG_PID_FILE.read_text().strip())
267
+ os.kill(pid, 0)
268
+ return # 已在运行
269
+ except (ProcessLookupError, ValueError, OSError):
270
+ pass
271
+ process = subprocess.Popen(
272
+ ["bash", str(WATCHDOG_SCRIPT)],
273
+ stdout=subprocess.DEVNULL, # watchdog 通过 tee 自己写 $LOG,不需要 stdout 捕获
274
+ stderr=subprocess.DEVNULL,
275
+ start_new_session=True,
276
+ )
277
+ print(f" watchdog: 已启动 (PID: {process.pid})")
278
+
279
+
280
+ def _stop_watchdog():
281
+ """停止后台 watchdog"""
282
+ if not WATCHDOG_PID_FILE.exists():
283
+ return
284
+ try:
285
+ pid = int(WATCHDOG_PID_FILE.read_text().strip())
286
+ os.kill(pid, signal.SIGTERM)
287
+ print(f" watchdog: 已停止 (PID: {pid})")
288
+ except (ProcessLookupError, ValueError, OSError):
289
+ pass
290
+ WATCHDOG_PID_FILE.unlink(missing_ok=True)
291
+
292
+
255
293
  def cmd_lark_start(args):
256
294
  """启动飞书客户端(守护进程)"""
257
295
  if is_lark_running():
@@ -296,6 +334,7 @@ def cmd_lark_start(args):
296
334
  print(f" 日志: {log_file}")
297
335
  print(f"\n使用 'python3 remote_claude.py lark status' 查看状态")
298
336
  print(f"使用 'python3 remote_claude.py lark stop' 停止")
337
+ _start_watchdog()
299
338
  return 0
300
339
  else:
301
340
  print("✗ 启动失败,请查看日志:")
@@ -342,6 +381,7 @@ def cmd_lark_stop(args):
342
381
  if not is_lark_running():
343
382
  print("✓ 飞书客户端已停止")
344
383
  cleanup_lark()
384
+ _stop_watchdog()
345
385
  return 0
346
386
  else:
347
387
  print("✗ 无法停止进程,请手动终止:")
@@ -351,6 +391,7 @@ def cmd_lark_stop(args):
351
391
  except ProcessLookupError:
352
392
  print("进程已不存在,清理残留文件")
353
393
  cleanup_lark()
394
+ _stop_watchdog()
354
395
  return 0
355
396
  except Exception as e:
356
397
  print(f"✗ 停止失败: {e}")
@@ -477,6 +518,184 @@ def cmd_update(args):
477
518
  return 0
478
519
 
479
520
 
521
+ def cmd_deps(args):
522
+ """检查并安装依赖"""
523
+ import shutil
524
+
525
+ YELLOW = "\033[33m"
526
+ GREEN = "\033[32m"
527
+ RED = "\033[31m"
528
+ RESET = "\033[0m"
529
+
530
+ def print_ok(msg):
531
+ print(f"{GREEN}✓{RESET} {msg}")
532
+
533
+ def print_warn(msg):
534
+ print(f"{YELLOW}⚠{RESET} {msg}")
535
+
536
+ def print_err(msg):
537
+ print(f"{RED}✗{RESET} {msg}")
538
+
539
+ print(f"\n{GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
540
+ print(f"{GREEN} 依赖检查{RESET}")
541
+ print(f"{GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}\n")
542
+
543
+ # 检查 uv
544
+ uv_path = shutil.which("uv")
545
+ if uv_path:
546
+ r = subprocess.run(["uv", "--version"], capture_output=True, text=True)
547
+ print_ok(f"uv: {r.stdout.strip()}")
548
+ else:
549
+ print_err("uv: 未安装")
550
+
551
+ # 检查 claude CLI
552
+ claude_path = shutil.which("claude")
553
+ if claude_path:
554
+ print_ok("Claude CLI: 已安装")
555
+ else:
556
+ print_warn("Claude CLI: 未安装")
557
+
558
+ # 检查 codex CLI
559
+ codex_path = shutil.which("codex")
560
+ if codex_path:
561
+ print_ok("Codex CLI: 已安装")
562
+ else:
563
+ print_warn("Codex CLI: 未安装(可选)")
564
+
565
+ # 检查 tmux
566
+ REQUIRED_MAJOR = 3
567
+ REQUIRED_MINOR = 6
568
+
569
+ tmux_path = shutil.which("tmux")
570
+ tmux_ok = False
571
+ if tmux_path:
572
+ r = subprocess.run(["tmux", "-V"], capture_output=True, text=True)
573
+ ver_str = r.stdout.strip().split()[-1] if r.stdout.strip() else "0.0"
574
+ parts = ver_str.replace("a", "").replace("b", "").replace("c", "").split(".")
575
+ try:
576
+ major = int(parts[0])
577
+ minor = int(parts[1]) if len(parts) > 1 else 0
578
+ except ValueError:
579
+ major, minor = 0, 0
580
+ if major > REQUIRED_MAJOR or (major == REQUIRED_MAJOR and minor >= REQUIRED_MINOR):
581
+ print_ok(f"tmux: {r.stdout.strip()}(满足 >= {REQUIRED_MAJOR}.{REQUIRED_MINOR})")
582
+ tmux_ok = True
583
+ else:
584
+ print_warn(f"tmux: {r.stdout.strip()}(需要 >= {REQUIRED_MAJOR}.{REQUIRED_MINOR})")
585
+ else:
586
+ print_err("tmux: 未安装")
587
+
588
+ if tmux_ok:
589
+ print(f"\n{GREEN}所有关键依赖已满足。{RESET}")
590
+ return 0
591
+
592
+ # tmux 版本不满足,提供源码编译安装
593
+ print(f"\n{YELLOW}tmux 版本不满足要求,是否从源码编译安装 tmux 3.6a?{RESET}")
594
+ print(f" 安装位置: $HOME/.local(不需要 root 权限)")
595
+ print(f" 编译依赖安装可能需要 sudo 密码\n")
596
+
597
+ try:
598
+ answer = input("继续?[y/N]: ").strip().lower()
599
+ except (EOFError, KeyboardInterrupt):
600
+ print()
601
+ return 0
602
+
603
+ if answer not in ("y", "yes"):
604
+ print("已跳过 tmux 安装。")
605
+ return 0
606
+
607
+ # 安装编译依赖
608
+ print(f"\n{YELLOW}[1/4] 安装编译依赖...{RESET}")
609
+ os_name = os.uname().sysname
610
+ if os_name == "Darwin":
611
+ subprocess.run(["brew", "install", "libevent", "ncurses", "pkg-config", "bison"], check=False)
612
+ elif os_name == "Linux":
613
+ if shutil.which("apt-get"):
614
+ subprocess.run(["sudo", "apt-get", "update"], check=False)
615
+ subprocess.run(["sudo", "apt-get", "install", "-y",
616
+ "build-essential", "libevent-dev", "libncurses5-dev",
617
+ "libncursesw5-dev", "bison", "pkg-config"], check=False)
618
+ elif shutil.which("yum"):
619
+ subprocess.run(["sudo", "yum", "groupinstall", "-y", "Development Tools"], check=False)
620
+ subprocess.run(["sudo", "yum", "install", "-y",
621
+ "libevent-devel", "ncurses-devel", "bison"], check=False)
622
+ else:
623
+ print_warn("无法识别包管理器,请手动安装编译依赖: libevent-dev ncurses-dev bison pkg-config")
624
+
625
+ # 下载源码
626
+ print(f"\n{YELLOW}[2/4] 下载 tmux 3.6a 源码...{RESET}")
627
+ import tempfile
628
+ tmpdir = tempfile.mkdtemp()
629
+ tarball = os.path.join(tmpdir, "tmux.tar.gz")
630
+ tmux_url = "https://github.com/tmux/tmux/releases/download/3.6a/tmux-3.6a.tar.gz"
631
+
632
+ r = subprocess.run(["curl", "-fsSL", tmux_url, "-o", tarball])
633
+ if r.returncode != 0:
634
+ print_err("下载失败,请检查网络连接。")
635
+ return 1
636
+
637
+ subprocess.run(["tar", "-xzf", tarball, "-C", tmpdir], check=True)
638
+ src_dir = os.path.join(tmpdir, "tmux-3.6a")
639
+
640
+ # 编译
641
+ prefix = os.path.join(os.path.expanduser("~"), ".local")
642
+ print(f"\n{YELLOW}[3/4] 编译 tmux(安装到 {prefix})...{RESET}")
643
+
644
+ nproc = "2"
645
+ try:
646
+ r = subprocess.run(["nproc"], capture_output=True, text=True)
647
+ if r.returncode == 0:
648
+ nproc = r.stdout.strip()
649
+ except FileNotFoundError:
650
+ pass
651
+
652
+ r = subprocess.run(
653
+ f"./configure --prefix={prefix} && make -j{nproc} && make install",
654
+ shell=True, cwd=src_dir
655
+ )
656
+ if r.returncode != 0:
657
+ print_err("编译失败,请检查编译依赖是否已安装。")
658
+ return 1
659
+
660
+ # 清理临时目录
661
+ import shutil as _shutil
662
+ _shutil.rmtree(tmpdir, ignore_errors=True)
663
+
664
+ # 配置 PATH
665
+ print(f"\n{YELLOW}[4/4] 配置 PATH...{RESET}")
666
+ local_bin = os.path.join(prefix, "bin")
667
+ os.environ["PATH"] = f"{local_bin}:{os.environ.get('PATH', '')}"
668
+
669
+ if f"{local_bin}" not in os.environ.get("PATH", ""):
670
+ os.environ["PATH"] = f"{local_bin}:{os.environ['PATH']}"
671
+
672
+ # 写入 shell rc
673
+ shell_name = os.path.basename(os.environ.get("SHELL", "bash"))
674
+ rc_file = os.path.join(os.path.expanduser("~"), ".zshrc" if shell_name == "zsh" else ".bashrc")
675
+ path_line = 'export PATH="$HOME/.local/bin:$PATH"'
676
+ try:
677
+ rc_content = open(rc_file).read() if os.path.exists(rc_file) else ""
678
+ if "$HOME/.local/bin" not in rc_content:
679
+ with open(rc_file, "a") as f:
680
+ f.write(f"\n# remote-claude: tmux 路径\n{path_line}\n")
681
+ print_ok(f"已将 $HOME/.local/bin 写入 {rc_file}")
682
+ except Exception as e:
683
+ print_warn(f"无法写入 {rc_file}: {e}")
684
+
685
+ # 验证
686
+ tmux_bin = os.path.join(local_bin, "tmux")
687
+ if os.path.exists(tmux_bin):
688
+ r = subprocess.run([tmux_bin, "-V"], capture_output=True, text=True)
689
+ print(f"\n{GREEN}✓ tmux 安装成功: {r.stdout.strip()}{RESET}")
690
+ print(f" 路径: {tmux_bin}")
691
+ print(f" 请运行 source {rc_file} 或重新打开终端使 PATH 生效。")
692
+ else:
693
+ print_err("安装似乎未成功,请检查上方输出。")
694
+ return 1
695
+
696
+ return 0
697
+
698
+
480
699
  def cmd_lark(args):
481
700
  """飞书客户端管理(兼容旧命令)"""
482
701
  # 如果没有子命令,默认显示状态或启动
@@ -529,6 +748,9 @@ def main():
529
748
 
530
749
  更新:
531
750
  %(prog)s update 更新到最新版本
751
+
752
+ 依赖管理:
753
+ %(prog)s deps 检查依赖并安装(含 tmux 源码编译)
532
754
  """
533
755
  )
534
756
 
@@ -636,6 +858,10 @@ def main():
636
858
  update_parser = subparsers.add_parser("update", help="更新 remote-claude 到最新版本")
637
859
  update_parser.set_defaults(func=cmd_update)
638
860
 
861
+ # deps 命令
862
+ deps_parser = subparsers.add_parser("deps", help="检查并安装依赖(tmux 源码编译等)")
863
+ deps_parser.set_defaults(func=cmd_deps)
864
+
639
865
  args, remaining = parser.parse_known_args()
640
866
 
641
867
  if args.command is None:
@@ -258,6 +258,40 @@ def _get_row_text(screen: pyte.Screen, row: int) -> str:
258
258
  return ''.join(buf).rstrip()
259
259
 
260
260
 
261
+ def _is_dim_fg(fg_color: str) -> bool:
262
+ """判断前景色是否为灰色/暗色(placeholder 风格)。"""
263
+ if not fg_color or fg_color == 'default':
264
+ return False
265
+ name = fg_color.lower().replace(' ', '').replace('-', '')
266
+ if name in ('brightblack', 'black'): # bright_black = gray
267
+ return True
268
+ if len(fg_color) == 6:
269
+ try:
270
+ r, g, b = int(fg_color[0:2], 16), int(fg_color[2:4], 16), int(fg_color[4:6], 16)
271
+ L = 0.2126 * r + 0.7152 * g + 0.0722 * b
272
+ # 灰色调(R≈G≈B)且非纯白 → inactive/placeholder 颜色(如 999999 亮度 153 > 128)
273
+ if max(r, g, b) - min(r, g, b) <= 30 and L < 230:
274
+ return True
275
+ # 非灰色但较暗
276
+ return L < 128
277
+ except ValueError:
278
+ pass
279
+ return False
280
+
281
+
282
+ def _option_label_is_dim(screen: pyte.Screen, row: int) -> bool:
283
+ """检查选项行 label 部分前景色是否为暗色(自由输入 placeholder 风格)。"""
284
+ text = _get_row_text(screen, row)
285
+ m = re.match(r'^[\s❯]*\d+[.)]\s+', text)
286
+ if not m:
287
+ return False
288
+ col = m.end()
289
+ buf_row = screen.buffer[row]
290
+ if col not in buf_row or not buf_row[col].data.strip():
291
+ return False
292
+ return _is_dim_fg(buf_row[col].fg)
293
+
294
+
261
295
  def _get_col0(screen: pyte.Screen, row: int) -> str:
262
296
  """获取指定行第一列字符(col=0)"""
263
297
  try:
@@ -874,6 +908,7 @@ class ClaudeParser(BaseParser):
874
908
 
875
909
  options: List[dict] = []
876
910
  current_opt: Optional[dict] = None
911
+ selected_value = ""
877
912
  ansi_raw_lines = [_get_row_ansi_text(screen, r) for r in input_rows + overflow]
878
913
 
879
914
  for row in all_option_rows:
@@ -891,7 +926,10 @@ class ClaudeParser(BaseParser):
891
926
  'label': m.group(2).strip(),
892
927
  'value': m.group(1),
893
928
  'description': '',
929
+ 'needs_input': _option_label_is_dim(screen, row), # 自由输入选项检测
894
930
  }
931
+ if line.startswith('❯'):
932
+ selected_value = m.group(1)
895
933
  elif current_opt is not None and line:
896
934
  # 描述行
897
935
  current_opt['description'] = (
@@ -905,6 +943,7 @@ class ClaudeParser(BaseParser):
905
943
  return OptionBlock(
906
944
  sub_type='option', tag=tag, question=question, options=options,
907
945
  ansi_raw='\n'.join(ansi_raw_lines).rstrip(),
946
+ selected_value=selected_value,
908
947
  )
909
948
  return None
910
949
 
@@ -1020,6 +1059,7 @@ class ClaudeParser(BaseParser):
1020
1059
  content_lines = pre_option_contents[1:-1]
1021
1060
 
1022
1061
  # 只收集范围内的 options
1062
+ selected_value = ""
1023
1063
  for i in range(first_opt_idx, last_opt_idx + 1):
1024
1064
  line, cat = classified[i]
1025
1065
  if cat == 'option':
@@ -1029,6 +1069,8 @@ class ClaudeParser(BaseParser):
1029
1069
  'label': m.group(2).strip(),
1030
1070
  'value': m.group(1),
1031
1071
  })
1072
+ if line.startswith('❯'):
1073
+ selected_value = m.group(1)
1032
1074
 
1033
1075
  return OptionBlock(
1034
1076
  sub_type='permission',
@@ -1037,6 +1079,7 @@ class ClaudeParser(BaseParser):
1037
1079
  question=question,
1038
1080
  options=options,
1039
1081
  ansi_raw='\n'.join(ansi_lines).rstrip(),
1082
+ selected_value=selected_value,
1040
1083
  )
1041
1084
 
1042
1085
  def _parse_agent_list_panel(
@@ -1256,6 +1256,7 @@ class CodexParser(BaseParser):
1256
1256
 
1257
1257
  options: List[dict] = []
1258
1258
  current_opt: Optional[dict] = None
1259
+ selected_value = ""
1259
1260
  ansi_raw_lines = [_get_row_ansi_text(screen, r) for r in input_rows + overflow]
1260
1261
 
1261
1262
  for row in all_option_rows:
@@ -1274,6 +1275,8 @@ class CodexParser(BaseParser):
1274
1275
  'value': m.group(1),
1275
1276
  'description': '',
1276
1277
  }
1278
+ if line[0:1] in CODEX_PROMPT_CHARS or line.startswith('❯'):
1279
+ selected_value = m.group(1)
1277
1280
  elif current_opt is not None and line:
1278
1281
  # 描述行
1279
1282
  current_opt['description'] = (
@@ -1287,6 +1290,7 @@ class CodexParser(BaseParser):
1287
1290
  return OptionBlock(
1288
1291
  sub_type='option', tag=tag, question=question, options=options,
1289
1292
  ansi_raw='\n'.join(ansi_raw_lines).rstrip(),
1293
+ selected_value=selected_value,
1290
1294
  )
1291
1295
  return None
1292
1296
 
@@ -1407,6 +1411,7 @@ class CodexParser(BaseParser):
1407
1411
  content_lines = pre_option_contents[1:-1]
1408
1412
 
1409
1413
  # 只收集范围内的 options
1414
+ selected_value = ""
1410
1415
  for i in range(first_opt_idx, last_opt_idx + 1):
1411
1416
  line, cat = classified[i]
1412
1417
  if cat == 'option':
@@ -1416,6 +1421,8 @@ class CodexParser(BaseParser):
1416
1421
  'label': m.group(2).strip(),
1417
1422
  'value': m.group(1),
1418
1423
  })
1424
+ if line[0:1] in CODEX_PROMPT_CHARS or line.startswith('❯'):
1425
+ selected_value = m.group(1)
1419
1426
 
1420
1427
  return OptionBlock(
1421
1428
  sub_type='permission',
@@ -1424,6 +1431,7 @@ class CodexParser(BaseParser):
1424
1431
  question=question,
1425
1432
  options=options,
1426
1433
  ansi_raw='\n'.join(ansi_lines).rstrip(),
1434
+ selected_value=selected_value,
1427
1435
  )
1428
1436
 
1429
1437
  def _parse_agent_list_panel(
package/server/server.py CHANGED
@@ -9,6 +9,7 @@ Proxy Server
9
9
  """
10
10
 
11
11
  import asyncio
12
+ import atexit
12
13
  import logging
13
14
  import os
14
15
  import pty
@@ -35,8 +36,8 @@ from utils.protocol import (
35
36
  encode_message, decode_message
36
37
  )
37
38
  from utils.session import (
38
- get_socket_path, get_pid_file, ensure_socket_dir,
39
- generate_client_id, cleanup_session, _safe_filename, get_env_file,
39
+ get_socket_path, get_pid_file, get_name_file, ensure_socket_dir,
40
+ generate_client_id, cleanup_session, _safe_filename, _log_filename, get_env_file,
40
41
  SOCKET_DIR
41
42
  )
42
43
 
@@ -184,12 +185,12 @@ class OutputWatcher:
184
185
  self._on_snapshot = on_snapshot # 回调:写共享内存
185
186
  self._debug_screen = debug_screen # --debug-screen 开启后才写 _screen.log
186
187
  self._debug_verbose = debug_verbose # --debug-verbose 开启后输出 indicator/repr 等诊断信息
187
- safe_name = _safe_filename(session_name)
188
- self._debug_file = f"/tmp/remote-claude/{safe_name}_messages.log"
188
+ log_name = _log_filename(session_name)
189
+ self._debug_file = f"/tmp/remote-claude/{log_name}_messages.log"
189
190
  # PTY 原始字节流日志(仅 --debug-screen 开启时使用)
190
191
  self._raw_log_fd = None
191
192
  if debug_screen:
192
- raw_log_path = f"/tmp/remote-claude/{safe_name}_pty_raw.log"
193
+ raw_log_path = f"/tmp/remote-claude/{log_name}_pty_raw.log"
193
194
  try:
194
195
  self._raw_log_fd = open(raw_log_path, "a", encoding="ascii", buffering=1)
195
196
  except Exception:
@@ -663,7 +664,7 @@ class OutputWatcher:
663
664
  每个字符的 fg/bg 颜色通过 ANSI SGR 序列直接嵌入,
664
665
  cat _screen.log 即可在终端看到与 pyte 渲染一致的着色效果。
665
666
  """
666
- base = f"/tmp/remote-claude/{_safe_filename(self._session_name)}"
667
+ base = f"/tmp/remote-claude/{_log_filename(self._session_name)}"
667
668
  try:
668
669
  # pyte 屏幕快照(覆盖写,只保留最新一帧)
669
670
  screen_path = base + "_screen.log"
@@ -803,12 +804,13 @@ class ProxyServer:
803
804
  """Proxy Server"""
804
805
 
805
806
  def __init__(self, session_name: str, claude_args: list = None,
806
- claude_cmd: str = "claude",
807
+ claude_cmd: str = "claude", codex_cmd: str = "codex",
807
808
  cli_type: str = "claude",
808
809
  debug_screen: bool = False, debug_verbose: bool = False):
809
810
  self.session_name = session_name
810
811
  self.claude_args = claude_args or []
811
812
  self.claude_cmd = claude_cmd
813
+ self.codex_cmd = codex_cmd
812
814
  self.cli_type = cli_type
813
815
  self.debug_screen = debug_screen
814
816
  self.debug_verbose = debug_verbose
@@ -863,6 +865,9 @@ class ProxyServer:
863
865
  # 写入 PID 文件
864
866
  self.pid_file.write_text(str(os.getpid()))
865
867
 
868
+ # 写入会话名映射文件(供 list_active_sessions 恢复原始显示名)
869
+ get_name_file(self.session_name).write_text(self.session_name)
870
+
866
871
  # 启动 Unix Socket 服务器
867
872
  t2 = time.time()
868
873
  self.server = await asyncio.start_unix_server(
@@ -915,9 +920,9 @@ class ProxyServer:
915
920
  logger.info(f"已重定向 stderr 到 {error_log_path}")
916
921
 
917
922
  # 添加运行阶段日志文件
918
- safe_name = _safe_filename(self.session_name)
923
+ log_name = _log_filename(self.session_name)
919
924
  runtime_handler = logging.FileHandler(
920
- f"{SOCKET_DIR}/{safe_name}_server.log",
925
+ f"{SOCKET_DIR}/{log_name}_server.log",
921
926
  encoding="utf-8"
922
927
  )
923
928
  runtime_handler.setFormatter(logging.Formatter(
@@ -930,7 +935,7 @@ class ProxyServer:
930
935
  # DEBUG 级别时额外记录调试日志到独立文件
931
936
  if SERVER_LOG_LEVEL_MAP == logging.DEBUG:
932
937
  debug_handler = logging.FileHandler(
933
- f"{SOCKET_DIR}/{safe_name}_debug.log",
938
+ f"{SOCKET_DIR}/{log_name}_debug.log",
934
939
  encoding="utf-8"
935
940
  )
936
941
  debug_handler.setLevel(logging.DEBUG)
@@ -940,14 +945,14 @@ class ProxyServer:
940
945
  ))
941
946
  debug_handler._debug_handler = True # 标记,方便后续清理
942
947
  root_logger.addHandler(debug_handler)
943
- logger.info(f"已启用 DEBUG 日志: {safe_name}_debug.log")
948
+ logger.info(f"已启用 DEBUG 日志: {log_name}_debug.log")
944
949
 
945
- logger.info(f"日志已切换到运行阶段: {safe_name}_server.log")
950
+ logger.info(f"日志已切换到运行阶段: {log_name}_server.log")
946
951
 
947
952
  def _get_effective_cmd(self) -> str:
948
- """根据 cli_type 返回实际执行的命令(codex 时使用 'codex',否则用 claude_cmd)"""
953
+ """根据 cli_type 返回实际执行的命令"""
949
954
  if self.cli_type == "codex":
950
- return "codex"
955
+ return self.codex_cmd
951
956
  return self.claude_cmd
952
957
 
953
958
  def _start_pty(self):
@@ -1193,21 +1198,27 @@ class ProxyServer:
1193
1198
 
1194
1199
 
1195
1200
  def run_server(session_name: str, claude_args: list = None,
1196
- claude_cmd: str = "claude",
1201
+ claude_cmd: str = "claude", codex_cmd: str = "codex",
1197
1202
  cli_type: str = "claude",
1198
1203
  debug_screen: bool = False, debug_verbose: bool = False):
1199
1204
  """运行服务器"""
1200
1205
  server = ProxyServer(session_name, claude_args, claude_cmd=claude_cmd,
1201
- cli_type=cli_type,
1206
+ codex_cmd=codex_cmd, cli_type=cli_type,
1202
1207
  debug_screen=debug_screen, debug_verbose=debug_verbose)
1203
1208
 
1209
+ # atexit 兜底:任何退出路径(包括被 SIGKILL 以外的信号强杀)都记录日志
1210
+ atexit.register(lambda: logger.warning("server 进程退出(atexit)[session=%s]", session_name))
1211
+
1204
1212
  # 信号处理
1205
1213
  def signal_handler(signum, frame):
1206
- print("\n[Server] 收到退出信号")
1214
+ sig_name = signal.Signals(signum).name if hasattr(signal, 'Signals') else str(signum)
1215
+ logger.warning("收到退出信号: %s (signum=%d)", sig_name, signum)
1216
+ print(f"\n[Server] 收到退出信号: {sig_name}")
1207
1217
  asyncio.create_task(server._shutdown())
1208
1218
 
1209
1219
  signal.signal(signal.SIGINT, signal_handler)
1210
1220
  signal.signal(signal.SIGTERM, signal_handler)
1221
+ signal.signal(signal.SIGHUP, signal_handler) # tmux 崩溃时也能优雅退出
1211
1222
 
1212
1223
  # 运行
1213
1224
  try:
@@ -1251,7 +1262,8 @@ if __name__ == "__main__":
1251
1262
  logging.getLogger().addHandler(startup_handler)
1252
1263
 
1253
1264
  claude_cmd = os.environ.get("CLAUDE_COMMAND", "claude")
1254
- logger.info(f"CLAUDE_COMMAND={claude_cmd!r}")
1265
+ codex_cmd = os.environ.get("CODEX_COMMAND", "codex")
1266
+ logger.info(f"CLAUDE_COMMAND={claude_cmd!r}, CODEX_COMMAND={codex_cmd!r}")
1255
1267
  run_server(args.session_name, args.claude_args, claude_cmd=claude_cmd,
1256
- cli_type=args.cli_type,
1268
+ codex_cmd=codex_cmd, cli_type=args.cli_type,
1257
1269
  debug_screen=args.debug_screen, debug_verbose=args.debug_verbose)
@@ -79,6 +79,7 @@ class OptionBlock:
79
79
  ansi_raw: str = "" # 整个选项区域的 ANSI 原始文本
80
80
  indicator: str = "" # 首列字符原文
81
81
  ansi_indicator: str = "" # 带 ANSI 颜色的首列字符
82
+ selected_value: str = "" # 当前 ❯ 光标所在选项的 value(数字字符串)
82
83
 
83
84
 
84
85
  # 向后兼容别名