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.
@@ -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)