remote-claude 0.2.9 → 0.2.11

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 CHANGED
@@ -22,3 +22,7 @@ ALLOWED_USERS=ou_xxxxx,ou_yyyyy
22
22
  # 单张卡片最多显示的 block 数量,超限自动冻结并创建新卡片(默认 50)
23
23
  # MAX_CARD_BLOCKS=50
24
24
 
25
+ # lark_client 日志级别(可选,默认 INFO)
26
+ # 支持: DEBUG / INFO / WARNING / ERROR
27
+ # LARK_LOG_LEVEL=INFO
28
+
package/README.md CHANGED
@@ -43,7 +43,7 @@ cd remote_claude
43
43
  ./init.sh
44
44
  ```
45
45
 
46
- `init.sh` 会自动安装 uv、tmux 等依赖,配置飞书环境(可选),并写入 `cla` / `cl` 快捷命令。执行完成后重启终端生效。
46
+ `init.sh` 会自动安装 uv、tmux 等依赖,配置飞书环境(可选),并写入 `cla` / `cl` / `cx` / `cdx` 快捷命令。执行完成后重启终端生效。
47
47
 
48
48
  ### 2. 启动
49
49
 
@@ -51,6 +51,8 @@ cd remote_claude
51
51
  |------|------|
52
52
  | `cla` | 启动 Claude (以当前目录路径为会话名) |
53
53
  | `cl` | 同 `cla`,但跳过权限确认 |
54
+ | `cx` | 启动 Codex (以当前目录路径为会话名,跳过权限确认) |
55
+ | `cdx` | 同 `cx`,但需要确认权限 |
54
56
  | `remote-claude` | 管理工具(一般不用)|
55
57
 
56
58
  ### 3. 从其他终端连接(比较少用)
@@ -210,6 +212,8 @@ remote-claude attach <会话名>
210
212
  |------|------|
211
213
  | `cla` | 启动飞书客户端 + 以当前目录路径为会话名启动 Claude |
212
214
  | `cl` | 同 `cla`,但跳过权限确认 |
215
+ | `cx` | 启动飞书客户端 + 以当前目录路径为会话名启动 Codex(跳过权限确认)|
216
+ | `cdx` | 同 `cx`,但需要确认权限 |
213
217
 
214
218
  ### 管理命令 (一般不需要)
215
219
 
@@ -260,7 +264,8 @@ CLAUDE_COMMAND=/usr/local/bin/claude
260
264
  ## 系统要求
261
265
 
262
266
  - **操作系统**: macOS 或 Linux
263
- - **依赖工具**: [uv](https://docs.astral.sh/uv/)、[tmux](https://github.com/tmux/tmux)、[Claude CLI](https://claude.ai/code)
267
+ - **依赖工具**: [uv](https://docs.astral.sh/uv/)、[tmux](https://github.com/tmux/tmux)
268
+ - **CLI 工具**: [Claude CLI](https://claude.ai/code) 或 [Codex CLI](https://github.com/openai/codex)
264
269
  - **可选**: 飞书企业自建应用
265
270
 
266
271
  ## 文档
package/bin/cdx ADDED
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+ # 解析符号链接,兼容 macOS(不支持 readlink -f)
3
+ SOURCE="$0"
4
+ while [ -L "$SOURCE" ]; do
5
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
6
+ SOURCE="$(readlink "$SOURCE")"
7
+ [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
8
+ done
9
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && cd .. && pwd)"
10
+
11
+ # uv 路径兜底
12
+ if ! command -v uv &>/dev/null; then
13
+ [ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
14
+ fi
15
+
16
+ # 检查飞书配置
17
+ source "$SCRIPT_DIR/scripts/check-env.sh" "$SCRIPT_DIR"
18
+
19
+ uv run --project "$SCRIPT_DIR" python3 "$SCRIPT_DIR/remote_claude.py" lark start
20
+ uv run --project "$SCRIPT_DIR" python3 "$SCRIPT_DIR/remote_claude.py" start "${PWD}_$(date +%m%d_%H%M%S)" --cli codex -- "$@"
package/bin/cx ADDED
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+ # 解析符号链接,兼容 macOS(不支持 readlink -f)
3
+ SOURCE="$0"
4
+ while [ -L "$SOURCE" ]; do
5
+ DIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
6
+ SOURCE="$(readlink "$SOURCE")"
7
+ [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
8
+ done
9
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE")" && cd .. && pwd)"
10
+
11
+ # uv 路径兜底
12
+ if ! command -v uv &>/dev/null; then
13
+ [ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
14
+ fi
15
+
16
+ # 检查飞书配置
17
+ source "$SCRIPT_DIR/scripts/check-env.sh" "$SCRIPT_DIR"
18
+
19
+ uv run --project "$SCRIPT_DIR" python3 "$SCRIPT_DIR/remote_claude.py" lark start
20
+ uv run --project "$SCRIPT_DIR" python3 "$SCRIPT_DIR/remote_claude.py" start "${PWD}_$(date +%m%d_%H%M%S)" --cli codex -- --dangerously-bypass-approvals-and-sandbox "$@"
package/bin/remote-claude CHANGED
@@ -21,8 +21,18 @@ if [ "$1" = "log" ]; then
21
21
  SESSION_SAFE=$(echo "$2" | tr '/.' '__')
22
22
  LOG_FILE="$LOG_DIR/${SESSION_SAFE}_messages.log"
23
23
  else
24
- # 找最后启动的 session(最新的 session .pid 文件,排除 lark.pid)
25
- LATEST_PID=$(ls -t "$LOG_DIR"/_*.pid 2>/dev/null | head -1)
24
+ # 找最后启动的 session(按创建时间排序,排除 lark.pid)
25
+ if [[ "$OSTYPE" == "darwin"* ]]; then
26
+ # macOS: stat -f "%B" 返回创建时间(秒)
27
+ LATEST_PID=$(ls "$LOG_DIR"/_*.pid 2>/dev/null | while read f; do
28
+ stat -f "%B:%N" "$f" 2>/dev/null
29
+ done | sort -rn | head -1 | cut -d: -f2)
30
+ else
31
+ # Linux: stat -c "%W" 返回创建时间(秒)
32
+ LATEST_PID=$(ls "$LOG_DIR"/_*.pid 2>/dev/null | while read f; do
33
+ stat -c "%W:%N" "$f" 2>/dev/null
34
+ done | sort -rn | head -1 | cut -d: -f2)
35
+ fi
26
36
  if [ -n "$LATEST_PID" ]; then
27
37
  SESSION_SAFE=$(basename "$LATEST_PID" .pid)
28
38
  LOG_FILE="$LOG_DIR/${SESSION_SAFE}_messages.log"
@@ -0,0 +1,3 @@
1
+ """
2
+ Remote Claude 终端客户端模块
3
+ """
package/init.sh CHANGED
@@ -350,6 +350,26 @@ check_claude() {
350
350
  fi
351
351
  }
352
352
 
353
+ # 检查 Codex CLI
354
+ check_codex() {
355
+ print_header "检查 Codex CLI"
356
+
357
+ if command -v codex &> /dev/null; then
358
+ print_success "Codex CLI 已安装"
359
+ return
360
+ fi
361
+
362
+ print_warning "未找到 Codex CLI"
363
+ print_info "请运行以下命令安装 Codex CLI:"
364
+ print_info " npm install -g @openai/codex"
365
+ print_info "或访问 https://github.com/openai/codex 了解更多"
366
+
367
+ if $NPM_MODE; then
368
+ print_info "(npm 模式:跳过交互,请安装后重新运行)"
369
+ return
370
+ fi
371
+ }
372
+
353
373
  # 安装 Python 依赖
354
374
  install_dependencies() {
355
375
  print_header "安装 Python 依赖"
@@ -449,7 +469,7 @@ configure_shell() {
449
469
  print_header "安装快捷命令"
450
470
 
451
471
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
452
- chmod +x "$SCRIPT_DIR/bin/cla" "$SCRIPT_DIR/bin/cl" "$SCRIPT_DIR/bin/remote-claude" 2>/dev/null || true
472
+ chmod +x "$SCRIPT_DIR/bin/cla" "$SCRIPT_DIR/bin/cl" "$SCRIPT_DIR/bin/cx" "$SCRIPT_DIR/bin/cdx" "$SCRIPT_DIR/bin/remote-claude" 2>/dev/null || true
453
473
 
454
474
  # 优先 /usr/local/bin,权限不够则选 ~/bin 或 ~/.local/bin 中已在 PATH 里的
455
475
  BIN_DIR="/usr/local/bin"
@@ -479,15 +499,21 @@ configure_shell() {
479
499
  mkdir -p "$BIN_DIR"
480
500
  ln -sf "$SCRIPT_DIR/bin/cla" "$BIN_DIR/cla" 2>/dev/null || true
481
501
  ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl" 2>/dev/null || true
502
+ ln -sf "$SCRIPT_DIR/bin/cx" "$BIN_DIR/cx" 2>/dev/null || true
503
+ ln -sf "$SCRIPT_DIR/bin/cdx" "$BIN_DIR/cdx" 2>/dev/null || true
482
504
  ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude" 2>/dev/null || true
483
505
  else
484
506
  ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl" 2>/dev/null || true
507
+ ln -sf "$SCRIPT_DIR/bin/cx" "$BIN_DIR/cx" 2>/dev/null || true
508
+ ln -sf "$SCRIPT_DIR/bin/cdx" "$BIN_DIR/cdx" 2>/dev/null || true
485
509
  ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude" 2>/dev/null || true
486
510
  fi
487
511
 
488
- print_success "已安装 cla、cl 和 remote-claude 到 $BIN_DIR"
512
+ print_success "已安装 cla、cl、cx、cdx 和 remote-claude 到 $BIN_DIR"
489
513
  print_info " cla - 启动飞书客户端 + 以当前目录路径+时间戳为会话名启动 Claude"
490
514
  print_info " cl - 同 cla,但跳过权限确认"
515
+ print_info " cx - 启动飞书客户端 + 以当前目录路径+时间戳为会话名启动 Codex(跳过权限)"
516
+ print_info " cdx - 同 cx,但需确认权限"
491
517
  print_info " remote-claude - Remote Claude 主命令(start/attach/list/kill/lark)"
492
518
 
493
519
  # 安装 shell 自动补全
@@ -536,6 +562,8 @@ ${YELLOW}快捷命令:${NC}
536
562
 
537
563
  ${GREEN}cla${NC} - 启动飞书客户端 + 以当前目录+时间戳为会话名启动 Claude
538
564
  ${GREEN}cl${NC} - 同 cla,但跳过权限确认
565
+ ${GREEN}cx${NC} - 启动飞书客户端 + 以当前目录+时间戳为会话名启动 Codex(跳过权限)
566
+ ${GREEN}cdx${NC} - 同 cx,但需确认权限
539
567
 
540
568
  详细使用说明请阅读 README.md
541
569
 
@@ -565,6 +593,7 @@ main() {
565
593
  check_uv
566
594
  check_tmux
567
595
  check_claude
596
+ check_codex
568
597
  install_dependencies
569
598
  if ! $NPM_MODE; then
570
599
  configure_lark
@@ -784,29 +784,36 @@ def _build_session_list_elements(sessions: List[Dict], current_session: Optional
784
784
  name = s["name"]
785
785
  cwd = s.get("cwd", "")
786
786
  start_time = s.get("start_time", "")
787
+ cli_type = s.get("cli_type", "claude")
787
788
  is_current = (name == current_session)
788
789
 
790
+ # CLI 类型颜色和标签:Claude=黄色,Codex=绿色
791
+ cli_color = "yellow" if cli_type == "claude" else "green"
792
+ cli_label = CLI_NAMES.get(cli_type, "Claude")
793
+
789
794
  status_icon = "🟢" if is_current else "⚪"
790
795
  current_label = "(当前)" if is_current else ""
791
796
  if cwd:
792
797
  short_name = cwd.rstrip("/").rsplit("/", 1)[-1] or name
793
798
  else:
794
799
  short_name = name
795
- meta_parts = []
800
+
801
+ # 构建4行内容:名字、cli类型、启动时间、目录
802
+ lines = [f"{status_icon} **{short_name}**{current_label}"]
803
+ lines.append(f"<font color=\"{cli_color}\">{cli_label}</font>")
804
+
796
805
  if start_time:
797
- meta_parts.append(f"启动:{start_time}")
806
+ lines.append(f"启动:{start_time}")
807
+
798
808
  if cwd:
799
809
  home = os.path.expanduser("~")
800
810
  display_cwd = cwd.replace(home, "~")
801
811
  if len(display_cwd) > 40:
802
812
  parts = display_cwd.rstrip("/").rsplit("/", 2)
803
813
  display_cwd = "…/" + "/".join(parts[-2:]) if len(parts) > 2 else display_cwd[-40:]
804
- meta_parts.append(f"`{display_cwd}`")
805
- meta_str = " ".join(meta_parts) if meta_parts else ""
814
+ lines.append(f"`{display_cwd}`")
806
815
 
807
- header_text = f"{status_icon} **{short_name}**{current_label}"
808
- if meta_str:
809
- header_text += f"\n{meta_str}"
816
+ header_text = "\n".join(lines)
810
817
 
811
818
  if is_current:
812
819
  btn_label = "断开连接"
@@ -38,3 +38,13 @@ GROUP_NAME_PREFIX = os.getenv("GROUP_NAME_PREFIX", "【Remote-Claude】")
38
38
 
39
39
  # 流式卡片配置
40
40
  MAX_CARD_BLOCKS = int(os.getenv("MAX_CARD_BLOCKS", "50"))
41
+
42
+ # lark_client 日志级别(可选,默认 INFO)
43
+ # 支持: DEBUG / INFO / WARNING / ERROR
44
+ _LARK_LOG_LEVEL = os.getenv("LARK_LOG_LEVEL", "INFO").upper()
45
+ LARK_LOG_LEVEL = {
46
+ "DEBUG": 10,
47
+ "INFO": 20,
48
+ "WARNING": 30,
49
+ "ERROR": 40,
50
+ }.get(_LARK_LOG_LEVEL, 20) # 默认 INFO
@@ -318,15 +318,20 @@ class LarkHandler:
318
318
  server_script = script_dir / "server" / "server.py"
319
319
  cmd = [sys.executable, str(server_script), session_name]
320
320
 
321
- logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {cmd}")
321
+ logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {' '.join(cmd)}")
322
322
  _track_stats('lark', 'cmd_start', session_name=session_name, chat_id=chat_id)
323
323
 
324
324
  try:
325
325
  import os as _os
326
+ from datetime import datetime as _datetime
326
327
  env = _os.environ.copy()
327
328
  env.pop("CLAUDECODE", None)
328
329
 
329
- subprocess.Popen(
330
+ from utils.session import USER_DATA_DIR
331
+ log_path = USER_DATA_DIR / "startup.log"
332
+ start_time = _datetime.now()
333
+
334
+ proc = subprocess.Popen(
330
335
  cmd,
331
336
  stdout=subprocess.DEVNULL,
332
337
  stderr=subprocess.DEVNULL,
@@ -335,13 +340,38 @@ class LarkHandler:
335
340
  env=env,
336
341
  )
337
342
 
343
+ def _read_log_since(since):
344
+ if not log_path.exists():
345
+ return ""
346
+ lines = []
347
+ for line in log_path.read_text(encoding="utf-8").splitlines():
348
+ try:
349
+ ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
350
+ if ts >= since:
351
+ lines.append(line)
352
+ except ValueError:
353
+ if lines:
354
+ lines.append(line)
355
+ return "\n".join(lines)
356
+
338
357
  socket_path = get_socket_path(session_name)
339
- for _ in range(120):
358
+ for i in range(120):
340
359
  await asyncio.sleep(0.1)
341
360
  if socket_path.exists():
342
361
  break
362
+ if (i + 1) % 10 == 0:
363
+ elapsed = (i + 1) // 10
364
+ rc = proc.poll()
365
+ if rc is not None:
366
+ log_content = _read_log_since(start_time)
367
+ logger.warning(f"会话启动失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
368
+ await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
369
+ return
370
+ logger.info(f"等待 server socket... ({elapsed}s)")
343
371
  else:
344
- await card_service.send_text(chat_id, "错误: 会话启动超时")
372
+ log_content = _read_log_since(start_time)
373
+ logger.error(f"会话启动超时 (12s), session={session_name}\n{log_content}")
374
+ await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
345
375
  return
346
376
 
347
377
  ok = await self._attach(chat_id, session_name)
@@ -12,16 +12,60 @@ import signal
12
12
  import sys
13
13
  from pathlib import Path
14
14
 
15
+
16
+ # 设置 sys.path 以导入 utils 模块
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+ from utils.session import USER_DATA_DIR
19
+
20
+
21
+ def _setup_logging():
22
+ """配置 lark_client 日志:INFO → lark_client.log, DEBUG → lark_client.debug.log"""
23
+ from .config import LARK_LOG_LEVEL
24
+
25
+ log_dir = USER_DATA_DIR
26
+ log_dir.mkdir(parents=True, exist_ok=True)
27
+
28
+ # 日志格式(含毫秒级时间戳)
29
+ log_format = "%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s"
30
+ date_format = "%Y-%m-%d %H:%M:%S"
31
+ formatter = logging.Formatter(log_format, datefmt=date_format)
32
+
33
+ # 根 logger 配置
34
+ root_logger = logging.getLogger()
35
+ root_logger.setLevel(LARK_LOG_LEVEL)
36
+
37
+ # 清除默认 handler
38
+ root_logger.handlers.clear()
39
+
40
+ # 正常日志文件(INFO 及以上)
41
+ info_handler = logging.FileHandler(log_dir / "lark_client.log", encoding="utf-8")
42
+ info_handler.setLevel(logging.INFO)
43
+ info_handler.setFormatter(formatter)
44
+ root_logger.addHandler(info_handler)
45
+
46
+ # 调试日志文件(DEBUG 及以上,仅当 LARK_LOG_LEVEL=DEBUG 时写入)
47
+ if LARK_LOG_LEVEL == logging.DEBUG:
48
+ debug_handler = logging.FileHandler(log_dir / "lark_client.debug.log", encoding="utf-8")
49
+ debug_handler.setLevel(logging.DEBUG)
50
+ debug_handler.setFormatter(formatter)
51
+ root_logger.addHandler(debug_handler)
52
+
53
+ # 第三方库保持 INFO 级别
54
+ for _noisy in ('urllib3', 'websockets', 'asyncio'):
55
+ logging.getLogger(_noisy).setLevel(logging.INFO)
56
+
57
+ # 控制台输出(仅重要消息,无调试信息)
58
+ console_handler = logging.StreamHandler()
59
+ console_handler.setLevel(logging.INFO)
60
+ console_handler.setFormatter(formatter)
61
+ root_logger.addHandler(console_handler)
62
+
63
+
64
+ # 在导入 lark SDK 之前配置日志
65
+ _setup_logging()
66
+
15
67
  import lark_oapi as lark
16
68
 
17
- # 在 SDK 配置 logging 之前,先设置根 logger 和我们自己模块的 DEBUG 级别
18
- logging.basicConfig(
19
- level=logging.DEBUG,
20
- format='[%(name)s] %(message)s',
21
- )
22
- # 将噪音较大的第三方库保持 INFO 级别
23
- for _noisy in ('urllib3', 'websockets', 'asyncio'):
24
- logging.getLogger(_noisy).setLevel(logging.INFO)
25
69
  from lark_oapi.api.im.v1 import P2ImMessageReceiveV1
26
70
  from lark_oapi.event.callback.model.p2_card_action_trigger import (
27
71
  P2CardActionTrigger, P2CardActionTriggerResponse, CallBackToast
@@ -11,7 +11,7 @@ import sys
11
11
  from pathlib import Path
12
12
  from typing import Optional, Callable, Dict
13
13
 
14
- logging.basicConfig(level=logging.DEBUG, format='[%(name)s] %(message)s')
14
+ logging.basicConfig(level=logging.INFO, format='[%(name)s] %(message)s')
15
15
  logger = logging.getLogger('SessionBridge')
16
16
 
17
17
  sys.path.insert(0, str(Path(__file__).parent.parent))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
@@ -33,7 +33,7 @@
33
33
  "license": "MIT",
34
34
  "repository": {
35
35
  "type": "git",
36
- "url": "git+https://github.com/yuyangzi/remote_claude.git"
36
+ "url": "git+https://github.com/yyzybb537/remote_claude.git"
37
37
  },
38
38
  "keywords": [
39
39
  "claude",
package/remote_claude.py CHANGED
@@ -3,19 +3,24 @@
3
3
  Remote Claude - 双端共享 Claude CLI 工具
4
4
 
5
5
  命令:
6
- start <name> 启动新会话(在 tmux 中)
7
- attach <name> 连接到已有会话
8
- list 列出所有会话
9
- kill <name> 终止会话
10
- lark 飞书客户端管理(start/stop/restart/status
6
+ start <name> 启动新会话(在 tmux 中)
7
+ attach <name> 连接到已有会话
8
+ list 列出所有会话
9
+ kill <name> 终止会话
10
+ status <name> 显示会话状态
11
+ lark 飞书客户端管理(start/stop/restart/status)
12
+ stats 查看使用统计
13
+ update 更新 remote-claude 到最新版本
11
14
  """
12
15
 
13
16
  import argparse
17
+ import logging
14
18
  import os
15
19
  import sys
16
20
  import subprocess
17
21
  import time
18
22
  import signal
23
+ from datetime import datetime
19
24
  from pathlib import Path
20
25
 
21
26
  # 确保项目根目录在 sys.path 中,以便 import client / server 子模块
@@ -31,7 +36,7 @@ from utils.session import (
31
36
  is_lark_running, get_lark_pid, get_lark_status, get_lark_pid_file,
32
37
  save_lark_status, cleanup_lark,
33
38
  USER_DATA_DIR, ensure_user_data_dir, get_lark_log_file,
34
- get_env_snapshot_path
39
+ get_env_snapshot_path,
35
40
  )
36
41
 
37
42
 
@@ -91,7 +96,22 @@ def cmd_start(args):
91
96
 
92
97
  server_cmd = f"{env_prefix}uv run --project '{SCRIPT_DIR}' python3 '{server_script}'{debug_flag}{debug_verbose_flag}{cli_type_flag} -- '{session_name}' {claude_args_str}"
93
98
 
94
- print(f"启动会话: {session_name}")
99
+ # 配置启动日志(写文件 + stdout)
100
+ _log_path = USER_DATA_DIR / "startup.log"
101
+ _start_logger = logging.getLogger('Start')
102
+ if not _start_logger.handlers:
103
+ _handler_file = logging.FileHandler(_log_path, encoding="utf-8")
104
+ _handler_file.setFormatter(logging.Formatter(
105
+ "%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
106
+ datefmt="%Y-%m-%d %H:%M:%S",
107
+ ))
108
+ _start_logger.addHandler(_handler_file)
109
+ _start_logger.setLevel(logging.INFO)
110
+ _start_logger.propagate = False
111
+
112
+ start_time = datetime.now()
113
+ _start_logger.info(f"启动会话: {session_name}")
114
+ _start_logger.info(f"server_cmd: {server_cmd}")
95
115
 
96
116
  # 创建 tmux 会话,运行 server(detached,仅后台)
97
117
  if not tmux_create_session(session_name, server_cmd, detached=True):
@@ -100,12 +120,30 @@ def cmd_start(args):
100
120
 
101
121
  # 等待 server 启动
102
122
  socket_path = get_socket_path(session_name)
103
- for _ in range(50): # 最多等待 5 秒
123
+ for i in range(50): # 最多等待 5 秒
104
124
  if socket_path.exists():
105
125
  break
106
126
  time.sleep(0.1)
127
+ if (i + 1) % 10 == 0:
128
+ elapsed = (i + 1) // 10
129
+ print(f"等待 Server 启动... ({elapsed}s)")
107
130
  else:
108
- print("错误: Server 启动超时")
131
+ print("错误: Server 启动超时 (5s)")
132
+ # 过滤出本次启动后的日志行
133
+ if _log_path.exists():
134
+ lines = []
135
+ for line in _log_path.read_text(encoding="utf-8").splitlines():
136
+ try:
137
+ ts = datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
138
+ if ts >= start_time:
139
+ lines.append(line)
140
+ except ValueError:
141
+ if lines: # 多行日志的续行,附到上一条
142
+ lines.append(line)
143
+ if lines:
144
+ print(f"--- Server 日志 ({_log_path}) ---")
145
+ print("\n".join(lines))
146
+ print("-------------------")
109
147
  tmux_kill_session(session_name)
110
148
  return 1
111
149
 
@@ -148,16 +186,29 @@ def cmd_list(args):
148
186
  print("没有活跃的会话")
149
187
  return 0
150
188
 
189
+ # ANSI 颜色码
190
+ YELLOW = "\033[33m"
191
+ GREEN = "\033[32m"
192
+ RESET = "\033[0m"
193
+
151
194
  print("活跃会话:")
152
- print("-" * 60)
153
- print(f"{'名称':<20} {'PID':<10} {'tmux':<10} {'Socket'}")
154
- print("-" * 60)
195
+ print("-" * 50)
196
+ print(f"{'类型':<8} {'PID':<10} {'tmux':<10} {'名称'}")
197
+ print("-" * 50)
155
198
 
156
199
  for s in sessions:
157
200
  tmux_status = "是" if s["tmux"] else "否"
158
- print(f"{s['name']:<20} {s['pid']:<10} {tmux_status:<10} {s['socket']}")
201
+ cli_type = s.get('cli_type', 'claude')
202
+ # 根据类型选择颜色
203
+ if cli_type == 'codex':
204
+ cli_colored = f"{GREEN}{cli_type}{RESET}"
205
+ else:
206
+ cli_colored = f"{YELLOW}{cli_type}{RESET}"
207
+ # 带颜色的字段需要单独计算宽度
208
+ padding = " " * (8 - len(cli_type))
209
+ print(f"{cli_colored}{padding} {s['pid']:<10} {tmux_status:<10} {s['name']}")
159
210
 
160
- print("-" * 60)
211
+ print("-" * 50)
161
212
  print(f"共 {len(sessions)} 个会话")
162
213
 
163
214
  return 0
@@ -448,9 +499,11 @@ def main():
448
499
  epilog="""
449
500
  示例:
450
501
  %(prog)s start mywork 启动名为 mywork 的会话
502
+ %(prog)s start mywork --cli codex 启动 codex 会话
451
503
  %(prog)s attach mywork 连接到 mywork 会话
452
504
  %(prog)s list 列出所有会话
453
505
  %(prog)s kill mywork 终止 mywork 会话
506
+ %(prog)s status mywork 显示 mywork 会话状态
454
507
 
455
508
  飞书客户端:
456
509
  %(prog)s lark start 启动飞书客户端
@@ -473,6 +526,9 @@ def main():
473
526
  %(prog)s stats --detail 详细分类
474
527
  %(prog)s stats --session mywork 按会话筛选
475
528
  %(prog)s stats --reset 清空数据
529
+
530
+ 更新:
531
+ %(prog)s update 更新到最新版本
476
532
  """
477
533
  )
478
534
 
@@ -580,12 +636,16 @@ def main():
580
636
  update_parser = subparsers.add_parser("update", help="更新 remote-claude 到最新版本")
581
637
  update_parser.set_defaults(func=cmd_update)
582
638
 
583
- args = parser.parse_args()
639
+ args, remaining = parser.parse_known_args()
584
640
 
585
641
  if args.command is None:
586
642
  parser.print_help()
587
643
  return 0
588
644
 
645
+ # 将剩余参数合并到 claude_args(支持 cx/cdx 脚本中使用 -- 分隔符)
646
+ if args.command == "start" and hasattr(args, 'claude_args'):
647
+ args.claude_args = args.claude_args + remaining
648
+
589
649
  return args.func(args)
590
650
 
591
651
 
@@ -0,0 +1 @@
1
+ # Remote Claude Server Package
package/server/server.py CHANGED
@@ -9,6 +9,7 @@ Proxy Server
9
9
  """
10
10
 
11
11
  import asyncio
12
+ import logging
12
13
  import os
13
14
  import pty
14
15
  import signal
@@ -35,9 +36,12 @@ from utils.protocol import (
35
36
  )
36
37
  from utils.session import (
37
38
  get_socket_path, get_pid_file, ensure_socket_dir,
38
- generate_client_id, cleanup_session, _safe_filename, get_env_file
39
+ generate_client_id, cleanup_session, _safe_filename, get_env_file,
40
+ SOCKET_DIR
39
41
  )
40
42
 
43
+ logger = logging.getLogger('Server')
44
+
41
45
  # 加载用户 .env 配置(支持 CLAUDE_COMMAND 等)
42
46
  try:
43
47
  from dotenv import load_dotenv
@@ -64,6 +68,11 @@ class _FrameObs:
64
68
  status_line: Optional[object] # 本帧的 StatusLine(None=无)
65
69
  block_blink: bool = False # 本帧最后一个 OutputBlock 是否 is_streaming=True
66
70
  has_background_agents: bool = False # 底部栏是否有后台 agent 信息
71
+ # 用于字符变化检测(增强闪烁判断)
72
+ last_ob_start_row: int = -1 # 最后 OutputBlock 的起始行号(跨帧识别同一 block)
73
+ last_ob_indicator_char: str = '' # 指示符字符值(pyte char.data)
74
+ last_ob_indicator_fg: str = '' # 指示符前景色(pyte char.fg)
75
+ last_ob_indicator_bold: bool = False # 指示符 bold 属性(影响显示亮度)
67
76
 
68
77
 
69
78
  @dataclass
@@ -154,6 +163,9 @@ class OutputWatcher:
154
163
 
155
164
  def feed(self, data: bytes):
156
165
  self._renderer.feed(data) # 直接喂持久化 screen,不再缓存原始字节
166
+ # 诊断日志:记录 PTY 数据到达
167
+ if data:
168
+ logger.debug(f"[diag-feed] len={len(data)} data={data[:50]!r}")
157
169
  try:
158
170
  loop = asyncio.get_running_loop()
159
171
  except RuntimeError:
@@ -185,6 +197,8 @@ class OutputWatcher:
185
197
 
186
198
  async def _flush(self):
187
199
  self._pending = False
200
+ # 诊断日志:记录 flush 触发时间和帧窗口大小
201
+ logger.debug(f"[diag-flush] ts={time.time():.6f} window_size={len(self._frame_window)}")
188
202
  try:
189
203
  from utils.components import StatusLine, BottomBar, Divider, OutputBlock, AgentPanelBlock, OptionBlock
190
204
 
@@ -224,10 +238,24 @@ class OutputWatcher:
224
238
  # 4a. 记录原始帧观测(必须用未平滑的原始值)
225
239
  last_ob_blink = False
226
240
  last_ob_content = ''
241
+ last_ob_start_row = -1
242
+ last_ob_indicator_char = ''
243
+ last_ob_indicator_fg = ''
244
+ last_ob_indicator_bold = False
227
245
  for b in reversed(visible_blocks):
228
246
  if isinstance(b, OutputBlock):
229
247
  last_ob_blink = b.is_streaming
230
248
  last_ob_content = b.content[:40]
249
+ last_ob_start_row = b.start_row
250
+ # 直接读 pyte screen buffer 获取原始字符属性(用于变化检测)
251
+ if b.start_row >= 0:
252
+ try:
253
+ char = self._renderer.screen.buffer[b.start_row][0]
254
+ last_ob_indicator_char = str(getattr(char, 'data', ''))
255
+ last_ob_indicator_fg = str(getattr(char, 'fg', ''))
256
+ last_ob_indicator_bold = bool(getattr(char, 'bold', False))
257
+ except (KeyError, IndexError):
258
+ pass
231
259
  break
232
260
  if last_ob_blink:
233
261
  _blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
@@ -241,6 +269,10 @@ class OutputWatcher:
241
269
  status_line=raw_status_line,
242
270
  block_blink=last_ob_blink,
243
271
  has_background_agents=getattr(raw_bottom_bar, 'has_background_agents', False) if raw_bottom_bar else False,
272
+ last_ob_start_row=last_ob_start_row,
273
+ last_ob_indicator_char=last_ob_indicator_char,
274
+ last_ob_indicator_fg=last_ob_indicator_fg,
275
+ last_ob_indicator_bold=last_ob_indicator_bold,
244
276
  ))
245
277
  cutoff = now - self.WINDOW_SECONDS
246
278
  while self._frame_window and self._frame_window[0].ts < cutoff:
@@ -262,9 +294,27 @@ class OutputWatcher:
262
294
  None
263
295
  )
264
296
 
265
- # 4c. block blink 平滑:窗口内任意帧有 blink → streaming
266
- # 修改 visible_blocks 中最后一个 OutputBlock(平滑后再合并)
297
+ # 4c. block blink 平滑:两种触发路径
298
+ # 路径1:窗口内任意帧有 pyte blink 属性
267
299
  window_block_active = any(o.block_blink for o in window_list)
300
+
301
+ # 路径2:窗口内同一 block 的指示符字符值/颜色/bold 有变化(增强闪烁判断)
302
+ if not window_block_active and last_ob_start_row >= 0:
303
+ same_block = [o for o in window_list if o.last_ob_start_row == last_ob_start_row]
304
+ if len(same_block) >= 2:
305
+ chars = {o.last_ob_indicator_char for o in same_block if o.last_ob_indicator_char}
306
+ fgs = {o.last_ob_indicator_fg for o in same_block if o.last_ob_indicator_fg}
307
+ bolds = {o.last_ob_indicator_bold for o in same_block}
308
+ if len(chars) > 1 or len(fgs) > 1 or len(bolds) > 1:
309
+ window_block_active = True
310
+ # 记录字符变化触发原因
311
+ _blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
312
+ _blink_log.write(
313
+ f"[{time.strftime('%H:%M:%S')}] char-change row={last_ob_start_row}"
314
+ f" chars={chars} fgs={fgs} bolds={bolds}\n"
315
+ )
316
+ _blink_log.close()
317
+
268
318
  if window_block_active:
269
319
  for b in reversed(visible_blocks):
270
320
  if isinstance(b, OutputBlock):
@@ -303,6 +353,16 @@ class OutputWatcher:
303
353
  layout_mode=self._parser.last_layout_mode,
304
354
  cli_type=self._cli_type,
305
355
  )
356
+ # 诊断日志:检测最终输出中是否有同时存在 status_line 和 SystemBlock 的情况
357
+ if display_status:
358
+ status_prefix = display_status.raw[:30] if hasattr(display_status, 'raw') else str(display_status)[:30]
359
+ has_systemblock_with_status = any(
360
+ b.__class__.__name__ == 'SystemBlock' and
361
+ hasattr(b, 'content') and status_prefix in b.content
362
+ for b in all_blocks
363
+ )
364
+ if has_systemblock_with_status:
365
+ logger.debug(f"[diag-output] BOTH status_line and SystemBlock present! status_line={status_prefix!r}")
306
366
  self.last_window = window
307
367
 
308
368
  # 7. 输出
@@ -342,7 +402,7 @@ class OutputWatcher:
342
402
  lines.append(f" ansi_raw={sl.ansi_raw[:120]!r}")
343
403
  lines.append(f" ansi_render: {sl.ansi_indicator} {sl.ansi_raw[:120]}\x1b[0m")
344
404
  else:
345
- lines.append(f"status_line: {sl.ansi_indicator} {sl.ansi_raw[:120]}\x1b[0m")
405
+ lines.append(f"status_line: {sl.ansi_raw[:120]}\x1b[0m")
346
406
  else:
347
407
  lines.append("status_line: None")
348
408
  # BottomBar
@@ -529,7 +589,7 @@ class ClientConnection:
529
589
  self.writer.write(data)
530
590
  await self.writer.drain()
531
591
  except Exception as e:
532
- print(f"[Server] 发送消息失败 ({self.client_id}): {e}")
592
+ logger.warning(f"发送消息失败 ({self.client_id}): {e}")
533
593
 
534
594
  async def read_message(self) -> Optional[Message]:
535
595
  """读取一条消息"""
@@ -540,7 +600,7 @@ class ClientConnection:
540
600
  try:
541
601
  return decode_message(line)
542
602
  except Exception as e:
543
- print(f"[Server] 解析消息失败: {e}")
603
+ logger.warning(f"解析消息失败: {e}")
544
604
  continue
545
605
 
546
606
  # 读取更多数据
@@ -608,6 +668,8 @@ class ProxyServer:
608
668
 
609
669
  async def start(self):
610
670
  """启动服务器"""
671
+ t0 = time.time()
672
+ logger.info(f"正在启动 (session={self.session_name})")
611
673
  ensure_socket_dir()
612
674
 
613
675
  # 清理旧的 socket 文件
@@ -615,12 +677,15 @@ class ProxyServer:
615
677
  self.socket_path.unlink()
616
678
 
617
679
  # 启动 PTY
680
+ t1 = time.time()
618
681
  self._start_pty()
682
+ logger.info(f"PTY 已启动 ({(time.time()-t1)*1000:.0f}ms)")
619
683
 
620
684
  # 写入 PID 文件
621
685
  self.pid_file.write_text(str(os.getpid()))
622
686
 
623
687
  # 启动 Unix Socket 服务器
688
+ t2 = time.time()
624
689
  self.server = await asyncio.start_unix_server(
625
690
  self._handle_client,
626
691
  path=str(self.socket_path)
@@ -628,11 +693,14 @@ class ProxyServer:
628
693
 
629
694
  self.running = True
630
695
  _track_stats('session', 'start', session_name=self.session_name)
631
- print(f"[Server] 已启动: {self.socket_path}")
696
+ logger.info(f"已启动: {self.socket_path} (Socket {(time.time()-t2)*1000:.0f}ms, 总计 {(time.time()-t0)*1000:.0f}ms)")
632
697
 
633
698
  # 启动 PTY 读取任务
634
699
  asyncio.create_task(self._read_pty())
635
700
 
701
+ # 切换到运行阶段日志
702
+ self._switch_to_runtime_logging()
703
+
636
704
  # 等待服务器关闭
637
705
  async with self.server:
638
706
  await self.server.serve_forever()
@@ -648,6 +716,32 @@ class ProxyServer:
648
716
  return CodexParser()
649
717
  return ClaudeParser()
650
718
 
719
+ def _switch_to_runtime_logging(self):
720
+ """从启动日志切换到运行阶段日志"""
721
+ root_logger = logging.getLogger()
722
+
723
+ # 移除启动日志 handler(保留 stdout handler)
724
+ for handler in root_logger.handlers[:]:
725
+ if isinstance(handler, logging.FileHandler) and \
726
+ not hasattr(handler, '_runtime_handler') and \
727
+ not hasattr(handler, '_debug_handler'):
728
+ root_logger.removeHandler(handler)
729
+
730
+ # 添加运行阶段日志文件
731
+ safe_name = _safe_filename(self.session_name)
732
+ runtime_handler = logging.FileHandler(
733
+ f"{SOCKET_DIR}/{safe_name}_server.log",
734
+ encoding="utf-8"
735
+ )
736
+ runtime_handler.setFormatter(logging.Formatter(
737
+ "%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
738
+ datefmt="%Y-%m-%d %H:%M:%S"
739
+ ))
740
+ runtime_handler._runtime_handler = True # 标记,方便后续清理
741
+ root_logger.addHandler(runtime_handler)
742
+
743
+ logger.info(f"日志已切换到运行阶段: {safe_name}_server.log")
744
+
651
745
  def _get_effective_cmd(self) -> str:
652
746
  """根据 cli_type 返回实际执行的命令(codex 时使用 'codex',否则用 claude_cmd)"""
653
747
  if self.cli_type == "codex":
@@ -664,8 +758,14 @@ class ProxyServer:
664
758
  try:
665
759
  with open(env_snapshot_path) as _f:
666
760
  _extra_env = _json.load(_f)
761
+ logger.info(f"环境快照已加载 ({len(_extra_env)} 个变量)")
667
762
  except Exception:
668
- pass
763
+ logger.warning("环境快照加载失败,使用当前进程环境")
764
+
765
+ # 提前计算命令(fork 后父子进程共享,方便父进程打印和子进程执行)
766
+ import shlex as _shlex
767
+ _cmd_parts = _shlex.split(self._get_effective_cmd())
768
+ _full_cmd = ' '.join(_cmd_parts + self.claude_args)
669
769
 
670
770
  try:
671
771
  pid, fd = pty.fork()
@@ -688,10 +788,25 @@ class ProxyServer:
688
788
  # 清除 tmux 标识变量(PTY 数据不经过 tmux,不应让 Claude CLI 误判终端环境)
689
789
  child_env.pop('TMUX', None)
690
790
  child_env.pop('TMUX_PANE', None)
691
- import shlex as _shlex
692
- _cmd_parts = _shlex.split(self._get_effective_cmd())
693
- os.execvpe(_cmd_parts[0], _cmd_parts + self.claude_args, child_env)
694
- os._exit(1) # execvpe 失败时兜底退出
791
+ try:
792
+ os.execvpe(_cmd_parts[0], _cmd_parts + self.claude_args, child_env)
793
+ except Exception as _e:
794
+ msg = f"启动失败: 命令 '{_cmd_parts[0]}' 无法执行: {_e}"
795
+ os.write(1, (msg + "\n").encode()) # 写到 PTY
796
+ # fork 后不能安全使用 logging,直接追加写日志文件
797
+ try:
798
+ import time as _t
799
+ _ts = _t.strftime("%Y-%m-%d %H:%M:%S")
800
+ _ms = int((_t.time() % 1) * 1000)
801
+ _log_line = f"{_ts}.{_ms:03d} [Server] ERROR {msg}\n"
802
+ _home = os.path.expanduser("~")
803
+ _log_file = os.path.join(_home, ".remote-claude", "startup.log")
804
+ with open(_log_file, "a", encoding="utf-8") as _f:
805
+ _f.write(_log_line)
806
+ except Exception:
807
+ pass
808
+ os._exit(127) # 127 = command not found (shell convention)
809
+ os._exit(1) # 理论上不可达
695
810
  else:
696
811
  # 父进程
697
812
  self.master_fd = fd
@@ -706,7 +821,8 @@ class ProxyServer:
706
821
  fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
707
822
 
708
823
  cli_label = self.cli_type.capitalize()
709
- print(f"[Server] {cli_label} 已启动 (PID: {pid}, PTY: {self.PTY_COLS}×{self.PTY_ROWS})")
824
+ logger.info(f"启动命令: {_full_cmd}")
825
+ logger.info(f"{cli_label} 已启动 (PID: {pid}, PTY: {self.PTY_COLS}×{self.PTY_ROWS})")
710
826
 
711
827
  _COALESCE_MAX = 64 * 1024 # 64KB,防止单次广播过大
712
828
 
@@ -744,11 +860,19 @@ class ProxyServer:
744
860
  break
745
861
  except Exception as e:
746
862
  if self.running:
747
- print(f"[Server] 读取 PTY 错误: {e}")
863
+ logger.error(f"读取 PTY 错误: {e}")
748
864
  break
749
865
 
750
- # Claude 退出
751
- print("[Server] Claude 已退出")
866
+ # Claude 退出,获取 exit code 以便诊断
867
+ try:
868
+ _, status = os.waitpid(self.child_pid, os.WNOHANG)
869
+ if status != 0:
870
+ exit_code = os.waitstatus_to_exitcode(status)
871
+ logger.error(f"CLI 进程异常退出 (exit_code={exit_code})")
872
+ else:
873
+ logger.info("Claude 已退出")
874
+ except Exception:
875
+ logger.info("Claude 已退出")
752
876
  await self._shutdown()
753
877
 
754
878
  def _read_pty_sync(self) -> Optional[bytes]:
@@ -766,7 +890,7 @@ class ProxyServer:
766
890
  client = ClientConnection(client_id, reader, writer)
767
891
  self.clients[client_id] = client
768
892
 
769
- print(f"[Server] 客户端连接: {client_id}")
893
+ logger.info(f"客户端连接: {client_id}")
770
894
  _track_stats('session', 'attach', session_name=self.session_name)
771
895
 
772
896
  # 发送历史输出
@@ -782,12 +906,12 @@ class ProxyServer:
782
906
  break
783
907
  await self._handle_message(client_id, msg)
784
908
  except Exception as e:
785
- print(f"[Server] 客户端处理错误 ({client_id}): {e}")
909
+ logger.error(f"客户端处理错误 ({client_id}): {e}")
786
910
  finally:
787
911
  # 清理
788
912
  del self.clients[client_id]
789
913
  client.close()
790
- print(f"[Server] 客户端断开: {client_id}")
914
+ logger.info(f"客户端断开: {client_id}")
791
915
 
792
916
  async def _handle_message(self, client_id: str, msg: Message):
793
917
  """处理客户端消息"""
@@ -804,7 +928,7 @@ class ProxyServer:
804
928
  _track_stats('terminal', 'input', session_name=self.session_name,
805
929
  value=len(data))
806
930
  except Exception as e:
807
- print(f"[Server] 写入 PTY 错误: {e}")
931
+ logger.error(f"写入 PTY 错误: {e}")
808
932
 
809
933
  # 广播输入给其他客户端(飞书侧可以感知终端用户的输入内容)
810
934
  for cid, client in list(self.clients.items()):
@@ -824,7 +948,7 @@ class ProxyServer:
824
948
  winsize = struct.pack('HHHH', msg.rows, msg.cols, 0, 0)
825
949
  fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
826
950
  except Exception as e:
827
- print(f"[Server] 调整终端大小错误: {e}")
951
+ logger.error(f"调整终端大小错误: {e}")
828
952
 
829
953
  async def _broadcast_output(self, data: bytes):
830
954
  """广播输出给所有客户端,同时喂给 OutputWatcher 生成快照"""
@@ -863,7 +987,7 @@ class ProxyServer:
863
987
  # 清理文件
864
988
  cleanup_session(self.session_name)
865
989
 
866
- print("[Server] 已关闭")
990
+ logger.info("已关闭")
867
991
 
868
992
 
869
993
  def run_server(session_name: str, claude_args: list = None,
@@ -903,7 +1027,29 @@ if __name__ == "__main__":
903
1027
  help="debug 日志输出完整诊断信息(indicator、repr 等)")
904
1028
  args = parser.parse_args()
905
1029
 
1030
+ # 配置日志:启动阶段输出到 stdout + startup.log
1031
+ from utils.session import USER_DATA_DIR, _safe_filename
1032
+ USER_DATA_DIR.mkdir(parents=True, exist_ok=True)
1033
+
1034
+ # 先配置基本输出(stdout)
1035
+ logging.basicConfig(
1036
+ level=logging.INFO,
1037
+ format="%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
1038
+ datefmt="%Y-%m-%d %H:%M:%S",
1039
+ handlers=[logging.StreamHandler(sys.stdout)],
1040
+ )
1041
+
1042
+ # 添加启动日志 handler
1043
+ startup_handler = logging.FileHandler(USER_DATA_DIR / "startup.log", encoding="utf-8")
1044
+ startup_handler.setFormatter(logging.Formatter(
1045
+ "%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
1046
+ datefmt="%Y-%m-%d %H:%M:%S"
1047
+ ))
1048
+ startup_handler._startup_handler = True # 标记为启动日志 handler
1049
+ logging.getLogger().addHandler(startup_handler)
1050
+
906
1051
  claude_cmd = os.environ.get("CLAUDE_COMMAND", "claude")
1052
+ logger.info(f"CLAUDE_COMMAND={claude_cmd!r}")
907
1053
  run_server(args.session_name, args.claude_args, claude_cmd=claude_cmd,
908
1054
  cli_type=args.cli_type,
909
1055
  debug_screen=args.debug_screen, debug_verbose=args.debug_verbose)
@@ -93,6 +93,7 @@ class SharedStateWriter:
93
93
  """写端:Server 进程持有,生命周期与 ProxyServer 相同"""
94
94
 
95
95
  def __init__(self, session_name: str):
96
+ self._session_name = session_name
96
97
  self._path = get_mq_path(session_name)
97
98
  self._path.parent.mkdir(parents=True, exist_ok=True)
98
99
 
@@ -139,7 +140,7 @@ class SharedStateWriter:
139
140
  "input_area_text": window.input_area_text,
140
141
  "timestamp": window.timestamp,
141
142
  "layout_mode": window.layout_mode,
142
- "cli_type": getattr(window, "cli_type", "claude"),
143
+ "cli_type": getattr(window, "cli_type", "unknown"),
143
144
  }
144
145
  data = json.dumps(snapshot, ensure_ascii=False).encode('utf-8')
145
146
 
@@ -177,7 +178,7 @@ class SharedStateReader:
177
178
  反映跨进程写入更新的问题。每次 read() 打开文件读取后关闭,保证读到最新数据。
178
179
  """
179
180
 
180
- _EMPTY = {"blocks": [], "status_line": None, "bottom_bar": None, "option_block": None}
181
+ _EMPTY = {"blocks": [], "status_line": None, "bottom_bar": None, "option_block": None, "cli_type": "claude"}
181
182
 
182
183
  def __init__(self, session_name: str):
183
184
  self._path = get_mq_path(session_name)
@@ -0,0 +1,3 @@
1
+ """
2
+ Remote Claude 工具模块
3
+ """
package/utils/session.py CHANGED
@@ -101,6 +101,8 @@ def tmux_create_session(session_name: str, command: str, detached: bool = True)
101
101
  args.extend(["-x", "200", "-y", "50"]) # 默认大小
102
102
  args.append(command)
103
103
 
104
+ import logging as _logging
105
+ _logging.getLogger('Start').info(f"tmux_cmd: {' '.join(args)}")
104
106
  result = subprocess.run(args, capture_output=True)
105
107
  if result.returncode == 0:
106
108
  # 启用鼠标支持,允许在 tmux 窗口内用鼠标滚轮查看历史输出
@@ -221,10 +223,30 @@ def list_active_sessions() -> List[dict]:
221
223
  import datetime
222
224
  try:
223
225
  mtime = pid_file.stat().st_mtime
224
- start_time = datetime.datetime.fromtimestamp(mtime).strftime("%H:%M")
226
+ start_time = datetime.datetime.fromtimestamp(mtime).strftime("%m-%d %H:%M")
225
227
  except OSError:
226
228
  mtime = 0
227
229
  start_time = "?"
230
+
231
+ # 读取 .mq 文件获取 cli_type(避免循环导入,在函数内导入)
232
+ try:
233
+ import sys
234
+ from pathlib import Path
235
+ import logging
236
+ project_root = str(Path(__file__).parent.parent)
237
+ if project_root not in sys.path:
238
+ sys.path.insert(0, project_root)
239
+ from server.shared_state import SharedStateReader
240
+ reader = SharedStateReader(session_name)
241
+ snapshot = reader.read()
242
+ cli_type = snapshot.get("cli_type", "claude")
243
+ except Exception as e:
244
+ # 添加详细日志记录,便于诊断问题
245
+ import logging
246
+ logger = logging.getLogger('Session')
247
+ logger.warning(f"读取共享内存 cli_type 失败: session={session_name}, error={e}")
248
+ cli_type = "claude" # 读取失败时使用默认值
249
+
228
250
  sessions.append({
229
251
  "name": session_name,
230
252
  "socket": str(sock_file),
@@ -232,7 +254,8 @@ def list_active_sessions() -> List[dict]:
232
254
  "cwd": cwd or "",
233
255
  "start_time": start_time,
234
256
  "mtime": mtime,
235
- "tmux": tmux_session_exists(session_name)
257
+ "tmux": tmux_session_exists(session_name),
258
+ "cli_type": cli_type
236
259
  })
237
260
  except (ProcessLookupError, ValueError, OSError):
238
261
  # 进程不存在或文件被并发清理,清理残留文件