remote-claude 0.1.0
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 +15 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/bin/cl +20 -0
- package/bin/cla +20 -0
- package/bin/remote-claude +21 -0
- package/client/client.py +251 -0
- package/lark_client/__init__.py +3 -0
- package/lark_client/capture_output.py +91 -0
- package/lark_client/card_builder.py +1114 -0
- package/lark_client/card_service.py +250 -0
- package/lark_client/config.py +22 -0
- package/lark_client/lark_handler.py +841 -0
- package/lark_client/main.py +306 -0
- package/lark_client/output_cleaner.py +222 -0
- package/lark_client/session_bridge.py +195 -0
- package/lark_client/shared_memory_poller.py +364 -0
- package/lark_client/terminal_buffer.py +215 -0
- package/lark_client/terminal_renderer.py +69 -0
- package/package.json +41 -0
- package/pyproject.toml +14 -0
- package/remote_claude.py +518 -0
- package/scripts/check-env.sh +40 -0
- package/scripts/completion.sh +76 -0
- package/scripts/postinstall.sh +76 -0
- package/server/component_parser.py +1113 -0
- package/server/rich_text_renderer.py +301 -0
- package/server/server.py +801 -0
- package/server/shared_state.py +198 -0
- package/stats/__init__.py +38 -0
- package/stats/collector.py +325 -0
- package/stats/machine.py +47 -0
- package/stats/query.py +151 -0
- package/utils/components.py +165 -0
- package/utils/protocol.py +164 -0
- package/utils/session.py +409 -0
- package/uv.lock +703 -0
package/server/server.py
ADDED
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Proxy Server
|
|
3
|
+
|
|
4
|
+
- 使用 PTY 启动 Claude CLI
|
|
5
|
+
- 通过 Unix Socket 接受多客户端连接
|
|
6
|
+
- 管理控制权状态
|
|
7
|
+
- 广播输出到所有连接的客户端
|
|
8
|
+
- 输出历史缓存
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import pty
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
import fcntl
|
|
17
|
+
import struct
|
|
18
|
+
import termios
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
# 将项目根目录和当前目录加入 sys.path
|
|
22
|
+
# 根目录:protocol / utils / lark_client;当前目录:shared_state, component_parser, rich_text_renderer
|
|
23
|
+
_here = __import__('pathlib').Path(__file__).parent
|
|
24
|
+
sys.path.insert(0, str(_here)) # server/ → shared_state
|
|
25
|
+
sys.path.insert(0, str(_here.parent)) # 根目录 → protocol, utils, lark_client
|
|
26
|
+
from collections import deque
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
from utils.protocol import (
|
|
32
|
+
Message, MessageType, InputMessage, OutputMessage,
|
|
33
|
+
HistoryMessage, ErrorMessage, ResizeMessage,
|
|
34
|
+
encode_message, decode_message
|
|
35
|
+
)
|
|
36
|
+
from utils.session import (
|
|
37
|
+
get_socket_path, get_pid_file, ensure_socket_dir,
|
|
38
|
+
generate_client_id, cleanup_session, _safe_filename
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from stats import track as _track_stats
|
|
43
|
+
except Exception:
|
|
44
|
+
def _track_stats(*args, **kwargs): pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# 历史缓存大小(字节)
|
|
48
|
+
HISTORY_BUFFER_SIZE = 100 * 1024 # 100KB
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ── 全量快照架构 ─────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class _FrameObs:
|
|
55
|
+
"""单帧状态观测(用于时序窗口平滑)"""
|
|
56
|
+
ts: float
|
|
57
|
+
status_line: Optional[object] # 本帧的 StatusLine(None=无)
|
|
58
|
+
block_blink: bool = False # 本帧最后一个 OutputBlock 是否 is_streaming=True
|
|
59
|
+
has_background_agents: bool = False # 底部栏是否有后台 agent 信息
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ClaudeWindow:
|
|
64
|
+
"""全量快照:累积型 blocks + 状态型组件
|
|
65
|
+
|
|
66
|
+
组件分两类:
|
|
67
|
+
- 累积型 Block(blocks):OutputBlock/UserInput,随对话增长
|
|
68
|
+
- 状态型组件(status_line/bottom_bar/agent_panel/option_block):全局唯一,每帧覆盖
|
|
69
|
+
"""
|
|
70
|
+
blocks: list # 累积型:全部历史 blocks
|
|
71
|
+
status_line: object # 状态型:StatusLine | None(窗口平滑后)
|
|
72
|
+
bottom_bar: object # 状态型:BottomBar | None
|
|
73
|
+
agent_panel: object = None # 状态型:AgentPanelBlock | None(agent 管理面板)
|
|
74
|
+
option_block: object = None # 状态型:OptionBlock | None(选项交互块)
|
|
75
|
+
input_area_text: str = ''
|
|
76
|
+
input_area_ansi_text: str = ''
|
|
77
|
+
timestamp: float = 0.0
|
|
78
|
+
layout_mode: str = "normal" # "normal" | "option" | "detail" | "agent_list" | "agent_detail"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class OutputWatcher:
|
|
83
|
+
"""PTY 输出监视器:全量快照架构
|
|
84
|
+
|
|
85
|
+
PTY 输出直接实时喂给持久化 pyte Screen;flush 时 screen 已是最新状态,
|
|
86
|
+
直接 parse → 时序窗口平滑 → 累积列表合并 → 生成 ClaudeWindow 快照 → 写 debug + 共享内存。
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
WINDOW_SECONDS = 1.0
|
|
90
|
+
|
|
91
|
+
def __init__(self, session_name: str, cols: int, rows: int,
|
|
92
|
+
on_snapshot=None, debug_screen: bool = False,
|
|
93
|
+
debug_verbose: bool = False):
|
|
94
|
+
self._session_name = session_name
|
|
95
|
+
self._cols = cols
|
|
96
|
+
self._rows = rows
|
|
97
|
+
self._pending = False
|
|
98
|
+
self._on_snapshot = on_snapshot # 回调:写共享内存
|
|
99
|
+
self._debug_screen = debug_screen # --debug-screen 开启后才写 _screen.log
|
|
100
|
+
self._debug_verbose = debug_verbose # --debug-verbose 开启后输出 indicator/repr 等诊断信息
|
|
101
|
+
safe_name = _safe_filename(session_name)
|
|
102
|
+
self._debug_file = f"/tmp/remote-claude/{safe_name}_messages.log"
|
|
103
|
+
# 持久化 pyte 渲染器:PTY 数据直接实时喂入,flush 时直接读 screen
|
|
104
|
+
from rich_text_renderer import RichTextRenderer
|
|
105
|
+
self._renderer = RichTextRenderer(columns=cols, lines=rows)
|
|
106
|
+
# 持久化解析器(跨帧保留 dot_row_cache)
|
|
107
|
+
from component_parser import ScreenParser
|
|
108
|
+
import logging as _logging
|
|
109
|
+
_logging.getLogger('ComponentParser').setLevel(_logging.DEBUG)
|
|
110
|
+
_blink_handler = _logging.FileHandler(
|
|
111
|
+
f"/tmp/remote-claude/{safe_name}_blink.log"
|
|
112
|
+
)
|
|
113
|
+
_blink_handler.setFormatter(_logging.Formatter('%(asctime)s %(message)s', '%H:%M:%S'))
|
|
114
|
+
_logging.getLogger('ComponentParser').addHandler(_blink_handler)
|
|
115
|
+
self._parser = ScreenParser()
|
|
116
|
+
# 时序窗口(迁移自 MessageQueue)
|
|
117
|
+
self._frame_window: deque = deque()
|
|
118
|
+
# 最近快照(供外部读取)
|
|
119
|
+
self.last_window: Optional[ClaudeWindow] = None
|
|
120
|
+
# PTY 静止后延迟重刷:消除窗口平滑的延迟效应
|
|
121
|
+
self._reflush_handle: Optional[asyncio.TimerHandle] = None
|
|
122
|
+
|
|
123
|
+
def resize(self, cols: int, rows: int):
|
|
124
|
+
"""重建 renderer 以适应新尺寸,历史随之丢失(可接受)。
|
|
125
|
+
PTY resize 后 Claude 会全屏重绘,新 screen 自然会被填充。"""
|
|
126
|
+
self._cols = cols
|
|
127
|
+
self._rows = rows
|
|
128
|
+
from rich_text_renderer import RichTextRenderer
|
|
129
|
+
self._renderer = RichTextRenderer(columns=cols, lines=rows)
|
|
130
|
+
|
|
131
|
+
def feed(self, data: bytes):
|
|
132
|
+
self._renderer.feed(data) # 直接喂持久化 screen,不再缓存原始字节
|
|
133
|
+
try:
|
|
134
|
+
loop = asyncio.get_running_loop()
|
|
135
|
+
except RuntimeError:
|
|
136
|
+
return
|
|
137
|
+
if not self._pending:
|
|
138
|
+
self._pending = True
|
|
139
|
+
loop.call_soon(lambda: asyncio.create_task(self._flush()))
|
|
140
|
+
# PTY 静止后延迟重刷:每次 feed 重置计时器,
|
|
141
|
+
# 静止 WINDOW_SECONDS 后再 flush 一次,消除窗口平滑的延迟效应
|
|
142
|
+
# (窗口内残留的旧 blink=True 帧过期后,streaming 标记才能正确清除)
|
|
143
|
+
if self._reflush_handle:
|
|
144
|
+
self._reflush_handle.cancel()
|
|
145
|
+
self._reflush_handle = loop.call_later(
|
|
146
|
+
self.WINDOW_SECONDS,
|
|
147
|
+
self._do_reflush
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _do_reflush(self):
|
|
151
|
+
"""PTY 静止 WINDOW_SECONDS 后重新 flush 一次。
|
|
152
|
+
此时窗口内旧帧已过期,streaming/status_line 状态能正确归零。"""
|
|
153
|
+
self._reflush_handle = None
|
|
154
|
+
if not self._pending:
|
|
155
|
+
self._pending = True
|
|
156
|
+
try:
|
|
157
|
+
loop = asyncio.get_running_loop()
|
|
158
|
+
loop.call_soon(lambda: asyncio.create_task(self._flush()))
|
|
159
|
+
except RuntimeError:
|
|
160
|
+
self._pending = False
|
|
161
|
+
|
|
162
|
+
async def _flush(self):
|
|
163
|
+
self._pending = False
|
|
164
|
+
try:
|
|
165
|
+
from utils.components import StatusLine, BottomBar, Divider, OutputBlock, AgentPanelBlock, OptionBlock
|
|
166
|
+
|
|
167
|
+
t0 = time.time()
|
|
168
|
+
|
|
169
|
+
if self._debug_screen:
|
|
170
|
+
self._write_screen_debug(self._renderer.screen)
|
|
171
|
+
t1 = time.time()
|
|
172
|
+
|
|
173
|
+
# 1. 解析(ScreenParser 不改;pyte 已在 feed() 里实时更新)
|
|
174
|
+
components = self._parser.parse(self._renderer.screen)
|
|
175
|
+
input_text = self._parser.last_input_text
|
|
176
|
+
input_ansi_text = self._parser.last_input_ansi_text
|
|
177
|
+
|
|
178
|
+
# 3. 分拣:累积型 Block vs 状态型组件
|
|
179
|
+
visible_blocks = [] # 累积型:OutputBlock/UserInput
|
|
180
|
+
raw_status_line = None # 状态型
|
|
181
|
+
raw_bottom_bar = None # 状态型
|
|
182
|
+
raw_agent_panel = None # 状态型
|
|
183
|
+
raw_option_block = None # 状态型
|
|
184
|
+
for c in components:
|
|
185
|
+
if isinstance(c, StatusLine):
|
|
186
|
+
raw_status_line = c
|
|
187
|
+
elif isinstance(c, BottomBar):
|
|
188
|
+
raw_bottom_bar = c
|
|
189
|
+
elif isinstance(c, AgentPanelBlock):
|
|
190
|
+
raw_agent_panel = c
|
|
191
|
+
elif isinstance(c, OptionBlock):
|
|
192
|
+
raw_option_block = c
|
|
193
|
+
elif isinstance(c, Divider):
|
|
194
|
+
pass
|
|
195
|
+
else:
|
|
196
|
+
visible_blocks.append(c)
|
|
197
|
+
|
|
198
|
+
# 4. 时序窗口平滑(先于合并,确保累积列表存储平滑后的值)
|
|
199
|
+
now = time.time()
|
|
200
|
+
# 4a. 记录原始帧观测(必须用未平滑的原始值)
|
|
201
|
+
last_ob_blink = False
|
|
202
|
+
last_ob_content = ''
|
|
203
|
+
for b in reversed(visible_blocks):
|
|
204
|
+
if isinstance(b, OutputBlock):
|
|
205
|
+
last_ob_blink = b.is_streaming
|
|
206
|
+
last_ob_content = b.content[:40]
|
|
207
|
+
break
|
|
208
|
+
if last_ob_blink:
|
|
209
|
+
_blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
|
|
210
|
+
_blink_log.write(
|
|
211
|
+
f"[{time.strftime('%H:%M:%S')}] raw-blink last_ob={last_ob_content!r}\n"
|
|
212
|
+
)
|
|
213
|
+
_blink_log.close()
|
|
214
|
+
|
|
215
|
+
self._frame_window.append(_FrameObs(
|
|
216
|
+
ts=now,
|
|
217
|
+
status_line=raw_status_line,
|
|
218
|
+
block_blink=last_ob_blink,
|
|
219
|
+
has_background_agents=getattr(raw_bottom_bar, 'has_background_agents', False) if raw_bottom_bar else False,
|
|
220
|
+
))
|
|
221
|
+
cutoff = now - self.WINDOW_SECONDS
|
|
222
|
+
while self._frame_window and self._frame_window[0].ts < cutoff:
|
|
223
|
+
self._frame_window.popleft()
|
|
224
|
+
|
|
225
|
+
window_list = list(self._frame_window)
|
|
226
|
+
|
|
227
|
+
# 4b. status_line 平滑:
|
|
228
|
+
# 优先选窗口内最新的"活跃状态"(有 elapsed),防止 spinner 重绘间隙
|
|
229
|
+
# 屏幕遗留的已完成状态(无 elapsed)覆盖当前活跃值。
|
|
230
|
+
# 若窗口内无活跃状态帧,回退到最新非 None 值(处理任务初始/刚完成场景)。
|
|
231
|
+
_active_status = next(
|
|
232
|
+
(o.status_line for o in reversed(window_list)
|
|
233
|
+
if o.status_line is not None and o.status_line.elapsed),
|
|
234
|
+
None
|
|
235
|
+
)
|
|
236
|
+
display_status = _active_status or next(
|
|
237
|
+
(o.status_line for o in reversed(window_list) if o.status_line is not None),
|
|
238
|
+
None
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
# 4c. block blink 平滑:窗口内任意帧有 blink → streaming
|
|
242
|
+
# 修改 visible_blocks 中最后一个 OutputBlock(平滑后再合并)
|
|
243
|
+
window_block_active = any(o.block_blink for o in window_list)
|
|
244
|
+
if window_block_active:
|
|
245
|
+
for b in reversed(visible_blocks):
|
|
246
|
+
if isinstance(b, OutputBlock):
|
|
247
|
+
_blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
|
|
248
|
+
_blink_log.write(
|
|
249
|
+
f"[{time.strftime('%H:%M:%S')}] win-smooth last_ob={b.content[:40]!r}"
|
|
250
|
+
f" window_frames={len(window_list)}"
|
|
251
|
+
f" blink_frames={sum(1 for o in window_list if o.block_blink)}\n"
|
|
252
|
+
)
|
|
253
|
+
_blink_log.close()
|
|
254
|
+
b.is_streaming = True
|
|
255
|
+
break
|
|
256
|
+
|
|
257
|
+
# 5. 直接使用 visible_blocks(pyte 2000 行已保留全部历史)
|
|
258
|
+
all_blocks = visible_blocks
|
|
259
|
+
|
|
260
|
+
# 5b. 后台 agent 摘要:BottomBar 有 agent 信息但面板未展开时,
|
|
261
|
+
# 生成 summary 类型的 AgentPanelBlock(确保下游始终能感知后台 agent)
|
|
262
|
+
if raw_agent_panel is None and raw_bottom_bar and getattr(raw_bottom_bar, 'has_background_agents', False):
|
|
263
|
+
raw_agent_panel = AgentPanelBlock(
|
|
264
|
+
panel_type="summary",
|
|
265
|
+
agent_count=raw_bottom_bar.agent_count,
|
|
266
|
+
raw_text=raw_bottom_bar.agent_summary,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# 6. 构建快照
|
|
270
|
+
window = ClaudeWindow(
|
|
271
|
+
blocks=all_blocks,
|
|
272
|
+
status_line=display_status,
|
|
273
|
+
bottom_bar=raw_bottom_bar,
|
|
274
|
+
agent_panel=raw_agent_panel,
|
|
275
|
+
option_block=raw_option_block,
|
|
276
|
+
input_area_text=input_text,
|
|
277
|
+
input_area_ansi_text=input_ansi_text,
|
|
278
|
+
timestamp=now,
|
|
279
|
+
layout_mode=self._parser.last_layout_mode,
|
|
280
|
+
)
|
|
281
|
+
self.last_window = window
|
|
282
|
+
|
|
283
|
+
# 7. 输出
|
|
284
|
+
t2 = time.time()
|
|
285
|
+
self._write_window_debug(window)
|
|
286
|
+
t3 = time.time()
|
|
287
|
+
if self._on_snapshot:
|
|
288
|
+
self._on_snapshot(window)
|
|
289
|
+
t4 = time.time()
|
|
290
|
+
|
|
291
|
+
with open(f"/tmp/remote-claude/{_safe_filename(self._session_name)}_flush.log", "a") as _f:
|
|
292
|
+
_f.write(
|
|
293
|
+
f"[flush] screen_log={1000*(t1-t0):.1f}ms parse={1000*(t2-t1):.1f}ms "
|
|
294
|
+
f"msg_log={1000*(t3-t2):.1f}ms snapshot={1000*(t4-t3):.1f}ms "
|
|
295
|
+
f"total={1000*(t4-t0):.1f}ms rows={self._rows}\n"
|
|
296
|
+
f" └─ {self._parser.last_parse_timing}\n"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
except Exception as e:
|
|
300
|
+
print(f"[OutputWatcher] flush 失败: {e}")
|
|
301
|
+
|
|
302
|
+
def _write_window_debug(self, window: ClaudeWindow):
|
|
303
|
+
"""将 ClaudeWindow 快照写入调试文件"""
|
|
304
|
+
try:
|
|
305
|
+
from utils.components import OutputBlock, UserInput, OptionBlock, AgentPanelBlock
|
|
306
|
+
lines = [
|
|
307
|
+
f"=== ClaudeWindow snapshot {time.strftime('%H:%M:%S')} ===",
|
|
308
|
+
f"session={self._session_name}",
|
|
309
|
+
f"blocks={len(window.blocks)}",
|
|
310
|
+
]
|
|
311
|
+
# StatusLine
|
|
312
|
+
if window.status_line:
|
|
313
|
+
sl = window.status_line
|
|
314
|
+
if self._debug_verbose:
|
|
315
|
+
lines.append(f"status_line: {sl.raw[:120]}")
|
|
316
|
+
lines.append(f" indicator={sl.indicator!r} ansi_indicator={sl.ansi_indicator!r}")
|
|
317
|
+
lines.append(f" ansi_raw={sl.ansi_raw[:120]!r}")
|
|
318
|
+
lines.append(f" ansi_render: {sl.ansi_indicator} {sl.ansi_raw[:120]}\x1b[0m")
|
|
319
|
+
else:
|
|
320
|
+
lines.append(f"status_line: {sl.ansi_indicator} {sl.ansi_raw[:120]}\x1b[0m")
|
|
321
|
+
else:
|
|
322
|
+
lines.append("status_line: None")
|
|
323
|
+
# BottomBar
|
|
324
|
+
if window.bottom_bar:
|
|
325
|
+
bb = window.bottom_bar
|
|
326
|
+
if self._debug_verbose:
|
|
327
|
+
lines.append(f"bottom_bar: {bb.text[:120]}")
|
|
328
|
+
lines.append(f" ansi_text={bb.ansi_text[:120]!r}")
|
|
329
|
+
lines.append(f" ansi_render: {bb.ansi_text[:120]}\x1b[0m")
|
|
330
|
+
else:
|
|
331
|
+
lines.append(f"bottom_bar: {bb.ansi_text[:120]}\x1b[0m")
|
|
332
|
+
else:
|
|
333
|
+
lines.append("bottom_bar: None")
|
|
334
|
+
# AgentPanelBlock(状态型,独立于 blocks)
|
|
335
|
+
if window.agent_panel:
|
|
336
|
+
ap = window.agent_panel
|
|
337
|
+
if ap.panel_type == 'detail':
|
|
338
|
+
lines.append(f"agent_panel: detail · {ap.agent_type} › {ap.agent_name[:40]}")
|
|
339
|
+
elif ap.panel_type == 'summary':
|
|
340
|
+
lines.append(f"agent_panel: summary · {ap.agent_count} agents · {ap.raw_text[:60]}")
|
|
341
|
+
else:
|
|
342
|
+
lines.append(f"agent_panel: list · {ap.agent_count} agents")
|
|
343
|
+
if self._debug_verbose and ap.raw_text:
|
|
344
|
+
raw_preview = ap.raw_text[:120].replace('\n', '\\n')
|
|
345
|
+
lines.append(f" raw_text={raw_preview!r}")
|
|
346
|
+
else:
|
|
347
|
+
lines.append("agent_panel: None")
|
|
348
|
+
# OptionBlock(状态型,独立于 blocks)
|
|
349
|
+
if window.option_block:
|
|
350
|
+
ob = window.option_block
|
|
351
|
+
lines.append(f"option_block: sub_type={ob.sub_type} question={ob.question[:60]!r} options={len(ob.options)}")
|
|
352
|
+
else:
|
|
353
|
+
lines.append("option_block: None")
|
|
354
|
+
if self._debug_verbose:
|
|
355
|
+
lines.append(f"input_area={window.input_area_text!r}")
|
|
356
|
+
if window.input_area_ansi_text:
|
|
357
|
+
lines.append(f" ansi={window.input_area_ansi_text!r}")
|
|
358
|
+
lines.append(f" ansi_render: {window.input_area_ansi_text}\x1b[0m")
|
|
359
|
+
else:
|
|
360
|
+
if window.input_area_ansi_text:
|
|
361
|
+
lines.append(f"input_area: {window.input_area_ansi_text}\x1b[0m")
|
|
362
|
+
else:
|
|
363
|
+
lines.append(f"input_area: {window.input_area_text!r}")
|
|
364
|
+
lines.append(f"layout_mode={window.layout_mode}")
|
|
365
|
+
lines.append("")
|
|
366
|
+
for i, block in enumerate(window.blocks):
|
|
367
|
+
if isinstance(block, OutputBlock):
|
|
368
|
+
streaming = " [STREAMING]" if block.is_streaming else ""
|
|
369
|
+
if self._debug_verbose:
|
|
370
|
+
content_preview = block.content[:120].replace('\n', '\\n')
|
|
371
|
+
lines.append(f"[{i}] OutputBlock{streaming}: {content_preview}")
|
|
372
|
+
if block.indicator:
|
|
373
|
+
lines.append(f" indicator={block.indicator!r} ansi_indicator={block.ansi_indicator!r}")
|
|
374
|
+
if block.ansi_content:
|
|
375
|
+
ansi_preview = block.ansi_content[:120].replace('\n', '\\n')
|
|
376
|
+
lines.append(f" ansi_content={ansi_preview!r}")
|
|
377
|
+
ansi_render = block.ansi_content.replace('\n', '\\n')
|
|
378
|
+
indicator_prefix = (block.ansi_indicator + ' ') if block.ansi_indicator else ''
|
|
379
|
+
lines.append(f" ansi_render: {indicator_prefix}{ansi_render}\x1b[0m")
|
|
380
|
+
else:
|
|
381
|
+
if block.ansi_content:
|
|
382
|
+
ansi_render = block.ansi_content.replace('\n', '\\n')
|
|
383
|
+
indicator_prefix = (block.ansi_indicator + ' ') if block.ansi_indicator else ''
|
|
384
|
+
lines.append(f"[{i}] OutputBlock{streaming}: {indicator_prefix}{ansi_render}\x1b[0m")
|
|
385
|
+
else:
|
|
386
|
+
content_preview = block.content[:120].replace('\n', '\\n')
|
|
387
|
+
lines.append(f"[{i}] OutputBlock{streaming}: {content_preview}")
|
|
388
|
+
elif isinstance(block, UserInput):
|
|
389
|
+
if self._debug_verbose:
|
|
390
|
+
lines.append(f"[{i}] UserInput: {block.text[:80]}")
|
|
391
|
+
if block.indicator:
|
|
392
|
+
lines.append(f" indicator={block.indicator!r} ansi_indicator={block.ansi_indicator!r}")
|
|
393
|
+
if block.ansi_text:
|
|
394
|
+
lines.append(f" ansi_text={block.ansi_text[:80]!r}")
|
|
395
|
+
ansi_render = block.ansi_text.replace('\n', '\\n')
|
|
396
|
+
indicator_prefix = (block.ansi_indicator + ' ') if block.ansi_indicator else ''
|
|
397
|
+
lines.append(f" ansi_render: {indicator_prefix}{ansi_render}\x1b[0m")
|
|
398
|
+
else:
|
|
399
|
+
if block.ansi_text:
|
|
400
|
+
ansi_render = block.ansi_text.replace('\n', '\\n')
|
|
401
|
+
indicator_prefix = (block.ansi_indicator + ' ') if block.ansi_indicator else ''
|
|
402
|
+
lines.append(f"[{i}] UserInput: {indicator_prefix}{ansi_render}\x1b[0m")
|
|
403
|
+
else:
|
|
404
|
+
lines.append(f"[{i}] UserInput: {block.text[:80]}")
|
|
405
|
+
else:
|
|
406
|
+
lines.append(f"[{i}] {type(block).__name__}: {str(block)[:80]}")
|
|
407
|
+
lines.append("")
|
|
408
|
+
lines.append("-----")
|
|
409
|
+
with open(self._debug_file, "w", encoding="utf-8") as f:
|
|
410
|
+
f.write("\n".join(lines) + "\n")
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
|
|
414
|
+
def _write_screen_debug(self, screen):
|
|
415
|
+
"""将 pyte 屏幕内容写入调试文件(_screen.log)"""
|
|
416
|
+
base = f"/tmp/remote-claude/{_safe_filename(self._session_name)}"
|
|
417
|
+
try:
|
|
418
|
+
# pyte 屏幕快照(覆盖写,只保留最新一帧)
|
|
419
|
+
screen_path = base + "_screen.log"
|
|
420
|
+
scan_limit = min(screen.cursor.y + 5, screen.lines - 1)
|
|
421
|
+
lines = [
|
|
422
|
+
f"=== screen snapshot {time.strftime('%H:%M:%S')} ===",
|
|
423
|
+
f"size={screen.columns}×{screen.lines} cursor_y={screen.cursor.y} scan_limit={scan_limit}",
|
|
424
|
+
"",
|
|
425
|
+
]
|
|
426
|
+
for row in range(scan_limit + 1):
|
|
427
|
+
buf = [' '] * screen.columns
|
|
428
|
+
for col, char in screen.buffer[row].items():
|
|
429
|
+
buf[col] = char.data
|
|
430
|
+
rstripped = ''.join(buf).rstrip()
|
|
431
|
+
if not rstripped:
|
|
432
|
+
lines.append(f"{row:3d} |")
|
|
433
|
+
continue
|
|
434
|
+
try:
|
|
435
|
+
c0 = screen.buffer[row][0]
|
|
436
|
+
col0_blink = getattr(c0, "blink", False)
|
|
437
|
+
except (KeyError, IndexError):
|
|
438
|
+
col0_blink = False
|
|
439
|
+
blink_mark = "B" if col0_blink else " "
|
|
440
|
+
lines.append(f"{row:3d}{blink_mark}|{rstripped}")
|
|
441
|
+
with open(screen_path, "w", encoding="utf-8") as f:
|
|
442
|
+
f.write("\n".join(lines))
|
|
443
|
+
except Exception:
|
|
444
|
+
pass
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ── 全量快照架构 end ─────────────────────────────────────────────────────────
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class HistoryBuffer:
|
|
451
|
+
"""环形历史缓冲区"""
|
|
452
|
+
|
|
453
|
+
def __init__(self, max_size: int = HISTORY_BUFFER_SIZE):
|
|
454
|
+
self.max_size = max_size
|
|
455
|
+
self.buffer = bytearray()
|
|
456
|
+
|
|
457
|
+
def append(self, data: bytes):
|
|
458
|
+
"""追加数据"""
|
|
459
|
+
self.buffer.extend(data)
|
|
460
|
+
# 超出大小时截断前面的数据
|
|
461
|
+
if len(self.buffer) > self.max_size:
|
|
462
|
+
self.buffer = self.buffer[-self.max_size:]
|
|
463
|
+
|
|
464
|
+
def get_all(self) -> bytes:
|
|
465
|
+
"""获取所有历史数据"""
|
|
466
|
+
return bytes(self.buffer)
|
|
467
|
+
|
|
468
|
+
def clear(self):
|
|
469
|
+
"""清空缓冲区"""
|
|
470
|
+
self.buffer.clear()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
class ClientConnection:
|
|
474
|
+
"""客户端连接"""
|
|
475
|
+
|
|
476
|
+
def __init__(self, client_id: str, reader: asyncio.StreamReader,
|
|
477
|
+
writer: asyncio.StreamWriter):
|
|
478
|
+
self.client_id = client_id
|
|
479
|
+
self.reader = reader
|
|
480
|
+
self.writer = writer
|
|
481
|
+
self.buffer = b""
|
|
482
|
+
|
|
483
|
+
async def send(self, msg: Message):
|
|
484
|
+
"""发送消息"""
|
|
485
|
+
try:
|
|
486
|
+
data = encode_message(msg)
|
|
487
|
+
self.writer.write(data)
|
|
488
|
+
await self.writer.drain()
|
|
489
|
+
except Exception as e:
|
|
490
|
+
print(f"[Server] 发送消息失败 ({self.client_id}): {e}")
|
|
491
|
+
|
|
492
|
+
async def read_message(self) -> Optional[Message]:
|
|
493
|
+
"""读取一条消息"""
|
|
494
|
+
while True:
|
|
495
|
+
# 检查缓冲区中是否有完整消息
|
|
496
|
+
if b"\n" in self.buffer:
|
|
497
|
+
line, self.buffer = self.buffer.split(b"\n", 1)
|
|
498
|
+
try:
|
|
499
|
+
return decode_message(line)
|
|
500
|
+
except Exception as e:
|
|
501
|
+
print(f"[Server] 解析消息失败: {e}")
|
|
502
|
+
continue
|
|
503
|
+
|
|
504
|
+
# 读取更多数据
|
|
505
|
+
try:
|
|
506
|
+
data = await self.reader.read(4096)
|
|
507
|
+
if not data:
|
|
508
|
+
return None
|
|
509
|
+
self.buffer += data
|
|
510
|
+
except Exception:
|
|
511
|
+
return None
|
|
512
|
+
|
|
513
|
+
def close(self):
|
|
514
|
+
"""关闭连接"""
|
|
515
|
+
try:
|
|
516
|
+
self.writer.close()
|
|
517
|
+
except Exception:
|
|
518
|
+
pass
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
class ProxyServer:
|
|
522
|
+
"""Proxy Server"""
|
|
523
|
+
|
|
524
|
+
def __init__(self, session_name: str, claude_args: list = None,
|
|
525
|
+
debug_screen: bool = False, debug_verbose: bool = False):
|
|
526
|
+
self.session_name = session_name
|
|
527
|
+
self.claude_args = claude_args or []
|
|
528
|
+
self.debug_screen = debug_screen
|
|
529
|
+
self.debug_verbose = debug_verbose
|
|
530
|
+
self.socket_path = get_socket_path(session_name)
|
|
531
|
+
self.pid_file = get_pid_file(session_name)
|
|
532
|
+
|
|
533
|
+
# PTY 相关
|
|
534
|
+
self.master_fd: Optional[int] = None
|
|
535
|
+
self.child_pid: Optional[int] = None
|
|
536
|
+
|
|
537
|
+
# 客户端管理
|
|
538
|
+
self.clients: Dict[str, ClientConnection] = {}
|
|
539
|
+
|
|
540
|
+
# 历史缓存
|
|
541
|
+
self.history = HistoryBuffer()
|
|
542
|
+
|
|
543
|
+
# 共享状态 mmap(向其他进程暴露快照)
|
|
544
|
+
from shared_state import SharedStateWriter
|
|
545
|
+
self.shared_state = SharedStateWriter(session_name)
|
|
546
|
+
|
|
547
|
+
# 输出监视器(全量快照架构:PTY → pyte → 解析 → 平滑 → 合并 → 快照 → 共享内存)
|
|
548
|
+
self.output_watcher = OutputWatcher(
|
|
549
|
+
session_name=session_name,
|
|
550
|
+
cols=self.PTY_COLS, rows=self.PTY_ROWS,
|
|
551
|
+
on_snapshot=lambda w: self.shared_state.write_snapshot(w),
|
|
552
|
+
debug_screen=self.debug_screen,
|
|
553
|
+
debug_verbose=self.debug_verbose,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
# 运行状态
|
|
557
|
+
self.running = False
|
|
558
|
+
self.server: Optional[asyncio.AbstractServer] = None
|
|
559
|
+
self._start_time = time.time()
|
|
560
|
+
|
|
561
|
+
async def start(self):
|
|
562
|
+
"""启动服务器"""
|
|
563
|
+
ensure_socket_dir()
|
|
564
|
+
|
|
565
|
+
# 清理旧的 socket 文件
|
|
566
|
+
if self.socket_path.exists():
|
|
567
|
+
self.socket_path.unlink()
|
|
568
|
+
|
|
569
|
+
# 启动 PTY
|
|
570
|
+
self._start_pty()
|
|
571
|
+
|
|
572
|
+
# 写入 PID 文件
|
|
573
|
+
self.pid_file.write_text(str(os.getpid()))
|
|
574
|
+
|
|
575
|
+
# 启动 Unix Socket 服务器
|
|
576
|
+
self.server = await asyncio.start_unix_server(
|
|
577
|
+
self._handle_client,
|
|
578
|
+
path=str(self.socket_path)
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
self.running = True
|
|
582
|
+
_track_stats('session', 'start', session_name=self.session_name)
|
|
583
|
+
print(f"[Server] 已启动: {self.socket_path}")
|
|
584
|
+
|
|
585
|
+
# 启动 PTY 读取任务
|
|
586
|
+
asyncio.create_task(self._read_pty())
|
|
587
|
+
|
|
588
|
+
# 等待服务器关闭
|
|
589
|
+
async with self.server:
|
|
590
|
+
await self.server.serve_forever()
|
|
591
|
+
|
|
592
|
+
# PTY 终端尺寸:与 lark_client 的 pyte 渲染器保持一致
|
|
593
|
+
PTY_COLS = 220
|
|
594
|
+
PTY_ROWS = 2000
|
|
595
|
+
|
|
596
|
+
def _start_pty(self):
|
|
597
|
+
"""启动 PTY 并运行 Claude"""
|
|
598
|
+
pid, fd = pty.fork()
|
|
599
|
+
|
|
600
|
+
if pid == 0:
|
|
601
|
+
# 恢复 TERM 以支持 kitty keyboard protocol(Shift+Enter 等扩展键)
|
|
602
|
+
# tmux 会将 TERM 改为 tmux-256color,导致 Claude CLI 不启用 kitty protocol
|
|
603
|
+
os.environ['TERM'] = 'xterm-256color'
|
|
604
|
+
# 清除 tmux 标识变量(PTY 数据不经过 tmux,不应让 Claude CLI 误判终端环境)
|
|
605
|
+
for key in ('TMUX', 'TMUX_PANE'):
|
|
606
|
+
os.environ.pop(key, None)
|
|
607
|
+
os.execvp("claude", ["claude"] + self.claude_args)
|
|
608
|
+
else:
|
|
609
|
+
# 父进程
|
|
610
|
+
self.master_fd = fd
|
|
611
|
+
self.child_pid = pid
|
|
612
|
+
|
|
613
|
+
# 设置 PTY 终端大小(与 pyte 渲染器尺寸一致,避免 ANSI 光标错位)
|
|
614
|
+
winsize = struct.pack('HHHH', self.PTY_ROWS, self.PTY_COLS, 0, 0)
|
|
615
|
+
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
|
|
616
|
+
|
|
617
|
+
# 设置非阻塞
|
|
618
|
+
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
|
619
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
620
|
+
|
|
621
|
+
print(f"[Server] Claude 已启动 (PID: {pid}, PTY: {self.PTY_COLS}×{self.PTY_ROWS})")
|
|
622
|
+
|
|
623
|
+
async def _read_pty(self):
|
|
624
|
+
"""读取 PTY 输出并广播"""
|
|
625
|
+
loop = asyncio.get_event_loop()
|
|
626
|
+
|
|
627
|
+
while self.running and self.master_fd is not None:
|
|
628
|
+
try:
|
|
629
|
+
# 使用 asyncio 读取
|
|
630
|
+
data = await loop.run_in_executor(
|
|
631
|
+
None, self._read_pty_sync
|
|
632
|
+
)
|
|
633
|
+
if data:
|
|
634
|
+
# 保存到历史
|
|
635
|
+
self.history.append(data)
|
|
636
|
+
# 广播给所有客户端
|
|
637
|
+
await self._broadcast_output(data)
|
|
638
|
+
elif data is None:
|
|
639
|
+
# 暂时无数据(BlockingIOError),稍等继续
|
|
640
|
+
await asyncio.sleep(0.01)
|
|
641
|
+
else:
|
|
642
|
+
# os.read 返回 b"":PTY 已关闭(子进程退出)
|
|
643
|
+
break
|
|
644
|
+
except Exception as e:
|
|
645
|
+
if self.running:
|
|
646
|
+
print(f"[Server] 读取 PTY 错误: {e}")
|
|
647
|
+
break
|
|
648
|
+
|
|
649
|
+
# Claude 退出
|
|
650
|
+
print("[Server] Claude 已退出")
|
|
651
|
+
await self._shutdown()
|
|
652
|
+
|
|
653
|
+
def _read_pty_sync(self) -> Optional[bytes]:
|
|
654
|
+
"""同步读取 PTY(在线程池中运行)"""
|
|
655
|
+
try:
|
|
656
|
+
return os.read(self.master_fd, 4096)
|
|
657
|
+
except BlockingIOError:
|
|
658
|
+
return None
|
|
659
|
+
# OSError(EIO)说明子进程已退出,PTY 已关闭,向上抛出让 _read_pty 检测并退出循环
|
|
660
|
+
|
|
661
|
+
async def _handle_client(self, reader: asyncio.StreamReader,
|
|
662
|
+
writer: asyncio.StreamWriter):
|
|
663
|
+
"""处理客户端连接"""
|
|
664
|
+
client_id = generate_client_id()
|
|
665
|
+
client = ClientConnection(client_id, reader, writer)
|
|
666
|
+
self.clients[client_id] = client
|
|
667
|
+
|
|
668
|
+
print(f"[Server] 客户端连接: {client_id}")
|
|
669
|
+
_track_stats('session', 'attach', session_name=self.session_name)
|
|
670
|
+
|
|
671
|
+
# 发送历史输出
|
|
672
|
+
history_data = self.history.get_all()
|
|
673
|
+
if history_data:
|
|
674
|
+
await client.send(HistoryMessage(history_data))
|
|
675
|
+
|
|
676
|
+
# 处理客户端消息
|
|
677
|
+
try:
|
|
678
|
+
while self.running:
|
|
679
|
+
msg = await client.read_message()
|
|
680
|
+
if msg is None:
|
|
681
|
+
break
|
|
682
|
+
await self._handle_message(client_id, msg)
|
|
683
|
+
except Exception as e:
|
|
684
|
+
print(f"[Server] 客户端处理错误 ({client_id}): {e}")
|
|
685
|
+
finally:
|
|
686
|
+
# 清理
|
|
687
|
+
del self.clients[client_id]
|
|
688
|
+
client.close()
|
|
689
|
+
print(f"[Server] 客户端断开: {client_id}")
|
|
690
|
+
|
|
691
|
+
async def _handle_message(self, client_id: str, msg: Message):
|
|
692
|
+
"""处理客户端消息"""
|
|
693
|
+
if msg.type == MessageType.INPUT:
|
|
694
|
+
await self._handle_input(client_id, msg)
|
|
695
|
+
elif msg.type == MessageType.RESIZE:
|
|
696
|
+
await self._handle_resize(client_id, msg)
|
|
697
|
+
|
|
698
|
+
async def _handle_input(self, client_id: str, msg: InputMessage):
|
|
699
|
+
"""处理输入消息"""
|
|
700
|
+
try:
|
|
701
|
+
data = msg.get_data()
|
|
702
|
+
os.write(self.master_fd, data)
|
|
703
|
+
_track_stats('terminal', 'input', session_name=self.session_name,
|
|
704
|
+
value=len(data))
|
|
705
|
+
except Exception as e:
|
|
706
|
+
print(f"[Server] 写入 PTY 错误: {e}")
|
|
707
|
+
|
|
708
|
+
# 广播输入给其他客户端(飞书侧可以感知终端用户的输入内容)
|
|
709
|
+
for cid, client in list(self.clients.items()):
|
|
710
|
+
if cid != client_id:
|
|
711
|
+
try:
|
|
712
|
+
await client.send(msg)
|
|
713
|
+
except Exception:
|
|
714
|
+
pass
|
|
715
|
+
|
|
716
|
+
async def _handle_resize(self, client_id: str, msg: ResizeMessage):
|
|
717
|
+
"""处理终端大小变化:同步更新 PTY 和 pyte 渲染尺寸,清空 raw buffer。
|
|
718
|
+
Claude 收到 SIGWINCH 后会全屏重绘,buffer 清空后自然恢复为新尺寸的完整屏幕数据。"""
|
|
719
|
+
try:
|
|
720
|
+
# output_watcher 的 rows 固定为 PTY_ROWS(2000),不跟随客户端终端尺寸变化
|
|
721
|
+
# terminal client 直接渲染 PTY 原始输出,不依赖 output_watcher,无需同步 rows
|
|
722
|
+
self.output_watcher.resize(msg.cols, self.PTY_ROWS)
|
|
723
|
+
winsize = struct.pack('HHHH', msg.rows, msg.cols, 0, 0)
|
|
724
|
+
fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
|
|
725
|
+
except Exception as e:
|
|
726
|
+
print(f"[Server] 调整终端大小错误: {e}")
|
|
727
|
+
|
|
728
|
+
async def _broadcast_output(self, data: bytes):
|
|
729
|
+
"""广播输出给所有客户端,同时喂给 OutputWatcher 生成快照"""
|
|
730
|
+
self.output_watcher.feed(data)
|
|
731
|
+
msg = OutputMessage(data)
|
|
732
|
+
tasks = [client.send(msg) for client in self.clients.values()]
|
|
733
|
+
if tasks:
|
|
734
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
735
|
+
|
|
736
|
+
async def _shutdown(self):
|
|
737
|
+
"""关闭服务器"""
|
|
738
|
+
self.running = False
|
|
739
|
+
|
|
740
|
+
# 关闭所有客户端
|
|
741
|
+
for client in list(self.clients.values()):
|
|
742
|
+
client.close()
|
|
743
|
+
self.clients.clear()
|
|
744
|
+
|
|
745
|
+
# 关闭服务器
|
|
746
|
+
if self.server:
|
|
747
|
+
self.server.close()
|
|
748
|
+
await self.server.wait_closed()
|
|
749
|
+
|
|
750
|
+
# 关闭 PTY
|
|
751
|
+
if self.master_fd is not None:
|
|
752
|
+
try:
|
|
753
|
+
os.close(self.master_fd)
|
|
754
|
+
except Exception:
|
|
755
|
+
pass
|
|
756
|
+
|
|
757
|
+
# 关闭共享状态(会删除 .mq 文件)
|
|
758
|
+
elapsed = int(time.time() - self._start_time)
|
|
759
|
+
_track_stats('session', 'end', session_name=self.session_name, value=elapsed)
|
|
760
|
+
self.shared_state.close()
|
|
761
|
+
|
|
762
|
+
# 清理文件
|
|
763
|
+
cleanup_session(self.session_name)
|
|
764
|
+
|
|
765
|
+
print("[Server] 已关闭")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def run_server(session_name: str, claude_args: list = None,
|
|
769
|
+
debug_screen: bool = False, debug_verbose: bool = False):
|
|
770
|
+
"""运行服务器"""
|
|
771
|
+
server = ProxyServer(session_name, claude_args, debug_screen=debug_screen,
|
|
772
|
+
debug_verbose=debug_verbose)
|
|
773
|
+
|
|
774
|
+
# 信号处理
|
|
775
|
+
def signal_handler(signum, frame):
|
|
776
|
+
print("\n[Server] 收到退出信号")
|
|
777
|
+
asyncio.create_task(server._shutdown())
|
|
778
|
+
|
|
779
|
+
signal.signal(signal.SIGINT, signal_handler)
|
|
780
|
+
signal.signal(signal.SIGTERM, signal_handler)
|
|
781
|
+
|
|
782
|
+
# 运行
|
|
783
|
+
try:
|
|
784
|
+
asyncio.run(server.start())
|
|
785
|
+
except KeyboardInterrupt:
|
|
786
|
+
pass
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
if __name__ == "__main__":
|
|
790
|
+
import argparse
|
|
791
|
+
parser = argparse.ArgumentParser(description="Remote Claude Server")
|
|
792
|
+
parser.add_argument("session_name", help="会话名称")
|
|
793
|
+
parser.add_argument("claude_args", nargs="*", help="传递给 Claude 的参数")
|
|
794
|
+
parser.add_argument("--debug-screen", action="store_true",
|
|
795
|
+
help="开启 pyte 屏幕快照调试日志(写入 _screen.log)")
|
|
796
|
+
parser.add_argument("--debug-verbose", action="store_true",
|
|
797
|
+
help="debug 日志输出完整诊断信息(indicator、repr 等)")
|
|
798
|
+
args = parser.parse_args()
|
|
799
|
+
|
|
800
|
+
run_server(args.session_name, args.claude_args, debug_screen=args.debug_screen,
|
|
801
|
+
debug_verbose=args.debug_verbose)
|