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/.env.example +8 -0
- package/README.md +0 -2
- package/bin/cdx +9 -1
- package/bin/cl +9 -1
- package/bin/cla +9 -1
- package/bin/cx +9 -1
- package/bin/remote-claude +9 -18
- package/client/client.py +1 -1
- package/init.sh +30 -97
- package/lark_client/card_builder.py +60 -33
- package/lark_client/config.py +4 -0
- package/lark_client/lark_handler.py +316 -83
- package/lark_client/main.py +26 -6
- package/lark_client/session_bridge.py +4 -10
- package/lark_client/shared_memory_poller.py +193 -107
- package/package.json +1 -1
- package/pyproject.toml +1 -0
- package/remote_claude.py +226 -0
- package/server/parsers/claude_parser.py +43 -0
- package/server/parsers/codex_parser.py +8 -0
- package/server/server.py +31 -19
- package/utils/components.py +1 -0
- package/utils/session.py +81 -30
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
|
-
|
|
188
|
-
self._debug_file = f"/tmp/remote-claude/{
|
|
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/{
|
|
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/{
|
|
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
|
-
|
|
923
|
+
log_name = _log_filename(self.session_name)
|
|
919
924
|
runtime_handler = logging.FileHandler(
|
|
920
|
-
f"{SOCKET_DIR}/{
|
|
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}/{
|
|
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 日志: {
|
|
948
|
+
logger.info(f"已启用 DEBUG 日志: {log_name}_debug.log")
|
|
944
949
|
|
|
945
|
-
logger.info(f"日志已切换到运行阶段: {
|
|
950
|
+
logger.info(f"日志已切换到运行阶段: {log_name}_server.log")
|
|
946
951
|
|
|
947
952
|
def _get_effective_cmd(self) -> str:
|
|
948
|
-
"""根据 cli_type
|
|
953
|
+
"""根据 cli_type 返回实际执行的命令"""
|
|
949
954
|
if self.cli_type == "codex":
|
|
950
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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)
|