remote-claude 0.2.12 → 0.2.14

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
@@ -26,3 +26,7 @@ ALLOWED_USERS=ou_xxxxx,ou_yyyyy
26
26
  # 支持: DEBUG / INFO / WARNING / ERROR
27
27
  # LARK_LOG_LEVEL=INFO
28
28
 
29
+ # server 日志级别(可选,默认 INFO)
30
+ # 支持: DEBUG / INFO / WARNING / ERROR
31
+ # SERVER_LOG_LEVEL=INFO
32
+
package/README.md CHANGED
@@ -11,7 +11,7 @@ Claude Code 只能在启动它的那个终端窗口里操作。一旦离开电
11
11
  - **飞书里直接操作** — 手机/平板打开飞书,就能看到 Claude 的实时输出,发消息、选选项、批准权限,和终端里一模一样。
12
12
  - **用手机无缝延续电脑上做的工作** — 电脑上打开的Claude进程,也可以用飞书共享操作,开会、午休、通勤、上厕所时,都可以用手机延续之前在电脑上的工作。
13
13
  - **在电脑上也可以无缝延续手机上的工作** - 在lark端也可以打开新的Claude进程启动新的工作,回到电脑前还可以`attach`共享操作同一个Claude进程,延续手机端的工作。
14
- - **多端共享操作** — 多个终端 + 飞书可以共享操作同一个claude进程,回到家里ssh登录到服务器上也可以通过`attach`继续操作在公司ssh登录到服务器上打开的claude进程操作。
14
+ - **多端共享操作** — 多个终端 + 飞书可以共享操作同一个claude/codex进程,回到家里ssh登录到服务器上也可以通过`attach`继续操作在公司ssh登录到服务器上打开的claude/codex进程操作。
15
15
  - **机制安全** - 完全不侵入 Claude 进程,remote 功能完全通过终端交互来实现,不必担心 Claude 进程意外崩溃导致工作进展丢失。
16
16
 
17
17
  ## 飞书端体验
@@ -68,7 +68,7 @@ remote-claude attach <会话名>
68
68
 
69
69
  1. 登录[飞书开放平台](https://open.feishu.cn/),创建企业自建应用
70
70
  2. 获取 **App ID** 和 **App Secret**
71
- 3. 用`cla`或`cl`启动一次claude, 按照交互提示填入**App ID** 和 **App Secret**
71
+ 3. 用`cla`或`cl`启动一次claude(或用cx或cdx启动一次codex), 按照交互提示填入**App ID** 和 **App Secret**
72
72
  4. [飞书开放平台]的企业自建应用页面`添加应用能力`(机器人能力)
73
73
  5. 企业自建应用页面配置事件回调(如果第3步没启动成功这里配置不了):
74
74
  - `事件与回调` -> `事件配置` -> `订阅方式`右边的笔图标 -> `选择:使用长连接接收事件` -> `点击保存` -> `下面添加事件: 接收消息 v2.0 (im.message.receive_v1)`
@@ -198,9 +198,9 @@ remote-claude attach <会话名>
198
198
  7. 企业自建应用页面: `创建版本` -> `发布到线上`
199
199
  8. 至此,完成飞书机器人配置
200
200
 
201
- #### 4.2 通过飞书机器人操作claude
201
+ #### 4.2 通过飞书机器人操作claude/codex
202
202
 
203
- 1. 从飞书搜素刚刚创建的飞书机器人(第一次搜比较慢,如果搜不到可能是忘记发布了)
203
+ 1. 从飞书搜索刚刚创建的飞书机器人(第一次搜比较慢,如果搜不到可能是忘记发布了)
204
204
  2. 飞书中与机器人对话,可用命令:
205
205
  - `/menu` 展示菜单卡片,后续操作都操作这个卡片上的按钮即可
206
206
 
package/client/client.py CHANGED
@@ -57,16 +57,55 @@ class RemoteClient:
57
57
  async def connect(self) -> bool:
58
58
  """连接到服务器"""
59
59
  if not self.socket_path.exists():
60
- print(f"错误: 会话 '{self.session_name}' 不存在")
60
+ print(
61
+ f"❌ 错误: Socket 文件不存在\n"
62
+ f" 会话名: {self.session_name}\n"
63
+ f" Socket 路径: {self.socket_path}\n"
64
+ f"\n"
65
+ f" 请使用 `python3 remote_claude.py list` 查看可用会话"
66
+ )
61
67
  return False
62
68
 
63
69
  try:
64
70
  self.reader, self.writer = await asyncio.open_unix_connection(
65
71
  path=str(self.socket_path)
66
72
  )
73
+ print(f"✅ 已连接到会话: {self.session_name}")
67
74
  return True
75
+ except ConnectionRefusedError as e:
76
+ # 检查进程状态
77
+ from utils.session import list_active_sessions
78
+ sessions = list_active_sessions()
79
+ session_exists = any(s["name"] == self.session_name for s in sessions)
80
+
81
+ print(
82
+ f"❌ 连接失败: Connection refused\n"
83
+ f" 会话名: {self.session_name}\n"
84
+ f" Socket 路径: {self.socket_path}\n"
85
+ f" 文件存在: {self.socket_path.exists()}\n"
86
+ f" 会话在列表中: {session_exists}\n"
87
+ f"\n"
88
+ f" 当前活跃会话:"
89
+ )
90
+ for s in sessions:
91
+ print(f" - {s['name']} (PID: {s.get('pid', 'N/A')})")
92
+ print(
93
+ f"\n"
94
+ f" 可能原因:\n"
95
+ f" 1. Server 进程已终止但 Socket 文件残留\n"
96
+ f" 2. Socket 文件权限错误\n"
97
+ f"\n"
98
+ f" 建议操作:\n"
99
+ f" python3 remote_claude.py kill {self.session_name}\n"
100
+ f" python3 remote_claude.py start {self.session_name}"
101
+ )
102
+ return False
68
103
  except Exception as e:
69
- print(f"连接失败: {e}")
104
+ print(
105
+ f"❌ 连接失败: {type(e).__name__}: {e}\n"
106
+ f" 会话名: {self.session_name}\n"
107
+ f" Socket 路径: {self.socket_path}"
108
+ )
70
109
  return False
71
110
 
72
111
  async def run(self):
package/init.sh CHANGED
@@ -40,6 +40,11 @@ setup_path() {
40
40
  # 不存在则创建
41
41
  [[ -f "$PROFILE" ]] || touch "$PROFILE"
42
42
 
43
+ # 未写入 source .bashrc 则追加
44
+ if ! grep -qF '.bashrc' "$PROFILE" 2>/dev/null; then
45
+ echo '[ -f "$HOME/.bashrc" ] && . "$HOME/.bashrc"' >> "$PROFILE"
46
+ fi
47
+
43
48
  # 未写入则追加
44
49
  if ! grep -qF '$HOME/.local/bin' "$PROFILE" 2>/dev/null; then
45
50
  echo "" >> "$PROFILE"
@@ -159,6 +164,19 @@ check_uv() {
159
164
  check_tmux() {
160
165
  print_header "检查 tmux"
161
166
 
167
+ # CI 模式:跳过 tmux 版本检查(Docker 环境可能没有 sudo)
168
+ if [ "$CI_MODE" = "true" ]; then
169
+ if command -v tmux &> /dev/null; then
170
+ TMUX_VERSION=$(tmux -V)
171
+ print_success "$TMUX_VERSION 已安装(CI 模式跳过版本检查)"
172
+ return
173
+ else
174
+ print_error "未找到 tmux"
175
+ WARNINGS+=("tmux 未安装,CI 模式跳过版本检查")
176
+ return
177
+ fi
178
+ fi
179
+
162
180
  REQUIRED_MAJOR=3
163
181
  REQUIRED_MINOR=6
164
182
 
@@ -581,6 +599,11 @@ main() {
581
599
  [[ "$arg" == "--npm" ]] && NPM_MODE=true
582
600
  done
583
601
 
602
+ # CI 模式:跳过 tmux 版本检查(CI 环境可能没有 sudo)
603
+ if [ -n "$CI" ]; then
604
+ export CI_MODE=true
605
+ fi
606
+
584
607
  echo ""
585
608
  echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
586
609
  echo -e "${GREEN} Remote Claude 初始化脚本${NC}"
@@ -47,6 +47,14 @@ class SessionBridge:
47
47
  async def connect(self) -> bool:
48
48
  """连接到会话"""
49
49
  if not self.socket_path.exists():
50
+ logger.error(
51
+ f"连接失败: Socket 文件不存在\n"
52
+ f" 会话名: {self.session_name}\n"
53
+ f" Socket 路径: {self.socket_path}\n"
54
+ f" 请确认:\n"
55
+ f" 1. 会话已启动 (使用 /list 查看)\n"
56
+ f" 2. 会话名拼写正确"
57
+ )
50
58
  return False
51
59
  try:
52
60
  self.reader, self.writer = await asyncio.open_unix_connection(
@@ -54,9 +62,40 @@ class SessionBridge:
54
62
  )
55
63
  self.running = True
56
64
  self._read_task = asyncio.create_task(self._read_loop())
65
+ logger.info(f"连接成功: {self.session_name}")
57
66
  return True
67
+ except FileNotFoundError:
68
+ logger.error(
69
+ f"连接失败: Socket 文件不存在\n"
70
+ f" 会话名: {self.session_name}\n"
71
+ f" Socket 路径: {self.socket_path}\n"
72
+ f" 可能原因: Socket 文件在连接前被删除"
73
+ )
74
+ return False
75
+ except ConnectionRefusedError as e:
76
+ # 检查进程状态
77
+ sessions = list_active_sessions()
78
+ session_exists = any(s["name"] == self.session_name for s in sessions)
79
+
80
+ logger.error(
81
+ f"连接失败: Connection refused\n"
82
+ f" 会话名: {self.session_name}\n"
83
+ f" Socket 路径: {self.socket_path}\n"
84
+ f" 文件存在: {self.socket_path.exists()}\n"
85
+ f" 会话在列表中: {session_exists}\n"
86
+ f" 当前活跃会话: {[s['name'] for s in sessions]}\n"
87
+ f" 可能原因:\n"
88
+ f" 1. Server 进程已终止但 Socket 文件残留\n"
89
+ f" 2. Socket 文件权限错误\n"
90
+ f" 建议: 使用 /kill {self.session_name} 清理后重新启动"
91
+ )
92
+ return False
58
93
  except Exception as e:
59
- logger.error(f"连接失败: {e}")
94
+ logger.error(
95
+ f"连接失败: {type(e).__name__}: {e}\n"
96
+ f" 会话名: {self.session_name}\n"
97
+ f" Socket 路径: {self.socket_path}"
98
+ )
60
99
  return False
61
100
 
62
101
  async def disconnect(self):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
@@ -5,7 +5,7 @@ ClaudeParser 和 CodexParser 均继承此类。
5
5
  """
6
6
 
7
7
  from abc import ABC, abstractmethod
8
- from typing import List
8
+ from typing import Any, Dict, List
9
9
 
10
10
  import pyte
11
11
 
@@ -29,6 +29,9 @@ class BaseParser(ABC):
29
29
  last_input_ansi_text: str = ''
30
30
  last_parse_timing: str = ''
31
31
  last_layout_mode: str = 'normal'
32
+ # 诊断用:记录最近一次 parse 的区域统计与组件统计
33
+ last_region_stats: Dict[str, Any] = None
34
+ last_component_stats: Dict[str, Any] = None
32
35
 
33
36
  @abstractmethod
34
37
  def parse(self, screen: pyte.Screen) -> List:
@@ -11,10 +11,14 @@
11
11
  """
12
12
 
13
13
  import codecs
14
+ import logging
14
15
  import pyte
16
+ from pyte.screens import Margins
15
17
  from typing import List, Tuple, Optional
16
18
  from dataclasses import dataclass
17
19
 
20
+ logger = logging.getLogger(__name__)
21
+
18
22
 
19
23
  # ANSI 颜色名称到飞书颜色的映射
20
24
  # 飞书 Markdown 只支持: green, red, grey 三种颜色
@@ -55,11 +59,38 @@ class StyledSpan:
55
59
  strikethrough: bool = False
56
60
 
57
61
 
58
- class _DimAwareScreen(pyte.Screen):
59
- """pyte 不支持 SGR 2 (dim/faint),子类化后将 dim 映射为灰色前景"""
62
+ class _ExtendedStream(pyte.Stream):
63
+ """pyte.Stream 子类:补充 SU(ESC[nS)和 SD(ESC[nT)支持。
64
+
65
+ pyte 原生 CSI dispatch 不包含 'S' 和 'T',Codex 的 ESC[2S 会被完全忽略。
66
+ """
67
+ csi = {**pyte.Stream.csi, 'S': 'scroll_up', 'T': 'scroll_down'}
68
+
69
+
70
+ class _DebugStream(_ExtendedStream):
71
+ """_ExtendedStream 子类:记录被 pyte 丢弃的未识别转义序列到 DEBUG 日志。
72
+
73
+ 仅在 --debug-screen 开启时使用,替换默认 _ExtendedStream。
74
+ """
75
+ _undef_logger = logging.getLogger('pyte.Stream.undefined')
76
+
77
+ def _undefined(self, *args, **kwargs):
78
+ self._undef_logger.debug(f"pyte 未识别序列: args={args!r} kwargs={kwargs!r}")
79
+
80
+
81
+ class _DimAwareScreen(pyte.HistoryScreen):
82
+ """pyte.HistoryScreen 子类:
83
+ 1. SGR 2 (dim/faint) 映射为灰色前景
84
+ 2. 补充 SU/SD(ESC[nS/ESC[nT)滚动支持,滚出行保存到 history.top
85
+ """
60
86
 
61
87
  # dim 状态下使用的灰色(与终端 dim 效果近似)
62
88
  _DIM_FG = '808080'
89
+ _DEFAULT_HISTORY = 5000
90
+
91
+ def __init__(self, columns, lines, **kwargs):
92
+ kwargs.setdefault('history', self._DEFAULT_HISTORY)
93
+ super().__init__(columns, lines, **kwargs)
63
94
 
64
95
  def select_graphic_rendition(self, *attrs):
65
96
  # 检查是否包含 SGR 2 (dim)
@@ -70,13 +101,35 @@ class _DimAwareScreen(pyte.Screen):
70
101
  if has_dim and self.cursor.attrs.fg == 'default':
71
102
  self.cursor.attrs = self.cursor.attrs._replace(fg=self._DIM_FG)
72
103
 
104
+ def scroll_up(self, count=1):
105
+ """ESC[nS — SU:上滚 n 行,滚出行保存到 history.top"""
106
+ top, bottom = self.margins or Margins(0, self.lines - 1)
107
+ saved_y = self.cursor.y
108
+ self.cursor.y = bottom
109
+ for _ in range(count):
110
+ self.index()
111
+ self.cursor.y = saved_y
112
+
113
+ def scroll_down(self, count=1):
114
+ """ESC[nT — SD:下滚 n 行"""
115
+ top, bottom = self.margins or Margins(0, self.lines - 1)
116
+ saved_y = self.cursor.y
117
+ self.cursor.y = top
118
+ for _ in range(count):
119
+ self.reverse_index()
120
+ self.cursor.y = saved_y
121
+
73
122
 
74
123
  class RichTextRenderer:
75
124
  """将 pyte 屏幕内容转换为飞书富文本"""
76
125
 
77
- def __init__(self, columns: int = 200, lines: int = 500):
126
+ def __init__(self, columns: int = 200, lines: int = 500, debug_stream: bool = False):
78
127
  self.screen = _DimAwareScreen(columns, lines)
79
- self.stream = pyte.Stream(self.screen)
128
+ # debug_stream=True 时使用 _DebugStream,记录 pyte 未识别序列
129
+ if debug_stream:
130
+ self.stream = _DebugStream(self.screen)
131
+ else:
132
+ self.stream = _ExtendedStream(self.screen)
80
133
  self.stream.use_utf8 = True
81
134
  # 增量 UTF-8 解码器:保持跨 chunk 的解码状态,防止多字节序列被截断
82
135
  self._utf8_decoder = codecs.getincrementaldecoder('utf-8')(errors='replace')
@@ -84,6 +137,18 @@ class RichTextRenderer:
84
137
  def feed(self, data: bytes) -> None:
85
138
  """喂入原始终端数据"""
86
139
  text = self._utf8_decoder.decode(data)
140
+ # 改动4:检测 UTF-8 解码产生的替换字符(U+FFFD),说明原始字节流有非法序列
141
+ if '\ufffd' in text:
142
+ # 定位替换字符位置(最多报告前 5 处)
143
+ positions = [i for i, c in enumerate(text) if c == '\ufffd'][:5]
144
+ contexts = []
145
+ for pos in positions:
146
+ ctx_start = max(0, pos - 4)
147
+ ctx_end = min(len(text), pos + 5)
148
+ ctx = repr(text[ctx_start:ctx_end])
149
+ contexts.append(f"pos={pos} ctx={ctx}")
150
+ logger.warning(f"UTF-8 解码替换字符(\\ufffd): count={text.count(chr(0xfffd))} "
151
+ f"positions={contexts}")
87
152
  self.stream.feed(text)
88
153
 
89
154
  def clear(self) -> None:
package/server/server.py CHANGED
@@ -42,6 +42,15 @@ from utils.session import (
42
42
 
43
43
  logger = logging.getLogger('Server')
44
44
 
45
+ # Server 日志级别配置
46
+ _SERVER_LOG_LEVEL = os.getenv("SERVER_LOG_LEVEL", "INFO").upper()
47
+ SERVER_LOG_LEVEL_MAP = {
48
+ "DEBUG": logging.DEBUG,
49
+ "INFO": logging.INFO,
50
+ "WARNING": logging.WARNING,
51
+ "ERROR": logging.ERROR,
52
+ }.get(_SERVER_LOG_LEVEL, logging.INFO) # 默认 INFO
53
+
45
54
  # 加载用户 .env 配置(支持 CLAUDE_COMMAND 等)
46
55
  try:
47
56
  from dotenv import load_dotenv
@@ -59,6 +68,63 @@ except Exception:
59
68
  HISTORY_BUFFER_SIZE = 100 * 1024 # 100KB
60
69
 
61
70
 
71
+ # ── VirtualScreen:将 HistoryScreen.history.top + buffer 合并为统一视图 ──────
72
+
73
+ class _VirtualBuffer:
74
+ """统一访问接口:history rows [0..offset) + screen.buffer [offset..offset+lines)"""
75
+ __slots__ = ('_history', '_buffer', '_offset')
76
+
77
+ def __init__(self, history, buffer, offset):
78
+ self._history = history # list[dict],来自 list(screen.history.top)
79
+ self._buffer = buffer # pyte screen.buffer
80
+ self._offset = offset # len(history)
81
+
82
+ def __getitem__(self, row):
83
+ if row < self._offset:
84
+ return self._history[row]
85
+ return self._buffer[row - self._offset]
86
+
87
+
88
+ class _VirtualCursor:
89
+ """模拟 pyte cursor,y 偏移 history 行数"""
90
+ __slots__ = ('y',)
91
+
92
+ def __init__(self, y: int):
93
+ self.y = y
94
+
95
+
96
+ class VirtualScreen:
97
+ """HistoryScreen wrapper:将 history.top + buffer 合并为 parser 可直接读取的统一屏幕视图。
98
+
99
+ parser 无需任何修改:
100
+ - _split_regions 从 cursor.y+5 向上扫描找分割线,先找到当前屏幕中的分割线就停了
101
+ - output_rows 自然包含 history 中的行号(0 到 offset-1)
102
+ - _get_row_text/buffer[row] 通过 VirtualBuffer 透明返回对应行
103
+ """
104
+ __slots__ = ('_screen', '_history', '_offset')
105
+
106
+ def __init__(self, screen):
107
+ self._screen = screen
108
+ self._history = list(screen.history.top) if hasattr(screen, 'history') else []
109
+ self._offset = len(self._history)
110
+
111
+ @property
112
+ def columns(self):
113
+ return self._screen.columns
114
+
115
+ @property
116
+ def lines(self):
117
+ return self._offset + self._screen.lines
118
+
119
+ @property
120
+ def cursor(self):
121
+ return _VirtualCursor(self._offset + self._screen.cursor.y)
122
+
123
+ @property
124
+ def buffer(self):
125
+ return _VirtualBuffer(self._history, self._screen.buffer, self._offset)
126
+
127
+
62
128
  # ── 全量快照架构 ─────────────────────────────────────────────────────────────
63
129
 
64
130
  @dataclass
@@ -120,17 +186,25 @@ class OutputWatcher:
120
186
  self._debug_verbose = debug_verbose # --debug-verbose 开启后输出 indicator/repr 等诊断信息
121
187
  safe_name = _safe_filename(session_name)
122
188
  self._debug_file = f"/tmp/remote-claude/{safe_name}_messages.log"
189
+ # PTY 原始字节流日志(仅 --debug-screen 开启时使用)
190
+ self._raw_log_fd = None
191
+ if debug_screen:
192
+ raw_log_path = f"/tmp/remote-claude/{safe_name}_pty_raw.log"
193
+ try:
194
+ self._raw_log_fd = open(raw_log_path, "a", encoding="ascii", buffering=1)
195
+ except Exception:
196
+ pass
123
197
  # 持久化 pyte 渲染器:PTY 数据直接实时喂入,flush 时直接读 screen
124
198
  from rich_text_renderer import RichTextRenderer
125
- self._renderer = RichTextRenderer(columns=cols, lines=rows)
199
+ self._renderer = RichTextRenderer(columns=cols, lines=rows, debug_stream=debug_screen)
126
200
  # 持久化解析器(跨帧保留 dot_row_cache);由调用方注入(可插拔架构)
127
201
  import logging as _logging
128
202
  _logging.getLogger('ComponentParser').setLevel(_logging.DEBUG)
129
- _blink_handler = _logging.FileHandler(
130
- f"/tmp/remote-claude/{safe_name}_blink.log"
131
- )
132
- _blink_handler.setFormatter(_logging.Formatter('%(asctime)s %(message)s', '%H:%M:%S'))
133
- _logging.getLogger('ComponentParser').addHandler(_blink_handler)
203
+ # 创建专用 logger 替换直接文件写入
204
+ self._blink_logger = _logging.getLogger(f'OutputWatcher.{self._session_name}.blink')
205
+ self._blink_logger.setLevel(_logging.DEBUG)
206
+ self._flush_logger = _logging.getLogger(f'OutputWatcher.{self._session_name}.flush')
207
+ self._flush_logger.setLevel(_logging.DEBUG)
134
208
  if parser is None:
135
209
  from parsers import ClaudeParser
136
210
  parser = ClaudeParser()
@@ -166,6 +240,15 @@ class OutputWatcher:
166
240
  # 诊断日志:记录 PTY 数据到达
167
241
  if data:
168
242
  logger.debug(f"[diag-feed] len={len(data)} data={data[:50]!r}")
243
+ # 改动3:--debug-screen 开启时追加原始字节到 _pty_raw.log(base64 编码)
244
+ if self._raw_log_fd is not None and data:
245
+ try:
246
+ import base64 as _b64
247
+ ts = time.strftime('%H:%M:%S') + f'.{int(time.time() * 1000) % 1000:03d}'
248
+ encoded = _b64.b64encode(data).decode('ascii')
249
+ self._raw_log_fd.write(f"{ts} len={len(data)} {encoded}\n")
250
+ except Exception:
251
+ pass
169
252
  try:
170
253
  loop = asyncio.get_running_loop()
171
254
  except RuntimeError:
@@ -208,8 +291,10 @@ class OutputWatcher:
208
291
  self._write_screen_debug(self._renderer.screen)
209
292
  t1 = time.time()
210
293
 
211
- # 1. 解析(ScreenParser 不改;pyte 已在 feed() 里实时更新)
212
- components = self._parser.parse(self._renderer.screen)
294
+ # 1. 构建 VirtualScreen(history.top + buffer)并解析
295
+ # _write_screen_debug 仍用原始 screen(只写当前帧快照,不含 history)
296
+ vscreen = VirtualScreen(self._renderer.screen)
297
+ components = self._parser.parse(vscreen)
213
298
  input_text = self._parser.last_input_text
214
299
  input_ansi_text = self._parser.last_input_ansi_text
215
300
 
@@ -247,10 +332,10 @@ class OutputWatcher:
247
332
  last_ob_blink = b.is_streaming
248
333
  last_ob_content = b.content[:40]
249
334
  last_ob_start_row = b.start_row
250
- # 直接读 pyte screen buffer 获取原始字符属性(用于变化检测)
335
+ # vscreen.buffer 获取原始字符属性(支持 history 行)
251
336
  if b.start_row >= 0:
252
337
  try:
253
- char = self._renderer.screen.buffer[b.start_row][0]
338
+ char = vscreen.buffer[b.start_row][0]
254
339
  last_ob_indicator_char = str(getattr(char, 'data', ''))
255
340
  last_ob_indicator_fg = str(getattr(char, 'fg', ''))
256
341
  last_ob_indicator_bold = bool(getattr(char, 'bold', False))
@@ -258,11 +343,7 @@ class OutputWatcher:
258
343
  pass
259
344
  break
260
345
  if last_ob_blink:
261
- _blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
262
- _blink_log.write(
263
- f"[{time.strftime('%H:%M:%S')}] raw-blink last_ob={last_ob_content!r}\n"
264
- )
265
- _blink_log.close()
346
+ self._blink_logger.debug(f"raw-blink last_ob={last_ob_content!r}")
266
347
 
267
348
  self._frame_window.append(_FrameObs(
268
349
  ts=now,
@@ -308,27 +389,23 @@ class OutputWatcher:
308
389
  if len(chars) > 1 or len(fgs) > 1 or len(bolds) > 1:
309
390
  window_block_active = True
310
391
  # 记录字符变化触发原因
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"
392
+ self._blink_logger.debug(
393
+ f"char-change row={last_ob_start_row}"
394
+ f" chars={chars} fgs={fgs} bolds={bolds}"
315
395
  )
316
- _blink_log.close()
317
396
 
318
397
  if window_block_active:
319
398
  for b in reversed(visible_blocks):
320
399
  if isinstance(b, OutputBlock):
321
- _blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
322
- _blink_log.write(
323
- f"[{time.strftime('%H:%M:%S')}] win-smooth last_ob={b.content[:40]!r}"
400
+ self._blink_logger.debug(
401
+ f"win-smooth last_ob={b.content[:40]!r}"
324
402
  f" window_frames={len(window_list)}"
325
- f" blink_frames={sum(1 for o in window_list if o.block_blink)}\n"
403
+ f" blink_frames={sum(1 for o in window_list if o.block_blink)}"
326
404
  )
327
- _blink_log.close()
328
405
  b.is_streaming = True
329
406
  break
330
407
 
331
- # 5. 直接使用 visible_blocks(pyte 2000 行已保留全部历史)
408
+ # 5. 直接使用 visible_blocks(VirtualScreen 已包含 history.top + 当前屏幕)
332
409
  all_blocks = visible_blocks
333
410
 
334
411
  # 5b. 后台 agent 摘要:BottomBar 有 agent 信息但面板未展开时,
@@ -373,13 +450,12 @@ class OutputWatcher:
373
450
  self._on_snapshot(window)
374
451
  t4 = time.time()
375
452
 
376
- with open(f"/tmp/remote-claude/{_safe_filename(self._session_name)}_flush.log", "a") as _f:
377
- _f.write(
378
- f"[flush] screen_log={1000*(t1-t0):.1f}ms parse={1000*(t2-t1):.1f}ms "
379
- f"msg_log={1000*(t3-t2):.1f}ms snapshot={1000*(t4-t3):.1f}ms "
380
- f"total={1000*(t4-t0):.1f}ms rows={self._rows}\n"
381
- f" └─ {self._parser.last_parse_timing}\n"
382
- )
453
+ self._flush_logger.debug(
454
+ f"[flush] screen_log={1000*(t1-t0):.1f}ms parse={1000*(t2-t1):.1f}ms "
455
+ f"msg_log={1000*(t3-t2):.1f}ms snapshot={1000*(t4-t3):.1f}ms "
456
+ f"total={1000*(t4-t0):.1f}ms rows={self._rows}\n"
457
+ f" └─ {self._parser.last_parse_timing}"
458
+ )
383
459
 
384
460
  except Exception as e:
385
461
  print(f"[OutputWatcher] flush 失败: {e}")
@@ -513,8 +589,80 @@ class OutputWatcher:
513
589
  except Exception:
514
590
  pass
515
591
 
592
+ @staticmethod
593
+ def _char_to_ansi(char) -> str:
594
+ """将 pyte Char 的颜色/样式属性转为 ANSI SGR 前缀序列。
595
+
596
+ 返回带 ANSI 转义的字符字符串,调用方需在行尾追加 \\033[0m 重置。
597
+ 仅在属性非默认时才输出对应 SGR,降低输出体积。
598
+ """
599
+ # 标准颜色名到 ANSI 码
600
+ _FG_NAMES = {
601
+ 'black': 30, 'red': 31, 'green': 32, 'yellow': 33,
602
+ 'blue': 34, 'magenta': 35, 'cyan': 36, 'white': 37,
603
+ 'brown': 33, # pyte 把 ANSI 33 解析为 brown
604
+ }
605
+ _BG_NAMES = {
606
+ 'black': 40, 'red': 41, 'green': 42, 'yellow': 43,
607
+ 'blue': 44, 'magenta': 45, 'cyan': 46, 'white': 47,
608
+ 'brown': 43,
609
+ }
610
+ _BRIGHT_FG = {
611
+ 'brightblack': 90, 'brightred': 91, 'brightgreen': 92, 'brightyellow': 93,
612
+ 'brightblue': 94, 'brightmagenta': 95, 'brightcyan': 96, 'brightwhite': 97,
613
+ }
614
+ _BRIGHT_BG = {
615
+ 'brightblack': 100, 'brightred': 101, 'brightgreen': 102, 'brightyellow': 103,
616
+ 'brightblue': 104, 'brightmagenta': 105, 'brightcyan': 106, 'brightwhite': 107,
617
+ }
618
+
619
+ sgr = []
620
+
621
+ # bold / blink
622
+ if getattr(char, 'bold', False):
623
+ sgr.append('1')
624
+ if getattr(char, 'blink', False):
625
+ sgr.append('5')
626
+
627
+ # 前景色
628
+ fg = getattr(char, 'fg', 'default')
629
+ if fg and fg != 'default':
630
+ fg_low = str(fg).lower()
631
+ if fg_low in _FG_NAMES:
632
+ sgr.append(str(_FG_NAMES[fg_low]))
633
+ elif fg_low in _BRIGHT_FG:
634
+ sgr.append(str(_BRIGHT_FG[fg_low]))
635
+ elif len(fg_low) == 6 and all(c in '0123456789abcdef' for c in fg_low):
636
+ # 24-bit true color
637
+ r = int(fg_low[0:2], 16)
638
+ g = int(fg_low[2:4], 16)
639
+ b = int(fg_low[4:6], 16)
640
+ sgr.append(f'38;2;{r};{g};{b}')
641
+
642
+ # 背景色
643
+ bg = getattr(char, 'bg', 'default')
644
+ if bg and bg != 'default':
645
+ bg_low = str(bg).lower()
646
+ if bg_low in _BG_NAMES:
647
+ sgr.append(str(_BG_NAMES[bg_low]))
648
+ elif bg_low in _BRIGHT_BG:
649
+ sgr.append(str(_BRIGHT_BG[bg_low]))
650
+ elif len(bg_low) == 6 and all(c in '0123456789abcdef' for c in bg_low):
651
+ r = int(bg_low[0:2], 16)
652
+ g = int(bg_low[2:4], 16)
653
+ b = int(bg_low[4:6], 16)
654
+ sgr.append(f'48;2;{r};{g};{b}')
655
+
656
+ if sgr:
657
+ return f'\033[{";".join(sgr)}m{char.data}\033[0m'
658
+ return char.data
659
+
516
660
  def _write_screen_debug(self, screen):
517
- """将 pyte 屏幕内容写入调试文件(_screen.log)"""
661
+ """将 pyte 屏幕内容写入调试文件(_screen.log)
662
+
663
+ 每个字符的 fg/bg 颜色通过 ANSI SGR 序列直接嵌入,
664
+ cat _screen.log 即可在终端看到与 pyte 渲染一致的着色效果。
665
+ """
518
666
  base = f"/tmp/remote-claude/{_safe_filename(self._session_name)}"
519
667
  try:
520
668
  # pyte 屏幕快照(覆盖写,只保留最新一帧)
@@ -526,20 +674,51 @@ class OutputWatcher:
526
674
  "",
527
675
  ]
528
676
  for row in range(scan_limit + 1):
529
- buf = [' '] * screen.columns
530
- for col, char in screen.buffer[row].items():
531
- buf[col] = char.data
532
- rstripped = ''.join(buf).rstrip()
533
- if not rstripped:
534
- lines.append(f"{row:3d} |")
677
+ # 构建带颜色的行内容(用于终端直接 cat 显示)
678
+ colored_buf = []
679
+ plain_buf = [' '] * screen.columns
680
+ for col in range(screen.columns):
681
+ char = screen.buffer[row].get(col)
682
+ if char is None:
683
+ colored_buf.append(' ')
684
+ continue
685
+ plain_buf[col] = char.data
686
+ colored_buf.append(self._char_to_ansi(char))
687
+
688
+ rstripped_plain = ''.join(plain_buf).rstrip()
689
+ if not rstripped_plain:
690
+ # 检查是否有背景色(对 Codex 背景色分割线很重要)
691
+ row_bgs = set()
692
+ for col in range(screen.columns):
693
+ char = screen.buffer[row].get(col)
694
+ if char is not None:
695
+ bg = getattr(char, 'bg', 'default')
696
+ if bg and bg != 'default':
697
+ row_bgs.add(str(bg))
698
+ if row_bgs:
699
+ bg_str = ','.join(sorted(row_bgs))
700
+ lines.append(f"{row:3d} |[bg:{bg_str} ···]")
701
+ else:
702
+ lines.append(f"{row:3d} |")
535
703
  continue
704
+
536
705
  try:
537
706
  c0 = screen.buffer[row][0]
538
707
  col0_blink = getattr(c0, "blink", False)
539
708
  except (KeyError, IndexError):
540
709
  col0_blink = False
710
+
541
711
  blink_mark = "B" if col0_blink else " "
542
- lines.append(f"{row:3d}{blink_mark}|{rstripped}")
712
+ # rstrip 着色内容:找最后一个有内容的列号(正确处理 CJK 占 2 列的情况)
713
+ # plain_buf 按列号索引,len(rstripped_plain) 会因 CJK 字符导致列数偏小
714
+ last_col = 0
715
+ for col in range(screen.columns - 1, -1, -1):
716
+ if plain_buf[col] != ' ':
717
+ last_col = col + 1
718
+ break
719
+ colored_line = ''.join(colored_buf[:last_col])
720
+ lines.append(f"{row:3d}{blink_mark}|{colored_line}")
721
+
543
722
  with open(screen_path, "w", encoding="utf-8") as f:
544
723
  f.write("\n".join(lines))
545
724
  except Exception:
@@ -707,7 +886,7 @@ class ProxyServer:
707
886
 
708
887
  # PTY 终端尺寸:与 lark_client 的 pyte 渲染器保持一致
709
888
  PTY_COLS = 220
710
- PTY_ROWS = 2000
889
+ PTY_ROWS = 100 # 行数缩减至 100,历史内容通过 HistoryScreen.history.top 保存(5000 行容量)
711
890
 
712
891
  def _get_parser(self):
713
892
  """根据 cli_type 返回对应的解析器实例"""
@@ -727,6 +906,14 @@ class ProxyServer:
727
906
  not hasattr(handler, '_debug_handler'):
728
907
  root_logger.removeHandler(handler)
729
908
 
909
+ # 重定向 sys.stderr 到 ~/.remote-claude/server.error.log
910
+ # 注意:这不会影响外层的 2>> startup.log,但 Python 的 stderr 输出会走这里
911
+ # 适用于:print(..., file=sys.stderr)、logging 的 StreamHandler 等
912
+ # 不适用于:C 扩展模块直接写文件描述符 2、解释器崩溃等底层错误
913
+ error_log_path = os.path.expanduser('~/.remote-claude/server.error.log')
914
+ sys.stderr = open(error_log_path, 'w', encoding='utf-8')
915
+ logger.info(f"已重定向 stderr 到 {error_log_path}")
916
+
730
917
  # 添加运行阶段日志文件
731
918
  safe_name = _safe_filename(self.session_name)
732
919
  runtime_handler = logging.FileHandler(
@@ -740,6 +927,21 @@ class ProxyServer:
740
927
  runtime_handler._runtime_handler = True # 标记,方便后续清理
741
928
  root_logger.addHandler(runtime_handler)
742
929
 
930
+ # DEBUG 级别时额外记录调试日志到独立文件
931
+ if SERVER_LOG_LEVEL_MAP == logging.DEBUG:
932
+ debug_handler = logging.FileHandler(
933
+ f"{SOCKET_DIR}/{safe_name}_debug.log",
934
+ encoding="utf-8"
935
+ )
936
+ debug_handler.setLevel(logging.DEBUG)
937
+ debug_handler.setFormatter(logging.Formatter(
938
+ "%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
939
+ datefmt="%Y-%m-%d %H:%M:%S"
940
+ ))
941
+ debug_handler._debug_handler = True # 标记,方便后续清理
942
+ root_logger.addHandler(debug_handler)
943
+ logger.info(f"已启用 DEBUG 日志: {safe_name}_debug.log")
944
+
743
945
  logger.info(f"日志已切换到运行阶段: {safe_name}_server.log")
744
946
 
745
947
  def _get_effective_cmd(self) -> str:
@@ -942,7 +1144,7 @@ class ProxyServer:
942
1144
  """处理终端大小变化:同步更新 PTY 和 pyte 渲染尺寸,清空 raw buffer。
943
1145
  Claude 收到 SIGWINCH 后会全屏重绘,buffer 清空后自然恢复为新尺寸的完整屏幕数据。"""
944
1146
  try:
945
- # output_watcher 的 rows 固定为 PTY_ROWS(2000),不跟随客户端终端尺寸变化
1147
+ # output_watcher 的 rows 固定为 PTY_ROWS(100),不跟随客户端终端尺寸变化
946
1148
  # terminal client 直接渲染 PTY 原始输出,不依赖 output_watcher,无需同步 rows
947
1149
  self.output_watcher.resize(msg.cols, self.PTY_ROWS)
948
1150
  winsize = struct.pack('HHHH', msg.rows, msg.cols, 0, 0)
package/utils/session.py CHANGED
@@ -99,7 +99,15 @@ def tmux_create_session(session_name: str, command: str, detached: bool = True)
99
99
  if detached:
100
100
  args.append("-d")
101
101
  args.extend(["-x", "200", "-y", "50"]) # 默认大小
102
- args.append(command)
102
+
103
+ # 将 stderr 重定向到 startup.log,捕获 Python 启动错误(如 ModuleNotFoundError)
104
+ # startup.log 位于 ~/.remote-claude/startup.log
105
+ startup_log = USER_DATA_DIR / "startup.log"
106
+ startup_log.parent.mkdir(parents=True, exist_ok=True)
107
+ # 直接在命令末尾添加重定向,使用 str(startup_log) 确保路径正确展开
108
+ command_with_stderr = f"{command} 2>> {startup_log}"
109
+
110
+ args.append(command_with_stderr)
103
111
 
104
112
  import logging as _logging
105
113
  _logging.getLogger('Start').info(f"tmux_cmd: {' '.join(args)}")