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,195 @@
1
+ """
2
+ 会话桥接器 - 连接到 remote_claude 的 Unix Socket
3
+
4
+ 职责:连接管理 + 输入发送。
5
+ 输出处理由 SharedMemoryPoller 通过 .mq 共享内存文件负责,这里不处理。
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Optional, Callable, Dict
13
+
14
+ logging.basicConfig(level=logging.DEBUG, format='[%(name)s] %(message)s')
15
+ logger = logging.getLogger('SessionBridge')
16
+
17
+ sys.path.insert(0, str(Path(__file__).parent.parent))
18
+
19
+ from utils.protocol import (
20
+ Message, MessageType, InputMessage,
21
+ encode_message, decode_message
22
+ )
23
+ from utils.session import get_socket_path, generate_client_id, list_active_sessions
24
+
25
+
26
+ class SessionBridge:
27
+ """连接到 remote_claude 会话的桥接器(仅负责输入发送)"""
28
+
29
+ def __init__(self, session_name: str,
30
+ on_input: Optional[Callable[[str], None]] = None,
31
+ on_disconnect: Optional[Callable[[], None]] = None):
32
+ self.session_name = session_name
33
+ self.socket_path = get_socket_path(session_name)
34
+ self.client_id = generate_client_id()
35
+ self.on_input = on_input # 其他客户端输入广播回调
36
+ self.on_disconnect = on_disconnect # 服务端关闭连接时的回调
37
+
38
+ self._input_bytes = bytearray() # 终端输入字节缓冲
39
+
40
+ self.reader: Optional[asyncio.StreamReader] = None
41
+ self.writer: Optional[asyncio.StreamWriter] = None
42
+ self.buffer = b""
43
+ self.running = False
44
+ self._read_task: Optional[asyncio.Task] = None
45
+ self._manually_disconnected = False # 主动断开标志
46
+
47
+ async def connect(self) -> bool:
48
+ """连接到会话"""
49
+ if not self.socket_path.exists():
50
+ return False
51
+ try:
52
+ self.reader, self.writer = await asyncio.open_unix_connection(
53
+ path=str(self.socket_path)
54
+ )
55
+ self.running = True
56
+ self._read_task = asyncio.create_task(self._read_loop())
57
+ return True
58
+ except Exception as e:
59
+ logger.error(f"连接失败: {e}")
60
+ return False
61
+
62
+ async def disconnect(self):
63
+ """断开连接"""
64
+ self._manually_disconnected = True
65
+ self.running = False
66
+ if self._read_task:
67
+ self._read_task.cancel()
68
+ try:
69
+ await self._read_task
70
+ except asyncio.CancelledError:
71
+ pass
72
+ if self.writer:
73
+ self.writer.close()
74
+ try:
75
+ await self.writer.wait_closed()
76
+ except Exception:
77
+ pass
78
+
79
+ async def send_input(self, text: str) -> bool:
80
+ """发送输入到 Claude"""
81
+ if not self.writer or not self.running:
82
+ return False
83
+ try:
84
+ msg = InputMessage(text.encode('utf-8'), self.client_id)
85
+ self.writer.write(encode_message(msg))
86
+ await self.writer.drain()
87
+
88
+ # 发送 Escape 退出多行模式
89
+ await asyncio.sleep(0.1)
90
+ msg = InputMessage(b"\x1b", self.client_id)
91
+ self.writer.write(encode_message(msg))
92
+ await self.writer.drain()
93
+
94
+ # 发送 Enter 提交
95
+ await asyncio.sleep(0.1)
96
+ msg = InputMessage(b"\r", self.client_id)
97
+ self.writer.write(encode_message(msg))
98
+ await self.writer.drain()
99
+
100
+ return True
101
+ except Exception as e:
102
+ logger.error(f"发送失败: {e}")
103
+ return False
104
+
105
+ async def send_key(self, key: str) -> bool:
106
+ """发送单个按键到 Claude(用于交互式选项)"""
107
+ if not self.writer or not self.running:
108
+ return False
109
+ try:
110
+ logger.info(f"发送按键: {repr(key)}")
111
+ msg = InputMessage(key.encode('utf-8'), self.client_id)
112
+ self.writer.write(encode_message(msg))
113
+ await self.writer.drain()
114
+
115
+ await asyncio.sleep(0.05)
116
+ msg = InputMessage(b"\r", self.client_id)
117
+ self.writer.write(encode_message(msg))
118
+ await self.writer.drain()
119
+
120
+ return True
121
+ except Exception as e:
122
+ logger.error(f"发送按键失败: {e}")
123
+ return False
124
+
125
+ async def send_raw(self, data: bytes) -> bool:
126
+ """发送原始字节到 Claude(不自动追加回车)"""
127
+ if not self.writer or not self.running:
128
+ return False
129
+ try:
130
+ msg = InputMessage(data, self.client_id)
131
+ self.writer.write(encode_message(msg))
132
+ await self.writer.drain()
133
+ return True
134
+ except Exception as e:
135
+ logger.error(f"发送原始字节失败: {e}")
136
+ return False
137
+
138
+ async def _read_loop(self):
139
+ """读取服务器消息(OUTPUT/HISTORY 直接丢弃,由 SharedMemoryPoller 处理)"""
140
+ while self.running:
141
+ try:
142
+ msg = await asyncio.wait_for(self._read_message(), timeout=1.0)
143
+ if msg is None:
144
+ self.running = False
145
+ break
146
+ if msg.type == MessageType.INPUT and self.on_input:
147
+ self._process_input_bytes(msg.get_data())
148
+ # OUTPUT / HISTORY / STATUS 消息直接丢弃
149
+ except asyncio.TimeoutError:
150
+ continue
151
+ except asyncio.CancelledError:
152
+ break
153
+ except Exception as e:
154
+ logger.debug(f"读取错误: {e}")
155
+ break
156
+
157
+ if self.on_disconnect and not self._manually_disconnected:
158
+ try:
159
+ self.on_disconnect()
160
+ except Exception as e:
161
+ logger.error(f"on_disconnect 回调异常: {e}")
162
+
163
+ async def _read_message(self) -> Optional[Message]:
164
+ """读取一条消息"""
165
+ while True:
166
+ if b"\n" in self.buffer:
167
+ line, self.buffer = self.buffer.split(b"\n", 1)
168
+ try:
169
+ return decode_message(line)
170
+ except Exception:
171
+ continue
172
+ try:
173
+ data = await self.reader.read(4096)
174
+ if not data:
175
+ return None
176
+ self.buffer += data
177
+ except Exception:
178
+ return None
179
+
180
+ def _process_input_bytes(self, data: bytes):
181
+ """处理终端输入字节,触发 on_input 回调"""
182
+ for byte in data:
183
+ if byte in (0x0d, 0x0a): # CR or LF → 完整一行
184
+ if self._input_bytes:
185
+ text = self._input_bytes.decode('utf-8', errors='replace').strip()
186
+ if text and self.on_input:
187
+ self.on_input(text)
188
+ self._input_bytes.clear()
189
+ elif byte in (0x7f, 0x08): # backspace
190
+ if self._input_bytes:
191
+ self._input_bytes = self._input_bytes[:-1]
192
+ elif byte == 0x1b: # ESC → 清空
193
+ self._input_bytes.clear()
194
+ elif byte >= 0x20:
195
+ self._input_bytes.append(byte)
@@ -0,0 +1,364 @@
1
+ """
2
+ 共享内存轮询器 - 流式滚动卡片模型
3
+
4
+ 核心理念:没有 turn、没有 message。只有一个不断增长的 blocks 流和跟踪它的滚动窗口。
5
+
6
+ 数据流:
7
+ .mq { blocks, status_line, bottom_bar }
8
+ ↓ 每秒轮询
9
+ _poll_once(tracker)
10
+
11
+ 渲染 blocks[start_idx:] → 卡片 elements
12
+ ↓ hash diff
13
+ 同一张卡片就地更新 / 超限时冻结+开新卡
14
+ """
15
+
16
+ import asyncio
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import sys
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Dict, List, Optional, Any
25
+
26
+ logger = logging.getLogger('SharedMemoryPoller')
27
+
28
+ # 添加 server/ 目录到路径(访问 shared_state)
29
+ _root = Path(__file__).parent.parent
30
+ sys.path.insert(0, str(_root / "server"))
31
+ sys.path.insert(0, str(_root))
32
+
33
+ try:
34
+ from stats import track as _track_stats
35
+ except Exception:
36
+ def _track_stats(*args, **kwargs): pass
37
+
38
+ # ── 常量 ──────────────────────────────────────────────────────────────────────
39
+ INITIAL_WINDOW = 30 # 首次 attach 最多显示最近 30 个 blocks
40
+ MAX_CARD_BLOCKS = 50 # 单张卡片最多 50 个 blocks → 超限冻结
41
+ POLL_INTERVAL = 1.0 # 轮询间隔(秒)
42
+ RAPID_INTERVAL = 0.2 # 快速轮询间隔(秒)
43
+ RAPID_DURATION = 2.0 # 快速轮询持续时间(秒)
44
+
45
+
46
+ # ── 数据模型 ──────────────────────────────────────────────────────────────────
47
+
48
+ @dataclass
49
+ class CardSlice:
50
+ """一张飞书卡片对应的 blocks 窗口"""
51
+ card_id: str
52
+ sequence: int = 0
53
+ start_idx: int = 0 # blocks[start_idx:] 开始渲染
54
+ frozen: bool = False
55
+
56
+
57
+ @dataclass
58
+ class StreamTracker:
59
+ """单个 chat_id 的流式跟踪状态"""
60
+ chat_id: str
61
+ session_name: str
62
+ cards: List[CardSlice] = field(default_factory=list)
63
+ content_hash: str = ""
64
+ reader: Optional[Any] = None # SharedStateReader,延迟初始化
65
+
66
+
67
+ # ── 轮询器 ────────────────────────────────────────────────────────────────────
68
+
69
+ class SharedMemoryPoller:
70
+ """
71
+ 共享内存轮询器(流式滚动卡片模型)
72
+
73
+ attach 时启动轮询 Task,detach/断线时停止。
74
+ 每秒读取 .mq 文件中的 blocks 流,通过 hash diff 触发飞书卡片创建/更新。
75
+ """
76
+
77
+ def __init__(self, card_service: Any):
78
+ self._card_service = card_service
79
+ self._trackers: Dict[str, StreamTracker] = {} # chat_id → StreamTracker
80
+ self._tasks: Dict[str, asyncio.Task] = {} # chat_id → Task
81
+ self._kick_events: Dict[str, asyncio.Event] = {} # chat_id → Event(唤醒轮询)
82
+ self._rapid_until: Dict[str, float] = {} # chat_id → 快速模式截止时间
83
+
84
+ def start(self, chat_id: str, session_name: str) -> None:
85
+ """attach 成功后调用:清空旧状态,启动轮询 Task"""
86
+ self.stop(chat_id)
87
+
88
+ tracker = StreamTracker(chat_id=chat_id, session_name=session_name)
89
+ self._trackers[chat_id] = tracker
90
+ self._kick_events[chat_id] = asyncio.Event()
91
+
92
+ task = asyncio.create_task(self._poll_loop(chat_id))
93
+ task.add_done_callback(lambda t: self._on_task_done(t, chat_id))
94
+ self._tasks[chat_id] = task
95
+ logger.info(f"轮询器启动: chat_id={chat_id[:8]}..., session={session_name}")
96
+
97
+ def stop(self, chat_id: str) -> None:
98
+ """detach/断线时调用:取消 Task,清空状态,关闭 Reader"""
99
+ task = self._tasks.pop(chat_id, None)
100
+ if task:
101
+ task.cancel()
102
+
103
+ self._kick_events.pop(chat_id, None)
104
+ self._rapid_until.pop(chat_id, None)
105
+
106
+ tracker = self._trackers.pop(chat_id, None)
107
+ if tracker and tracker.reader:
108
+ try:
109
+ tracker.reader.close()
110
+ except Exception:
111
+ pass
112
+ logger.info(f"轮询器停止: chat_id={chat_id[:8]}...")
113
+
114
+ def stop_and_get_active_slice(self, chat_id: str) -> Optional['CardSlice']:
115
+ """停止轮询并返回活跃(未冻结)CardSlice,原子操作。供 detach/disconnect 就地更新卡片使用。"""
116
+ task = self._tasks.pop(chat_id, None)
117
+ if task:
118
+ task.cancel()
119
+
120
+ self._kick_events.pop(chat_id, None)
121
+ self._rapid_until.pop(chat_id, None)
122
+
123
+ tracker = self._trackers.pop(chat_id, None)
124
+ if not tracker:
125
+ return None
126
+
127
+ active = None
128
+ if tracker.cards and not tracker.cards[-1].frozen:
129
+ active = tracker.cards[-1]
130
+
131
+ if tracker.reader:
132
+ try:
133
+ tracker.reader.close()
134
+ except Exception:
135
+ pass
136
+
137
+ logger.info(f"轮询器停止(含活跃切片): chat_id={chat_id[:8]}..., active={'有' if active else '无'}")
138
+ return active
139
+
140
+ def _on_task_done(self, task: asyncio.Task, chat_id: str) -> None:
141
+ """Task 完成回调:记录异常"""
142
+ if task.cancelled():
143
+ return
144
+ exc = task.exception()
145
+ if exc:
146
+ logger.error(f"轮询 Task 异常: chat_id={chat_id[:8]}..., {exc}", exc_info=exc)
147
+
148
+ def kick(self, chat_id: str) -> None:
149
+ """触发立即轮询并进入快速轮询模式"""
150
+ self._rapid_until[chat_id] = time.time() + RAPID_DURATION
151
+ ev = self._kick_events.get(chat_id)
152
+ if ev:
153
+ ev.set()
154
+
155
+ async def _poll_loop(self, chat_id: str) -> None:
156
+ """轮询循环:支持 kick 唤醒 + 快速轮询模式"""
157
+ while True:
158
+ try:
159
+ # 动态间隔:快速模式 0.2s,常规 1.0s
160
+ rapid_until = self._rapid_until.get(chat_id, 0)
161
+ interval = RAPID_INTERVAL if time.time() < rapid_until else POLL_INTERVAL
162
+
163
+ # 等待 kick 事件或超时
164
+ kick_event = self._kick_events.get(chat_id)
165
+ if kick_event:
166
+ try:
167
+ await asyncio.wait_for(kick_event.wait(), timeout=interval)
168
+ kick_event.clear()
169
+ # kick 触发时进入快速模式
170
+ self._rapid_until[chat_id] = time.time() + RAPID_DURATION
171
+ except asyncio.TimeoutError:
172
+ pass
173
+ else:
174
+ await asyncio.sleep(interval)
175
+
176
+ tracker = self._trackers.get(chat_id)
177
+ if not tracker:
178
+ break
179
+ await self._poll_once(tracker)
180
+ except asyncio.CancelledError:
181
+ break
182
+ except Exception as e:
183
+ logger.error(f"_poll_once 异常: {e}", exc_info=True)
184
+
185
+ async def _poll_once(self, tracker: StreamTracker) -> None:
186
+ """单次轮询:读取共享内存 → diff → 创建/更新卡片"""
187
+ # 延迟初始化 Reader
188
+ if tracker.reader is None:
189
+ try:
190
+ from shared_state import get_mq_path, SharedStateReader
191
+ mq_path = get_mq_path(tracker.session_name)
192
+ if not mq_path.exists():
193
+ return
194
+ tracker.reader = SharedStateReader(tracker.session_name)
195
+ logger.info(f"Reader 初始化成功: session={tracker.session_name}")
196
+ except Exception as e:
197
+ logger.warning(f"创建 Reader 失败: {e}")
198
+ return
199
+
200
+ # 读取共享内存
201
+ try:
202
+ state = tracker.reader.read()
203
+ except Exception as e:
204
+ logger.error(f"读取共享内存失败: {e}")
205
+ tracker.reader = None
206
+ return
207
+
208
+ blocks = state.get("blocks", [])
209
+ status_line = state.get("status_line")
210
+ bottom_bar = state.get("bottom_bar")
211
+ agent_panel = state.get("agent_panel")
212
+ option_block = state.get("option_block")
213
+
214
+ # 获取活跃卡片(最后一张且未冻结)
215
+ active = None
216
+ if tracker.cards and not tracker.cards[-1].frozen:
217
+ active = tracker.cards[-1]
218
+
219
+ if not blocks and not status_line and not bottom_bar and not agent_panel and not option_block and active is None:
220
+ return # 完全无内容且无活跃卡片时不创建卡片
221
+
222
+ if active is None:
223
+ # 需要创建新卡片
224
+ await self._create_new_card(tracker, blocks, status_line, bottom_bar, agent_panel, option_block)
225
+ else:
226
+ # 有活跃卡片,检查是否需要更新
227
+ blocks_slice = blocks[active.start_idx:]
228
+
229
+ # 超限检查
230
+ if len(blocks_slice) > MAX_CARD_BLOCKS:
231
+ await self._freeze_and_split(tracker, blocks, status_line, bottom_bar, agent_panel, option_block)
232
+ return
233
+
234
+ # hash diff
235
+ new_hash = self._compute_hash(blocks_slice, status_line, bottom_bar, agent_panel, option_block)
236
+ if new_hash == tracker.content_hash:
237
+ return # 无变化
238
+
239
+ # 更新卡片
240
+ from .card_builder import build_stream_card
241
+ card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name)
242
+
243
+ active.sequence += 1
244
+ success = await self._card_service.update_card(
245
+ card_id=active.card_id,
246
+ sequence=active.sequence,
247
+ card_content=card_dict,
248
+ )
249
+
250
+ if not success:
251
+ # 降级:创建新卡片替代
252
+ logger.warning(
253
+ f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
254
+ )
255
+ _track_stats('card', 'fallback', session_name=tracker.session_name,
256
+ chat_id=tracker.chat_id)
257
+ new_card_id = await self._card_service.create_card(card_dict)
258
+ if new_card_id:
259
+ await self._card_service.send_card(tracker.chat_id, new_card_id)
260
+ active.card_id = new_card_id
261
+ active.sequence = 0
262
+ else:
263
+ _track_stats('card', 'update', session_name=tracker.session_name,
264
+ chat_id=tracker.chat_id)
265
+
266
+ tracker.content_hash = new_hash
267
+ logger.debug(
268
+ f"[UPDATE] session={tracker.session_name} blocks={len(blocks_slice)} "
269
+ f"seq={active.sequence} hash={new_hash[:8]}"
270
+ )
271
+
272
+ async def _create_new_card(
273
+ self, tracker: StreamTracker, blocks: List[dict],
274
+ status_line: Optional[dict], bottom_bar: Optional[dict],
275
+ agent_panel: Optional[dict] = None,
276
+ option_block: Optional[dict] = None,
277
+ ) -> None:
278
+ """创建新卡片(首次 attach 或冻结后)"""
279
+ if not tracker.cards:
280
+ # 首次 attach:取最近 INITIAL_WINDOW 个 blocks
281
+ start_idx = max(0, len(blocks) - INITIAL_WINDOW)
282
+ else:
283
+ # 冻结后:从上张冻结卡片的结束位置开始
284
+ last_frozen = tracker.cards[-1]
285
+ start_idx = last_frozen.start_idx + MAX_CARD_BLOCKS
286
+
287
+ blocks_slice = blocks[start_idx:]
288
+ if not blocks_slice and not status_line and not bottom_bar and not agent_panel and not option_block:
289
+ return
290
+
291
+ from .card_builder import build_stream_card
292
+ card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name)
293
+ card_id = await self._card_service.create_card(card_dict)
294
+
295
+ if card_id:
296
+ await self._card_service.send_card(tracker.chat_id, card_id)
297
+ tracker.cards.append(CardSlice(card_id=card_id, start_idx=start_idx))
298
+ tracker.content_hash = self._compute_hash(blocks_slice, status_line, bottom_bar, agent_panel, option_block)
299
+ _track_stats('card', 'create', session_name=tracker.session_name,
300
+ chat_id=tracker.chat_id)
301
+ logger.info(
302
+ f"[NEW] session={tracker.session_name} start_idx={start_idx} "
303
+ f"blocks={len(blocks_slice)} card_id={card_id}"
304
+ )
305
+ else:
306
+ logger.warning(f"create_card 失败 session={tracker.session_name}")
307
+
308
+ async def _freeze_and_split(
309
+ self, tracker: StreamTracker, blocks: List[dict],
310
+ status_line: Optional[dict], bottom_bar: Optional[dict],
311
+ agent_panel: Optional[dict] = None,
312
+ option_block: Optional[dict] = None,
313
+ ) -> None:
314
+ """冻结当前卡片 + 开新卡"""
315
+ active = tracker.cards[-1]
316
+
317
+ # 冻结当前卡片(只保留前 MAX_CARD_BLOCKS 个 blocks,移除状态区和按钮)
318
+ frozen_blocks = blocks[active.start_idx:active.start_idx + MAX_CARD_BLOCKS]
319
+ from .card_builder import build_stream_card
320
+ frozen_card = build_stream_card(frozen_blocks, None, None, is_frozen=True)
321
+ active.sequence += 1
322
+ await self._card_service.update_card(active.card_id, active.sequence, frozen_card)
323
+ active.frozen = True
324
+ _track_stats('card', 'freeze', session_name=tracker.session_name,
325
+ chat_id=tracker.chat_id)
326
+ logger.info(
327
+ f"[FREEZE] session={tracker.session_name} card_id={active.card_id} "
328
+ f"blocks=[{active.start_idx}:{active.start_idx + MAX_CARD_BLOCKS}]"
329
+ )
330
+
331
+ # 创建新卡片
332
+ new_start = active.start_idx + MAX_CARD_BLOCKS
333
+ new_blocks = blocks[new_start:]
334
+ if not new_blocks:
335
+ return
336
+
337
+ new_card_dict = build_stream_card(new_blocks, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name)
338
+ new_card_id = await self._card_service.create_card(new_card_dict)
339
+ if new_card_id:
340
+ await self._card_service.send_card(tracker.chat_id, new_card_id)
341
+ tracker.cards.append(CardSlice(card_id=new_card_id, start_idx=new_start))
342
+ tracker.content_hash = self._compute_hash(new_blocks, status_line, bottom_bar, agent_panel, option_block)
343
+ logger.info(
344
+ f"[NEW after FREEZE] session={tracker.session_name} start_idx={new_start} "
345
+ f"blocks={len(new_blocks)} card_id={new_card_id}"
346
+ )
347
+
348
+ @staticmethod
349
+ def _compute_hash(
350
+ blocks: list, status_line: Optional[dict],
351
+ bottom_bar: Optional[dict], agent_panel: Optional[dict] = None,
352
+ option_block: Optional[dict] = None,
353
+ ) -> str:
354
+ """计算内容 hash(用于 diff)"""
355
+ data = {
356
+ "blocks": blocks,
357
+ "status_line": status_line,
358
+ "bottom_bar": bottom_bar,
359
+ "agent_panel": agent_panel,
360
+ "option_block": option_block,
361
+ }
362
+ return hashlib.md5(
363
+ json.dumps(data, ensure_ascii=False, sort_keys=True).encode()
364
+ ).hexdigest()