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 +4 -0
- package/README.md +4 -4
- package/client/client.py +41 -2
- package/init.sh +23 -0
- package/lark_client/session_bridge.py +40 -1
- package/package.json +1 -1
- package/server/parsers/base_parser.py +4 -1
- package/server/rich_text_renderer.py +69 -4
- package/server/server.py +245 -43
- package/utils/session.py +9 -1
package/.env.example
CHANGED
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(
|
|
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(
|
|
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(
|
|
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
|
@@ -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
|
|
59
|
-
"""pyte
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
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.
|
|
212
|
-
|
|
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
|
-
#
|
|
335
|
+
# 读 vscreen.buffer 获取原始字符属性(支持 history 行)
|
|
251
336
|
if b.start_row >= 0:
|
|
252
337
|
try:
|
|
253
|
-
char =
|
|
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
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
f"
|
|
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
|
-
|
|
322
|
-
|
|
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)}
|
|
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(
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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)}")
|