remote-claude 1.0.3 → 1.0.5

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/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)
package/utils/session.py CHANGED
@@ -20,11 +20,6 @@ SOCKET_DIR = Path("/tmp/remote-claude")
20
20
  USER_DATA_DIR = Path.home() / ".remote-claude"
21
21
  TMUX_SESSION_PREFIX = "rc-"
22
22
 
23
- # macOS AF_UNIX sun_path 限制 104 字节
24
- # /tmp/remote-claude/ = 19 字节, .sock = 5 字节
25
- _MAX_SOCKET_PATH = 104
26
- _MAX_FILENAME = _MAX_SOCKET_PATH - len(str(SOCKET_DIR)) - 1 - len(".sock")
27
-
28
23
 
29
24
  def get_env_file() -> Path:
30
25
  """获取 .env 配置文件路径"""
@@ -47,14 +42,15 @@ def ensure_user_data_dir():
47
42
 
48
43
 
49
44
  def _safe_filename(session_name: str) -> str:
50
- """将会话名转为安全文件名(/ 和 . 替换为 _),超长时用完整 MD5"""
51
- name = session_name.replace('/', '_').replace('.', '_')
52
- if len(name) <= _MAX_FILENAME:
53
- return name
54
- # 超长:直接用完整 32 字符 MD5,彻底避免路径超限
45
+ """将会话名转为安全文件名(MD5 hash)"""
55
46
  return hashlib.md5(session_name.encode()).hexdigest()
56
47
 
57
48
 
49
+ def _log_filename(session_name: str) -> str:
50
+ """将会话名转为可读的安全文件名(用于日志文件,不受路径长度限制)"""
51
+ return session_name.replace("/", "_").replace(".", "_").replace(" ", "_")
52
+
53
+
58
54
  def get_socket_path(session_name: str) -> Path:
59
55
  """获取会话的 socket 路径"""
60
56
  return SOCKET_DIR / f"{_safe_filename(session_name)}.sock"
@@ -75,6 +71,11 @@ def get_env_snapshot_path(session_name: str) -> Path:
75
71
  return SOCKET_DIR / f"{_safe_filename(session_name)}_env.json"
76
72
 
77
73
 
74
+ def get_name_file(session_name: str) -> Path:
75
+ """获取会话名映射文件路径(存储原始会话名,供 list 恢复显示名)"""
76
+ return SOCKET_DIR / f"{_safe_filename(session_name)}.name"
77
+
78
+
78
79
  def ensure_socket_dir():
79
80
  """确保 socket 目录存在"""
80
81
  SOCKET_DIR.mkdir(parents=True, exist_ok=True)
@@ -221,13 +222,19 @@ def get_process_cwd(pid: int) -> Optional[str]:
221
222
 
222
223
 
223
224
  def list_active_sessions() -> List[dict]:
224
- """列出所有活跃会话"""
225
+ """列出所有活跃会话
226
+
227
+ 注意:sock 文件的 stem 已经是 MD5 哈希(_safe_filename 的输出),
228
+ 因此直接用 stem 构造 PID/MQ 文件路径,不再经过 get_pid_file 等函数
229
+ (否则会被 _safe_filename 二次哈希)。
230
+ """
225
231
  ensure_socket_dir()
226
232
  sessions = []
227
233
 
228
234
  for sock_file in SOCKET_DIR.glob("*.sock"):
229
- session_name = sock_file.stem
230
- pid_file = get_pid_file(session_name)
235
+ # stem 已经是 _safe_filename() 的输出(MD5 哈希)
236
+ safe_name = sock_file.stem
237
+ pid_file = SOCKET_DIR / f"{safe_name}.pid"
231
238
 
232
239
  # 检查 socket 文件是否有效(进程是否存在)
233
240
  if pid_file.exists():
@@ -237,6 +244,17 @@ def list_active_sessions() -> List[dict]:
237
244
  os.kill(pid, 0)
238
245
  # 获取进程 CWD
239
246
  cwd = get_process_cwd(pid)
247
+
248
+ # 恢复原始会话名(从 .name 文件读取,不存在则回退到哈希值)
249
+ name_file = SOCKET_DIR / f"{safe_name}.name"
250
+ if name_file.exists():
251
+ try:
252
+ display_name = name_file.read_text().strip()
253
+ except OSError:
254
+ display_name = safe_name
255
+ else:
256
+ display_name = safe_name
257
+
240
258
  # 获取启动时间(PID 文件的修改时间,文件可能已被并发清理)
241
259
  import datetime
242
260
  try:
@@ -246,7 +264,7 @@ def list_active_sessions() -> List[dict]:
246
264
  mtime = 0
247
265
  start_time = "?"
248
266
 
249
- # 读取 .mq 文件获取 cli_type(避免循环导入,在函数内导入)
267
+ # 读取 .mq 文件获取 cli_type
250
268
  try:
251
269
  import sys
252
270
  from pathlib import Path
@@ -255,29 +273,38 @@ def list_active_sessions() -> List[dict]:
255
273
  if project_root not in sys.path:
256
274
  sys.path.insert(0, project_root)
257
275
  from server.shared_state import SharedStateReader
258
- reader = SharedStateReader(session_name)
276
+ # _BypassHashReader 直接传入 safe_name 避免二次哈希
277
+ mq_path = SOCKET_DIR / f"{safe_name}.mq"
278
+ reader = SharedStateReader.__new__(SharedStateReader)
279
+ reader._path = mq_path
259
280
  snapshot = reader.read()
260
281
  cli_type = snapshot.get("cli_type", "claude")
261
282
  except Exception as e:
262
- # 添加详细日志记录,便于诊断问题
263
283
  import logging
264
284
  logger = logging.getLogger('Session')
265
- logger.warning(f"读取共享内存 cli_type 失败: session={session_name}, error={e}")
266
- cli_type = "claude" # 读取失败时使用默认值
285
+ logger.warning(f"读取共享内存 cli_type 失败: session={display_name}, error={e}")
286
+ cli_type = "claude"
287
+
288
+ # tmux 会话名也用 safe_name 直接构造
289
+ tmux_name = f"{TMUX_SESSION_PREFIX}{safe_name}"
290
+ tmux_exists = subprocess.run(
291
+ ["tmux", "has-session", "-t", tmux_name],
292
+ capture_output=True
293
+ ).returncode == 0
267
294
 
268
295
  sessions.append({
269
- "name": session_name,
296
+ "name": display_name,
270
297
  "socket": str(sock_file),
271
298
  "pid": pid,
272
299
  "cwd": cwd or "",
273
300
  "start_time": start_time,
274
301
  "mtime": mtime,
275
- "tmux": tmux_session_exists(session_name),
302
+ "tmux": tmux_exists,
276
303
  "cli_type": cli_type
277
304
  })
278
305
  except (ProcessLookupError, ValueError, OSError):
279
306
  # 进程不存在或文件被并发清理,清理残留文件
280
- cleanup_session(session_name)
307
+ _cleanup_by_safe_name(safe_name)
281
308
  else:
282
309
  # 没有 PID 文件,清理 socket
283
310
  sock_file.unlink(missing_ok=True)
@@ -287,14 +314,32 @@ def list_active_sessions() -> List[dict]:
287
314
 
288
315
 
289
316
  def cleanup_session(session_name: str):
290
- """清理会话残留文件"""
291
- sock_path = get_socket_path(session_name)
292
- pid_file = get_pid_file(session_name)
317
+ """清理会话残留文件(传入原始会话名,经过 _safe_filename 转换)"""
318
+ safe = _safe_filename(session_name)
319
+ _cleanup_by_safe_name(safe, session_name)
293
320
 
294
- sock_path.unlink(missing_ok=True)
295
- pid_file.unlink(missing_ok=True)
296
- get_mq_path(session_name).unlink(missing_ok=True)
297
- get_env_snapshot_path(session_name).unlink(missing_ok=True)
321
+
322
+ def _cleanup_by_safe_name(safe_name: str, session_name: Optional[str] = None):
323
+ """清理会话残留文件(传入已经是 MD5 哈希的 safe_name,不再二次哈希)"""
324
+ # 尝试从 .name 文件读取原始会话名(用于清理可读日志文件)
325
+ if session_name is None:
326
+ name_file = SOCKET_DIR / f"{safe_name}.name"
327
+ if name_file.exists():
328
+ try:
329
+ session_name = name_file.read_text().strip()
330
+ except OSError:
331
+ pass
332
+ for suffix in [".sock", ".pid", ".mq", ".name", "_env.json"]:
333
+ (SOCKET_DIR / f"{safe_name}{suffix}").unlink(missing_ok=True)
334
+ # 清理带后缀的日志文件(使用可读文件名)
335
+ log_suffixes = ["_messages.log", "_screen.log", "_server.log", "_debug.log", "_pty_raw.log"]
336
+ if session_name:
337
+ log_name = _log_filename(session_name)
338
+ for suffix in log_suffixes:
339
+ (SOCKET_DIR / f"{log_name}{suffix}").unlink(missing_ok=True)
340
+ # 兼容旧的 MD5 日志文件
341
+ for suffix in log_suffixes:
342
+ (SOCKET_DIR / f"{safe_name}{suffix}").unlink(missing_ok=True)
298
343
 
299
344
 
300
345
  def is_session_active(session_name: str) -> bool:
@@ -346,8 +391,14 @@ def is_lark_running() -> bool:
346
391
  try:
347
392
  pid = int(pid_file.read_text().strip())
348
393
  os.kill(pid, 0)
349
- return True
350
- except (ProcessLookupError, ValueError, OSError):
394
+ # 额外验证:确认进程确实是我们的 lark_client(防止 PID 复用误判)
395
+ import subprocess
396
+ result = subprocess.run(
397
+ ["ps", "-p", str(pid), "-o", "command="],
398
+ capture_output=True, text=True, timeout=2
399
+ )
400
+ return "lark_client" in result.stdout
401
+ except (ProcessLookupError, ValueError, OSError, Exception):
351
402
  return False
352
403
 
353
404
 
@@ -1,43 +0,0 @@
1
- #!/bin/bash
2
- # 检查 .env 中 FEISHU_APP_ID/APP_SECRET 是否已配置,未配置则交互引导
3
- # 用法: source scripts/check-env.sh "$INSTALL_DIR"
4
-
5
- INSTALL_DIR="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
6
- ENV_FILE="$HOME/.remote-claude/.env"
7
- mkdir -p "$HOME/.remote-claude"
8
- ENV_OK=false
9
-
10
- if [ -f "$ENV_FILE" ]; then
11
- APP_ID=$(grep -E '^FEISHU_APP_ID=' "$ENV_FILE" | cut -d= -f2)
12
- APP_SECRET=$(grep -E '^FEISHU_APP_SECRET=' "$ENV_FILE" | cut -d= -f2)
13
- if [ -n "$APP_ID" ] && [ "$APP_ID" != "cli_xxxxx" ] && \
14
- [ -n "$APP_SECRET" ] && [ "$APP_SECRET" != "xxxxx" ]; then
15
- ENV_OK=true
16
- fi
17
- fi
18
-
19
- if [ "$ENV_OK" = false ]; then
20
- echo ""
21
- echo "飞书客户端尚未配置,需要填写应用凭证。"
22
- echo "(在飞书开发者后台创建应用获取: https://open.feishu.cn/app)"
23
- echo ""
24
- echo -e "\033[33m飞书机器人配置文档参考:https://github.com/yyzybb537/remote_claude\033[0m"
25
- echo ""
26
- read -p "FEISHU_APP_ID: " INPUT_APP_ID
27
- read -p "FEISHU_APP_SECRET: " INPUT_APP_SECRET
28
-
29
- if [ -z "$INPUT_APP_ID" ] || [ -z "$INPUT_APP_SECRET" ]; then
30
- echo "错误: APP_ID 和 APP_SECRET 不能为空"
31
- exit 1
32
- fi
33
-
34
- cp "$INSTALL_DIR/.env.example" "$ENV_FILE"
35
- sed -i.bak "s/^FEISHU_APP_ID=.*/FEISHU_APP_ID=$INPUT_APP_ID/" "$ENV_FILE"
36
- sed -i.bak "s/^FEISHU_APP_SECRET=.*/FEISHU_APP_SECRET=$INPUT_APP_SECRET/" "$ENV_FILE"
37
- rm -f "$ENV_FILE.bak"
38
-
39
- echo ""
40
- echo "配置已保存到 $ENV_FILE"
41
- echo "(可选配置, 如"白名单"等可稍后编辑该文件)"
42
- echo ""
43
- fi